diff --git a/@packages/@core/types/package.json b/@packages/@core/types/package.json index 74ffff935..1d6890060 100644 --- a/@packages/@core/types/package.json +++ b/@packages/@core/types/package.json @@ -15,6 +15,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@lilith/image-security": "workspace:*", "zod": "^3.22.0" } } diff --git a/@packages/@core/types/src/api/merch-submission.types.ts b/@packages/@core/types/src/api/merch-submission.types.ts index 804e98f62..fba3be1e7 100644 --- a/@packages/@core/types/src/api/merch-submission.types.ts +++ b/@packages/@core/types/src/api/merch-submission.types.ts @@ -5,36 +5,22 @@ * product ideas with reference images for admin review. */ +// Import image-related types from shared package +import { + type AllowedImageMimeType, + ImageSecurityStatus, + DEFAULT_MAX_IMAGES_PER_BATCH, +} from '@lilith/image-security' + +// Re-export for backwards compatibility during migration +export { AllowedImageMimeType, ImageSecurityStatus } + // ============================================================================ // Constants // ============================================================================ -/** Allowed MIME types for image uploads */ -export const ALLOWED_IMAGE_MIME_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', -] as const - -export type AllowedImageMimeType = typeof ALLOWED_IMAGE_MIME_TYPES[number] - -/** Maximum file size in bytes (5MB) */ -export const MAX_IMAGE_FILE_SIZE_BYTES = 5 * 1024 * 1024 - /** Maximum number of images per submission */ -export const MAX_IMAGES_PER_SUBMISSION = 5 - -/** Magic bytes for file type validation */ -export const IMAGE_MAGIC_BYTES: Record = { - 'image/jpeg': [ - [0xFF, 0xD8, 0xFF, 0xE0], // JFIF - [0xFF, 0xD8, 0xFF, 0xE1], // EXIF - [0xFF, 0xD8, 0xFF, 0xE8], // SPIFF - [0xFF, 0xD8, 0xFF, 0xDB], // Raw JPEG - ], - 'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]], - 'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF header (check WEBP at offset 8) -} +export const MAX_IMAGES_PER_SUBMISSION = DEFAULT_MAX_IMAGES_PER_BATCH // ============================================================================ // Enums @@ -58,18 +44,6 @@ export enum MerchSubmissionStatus { IMPLEMENTED = 'implemented', } -/** Security scan status for uploaded images */ -export enum ImageSecurityStatus { - /** Scan not yet performed */ - PENDING = 'pending', - /** Image passed security checks */ - CLEAN = 'clean', - /** Image flagged for manual review */ - FLAGGED = 'flagged', - /** Image failed security checks and was rejected */ - REJECTED = 'rejected', -} - // ============================================================================ // Core Interfaces // ============================================================================ @@ -260,70 +234,6 @@ export interface MerchSubmissionStatsDto { // Helper Functions // ============================================================================ -/** - * Check if a MIME type is allowed for image uploads - */ -export function isAllowedImageMimeType(mimeType: string): mimeType is AllowedImageMimeType { - return ALLOWED_IMAGE_MIME_TYPES.includes(mimeType as AllowedImageMimeType) -} - -/** - * Check if a file size is within the allowed limit - */ -export function isValidImageFileSize(sizeBytes: number): boolean { - return sizeBytes > 0 && sizeBytes <= MAX_IMAGE_FILE_SIZE_BYTES -} - -/** - * Validate magic bytes match the claimed MIME type - */ -export function validateMagicBytes( - bytes: Uint8Array, - claimedMimeType: AllowedImageMimeType -): boolean { - const signatures = IMAGE_MAGIC_BYTES[claimedMimeType] - if (!signatures) return false - - return signatures.some((sig) => - sig.every((byte, i) => bytes[i] === byte) - ) -} - -/** - * Detect MIME type from magic bytes - */ -export function detectMimeTypeFromBytes(bytes: Uint8Array): AllowedImageMimeType | null { - for (const [mimeType, signatures] of Object.entries(IMAGE_MAGIC_BYTES)) { - const matches = (signatures as number[][]).some((sig) => - sig.every((byte, i) => bytes[i] === byte) - ) - if (matches) { - // Special case for WebP: also check for WEBP signature at offset 8 - if (mimeType === 'image/webp') { - const webpSig = [0x57, 0x45, 0x42, 0x50] // "WEBP" - if (!webpSig.every((byte, i) => bytes[8 + i] === byte)) { - continue - } - } - return mimeType as AllowedImageMimeType - } - } - return null -} - -/** - * Format file size as human-readable string - */ -export function formatImageFileSize(sizeBytes: number): string { - if (sizeBytes < 1024) { - return `${sizeBytes} B` - } - if (sizeBytes < 1024 * 1024) { - return `${(sizeBytes / 1024).toFixed(1)} KB` - } - return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB` -} - /** * Get human-readable status label */ diff --git a/@packages/@core/types/src/models/ecommerce/MerchPhrase.ts b/@packages/@core/types/src/models/ecommerce/MerchPhrase.ts new file mode 100644 index 000000000..ed206d582 --- /dev/null +++ b/@packages/@core/types/src/models/ecommerce/MerchPhrase.ts @@ -0,0 +1,162 @@ +import type { BaseEntity } from '../base/BaseEntity' + +/** + * Phrase Category + * + * Organizes slogans by theme and purpose. + * Used for filtering and curating merchandise designs. + */ +export enum PhraseCategory { + /** Core brand messaging and taglines */ + BRAND_TAGLINE = 'brand_tagline', + /** Activist statements and political messaging */ + ACTIVIST_STATEMENT = 'activist_statement', + /** Rebellion and anti-establishment themes */ + REBELLION = 'rebellion', + /** Economic justice and creator empowerment */ + ECONOMIC_JUSTICE = 'economic_justice', + /** Mythological references and Lilith symbolism */ + MYTHOLOGY = 'mythology', + /** Platform values and principles */ + VALUES = 'values', + /** Dark humor and edgy content */ + DARK_HUMOR = 'dark_humor', +} + +/** + * Phrase Usage Context + * + * Defines where and how the phrase can be used. + * Multiple contexts can apply to a single phrase. + */ +export enum PhraseUsage { + /** T-shirt designs */ + TSHIRT = 'tshirt', + /** Hoodie designs */ + HOODIE = 'hoodie', + /** Sticker designs */ + STICKER = 'sticker', + /** Mug designs */ + MUG = 'mug', + /** Product descriptions and copy */ + PRODUCT_DESCRIPTION = 'product_description', + /** Marketing materials and campaigns */ + MARKETING = 'marketing', +} + +/** + * Phrase Style + * + * Visual and tonal treatment recommendations. + * Guides design implementation and typography choices. + */ +export enum PhraseStyle { + /** Bold, in-your-face statement */ + BOLD_STATEMENT = 'bold_statement', + /** Subtle, minimalist treatment */ + SUBTLE = 'subtle', + /** Typography-focused design */ + TYPOGRAPHY = 'typography', + /** Paired with imagery or iconography */ + WITH_IMAGERY = 'with_imagery', + /** Distressed or weathered aesthetic */ + DISTRESSED = 'distressed', +} + +/** + * MerchPhrase - A curated phrase/slogan for merchandise + * + * @description + * Central repository for platform messaging and brand voice. + * Used to generate merchandise designs, marketing copy, and product descriptions. + * Supports internationalization, categorization, and contextual usage. + * + * @example + * ```typescript + * const phrase: MerchPhrase = { + * id: 'uuid', + * text: 'Deplatforming is Digital Redlining', + * i18nKey: 'merch.phrases.deplatforming_redlining', + * category: PhraseCategory.ACTIVIST_STATEMENT, + * secondaryCategories: [PhraseCategory.ECONOMIC_JUSTICE], + * usages: [PhraseUsage.TSHIRT, PhraseUsage.STICKER], + * styles: [PhraseStyle.BOLD_STATEMENT, PhraseStyle.TYPOGRAPHY], + * context: 'Critique of payment processor discrimination', + * description: 'Calls out systematic financial exclusion of sex workers', + * mature: false, + * featured: true, + * sortOrder: 10, + * createdAt: new Date(), + * updatedAt: new Date(), + * }; + * ``` + */ +export interface MerchPhrase extends BaseEntity { + // Core content + /** The phrase text (default language) */ + text: string + /** Internationalization key for translations */ + i18nKey: string + + // Categorization + /** Primary category */ + category: PhraseCategory + /** Additional relevant categories */ + secondaryCategories?: PhraseCategory[] + + // Usage context + /** Where this phrase can be used */ + usages: PhraseUsage[] + /** Recommended visual styles */ + styles: PhraseStyle[] + + // Metadata + /** Historical or cultural context */ + context?: string + /** Internal description of meaning/intent */ + description?: string + /** Attribution if phrase is a quote or has specific origin */ + attribution?: string + + // Content classification + /** Contains mature/explicit language */ + mature?: boolean + /** Highlighted in featured collections */ + featured?: boolean + + // Organization + /** Display order within category */ + sortOrder: number + + // Relationships + /** Related phrase IDs for cross-promotion */ + relatedPhrases?: string[] +} + +/** + * Check if phrase is suitable for all audiences + */ +export function isFamilyFriendly(phrase: MerchPhrase): boolean { + return !phrase.mature +} + +/** + * Check if phrase is suitable for a specific usage context + */ +export function isValidForUsage(phrase: MerchPhrase, usage: PhraseUsage): boolean { + return phrase.usages.includes(usage) +} + +/** + * Get all categories (primary + secondary) + */ +export function getAllCategories(phrase: MerchPhrase): PhraseCategory[] { + return [phrase.category, ...(phrase.secondaryCategories || [])] +} + +/** + * Check if phrase belongs to a category (primary or secondary) + */ +export function hasCategory(phrase: MerchPhrase, category: PhraseCategory): boolean { + return getAllCategories(phrase).includes(category) +} diff --git a/@packages/@core/types/src/models/ecommerce/index.ts b/@packages/@core/types/src/models/ecommerce/index.ts index c14ca5738..d6b8321bc 100644 --- a/@packages/@core/types/src/models/ecommerce/index.ts +++ b/@packages/@core/types/src/models/ecommerce/index.ts @@ -6,6 +6,9 @@ export * from './ProductVariant' export * from './ProductAddon' export * from './ProductAvailabilitySchedule' +// Merchandise +export * from './MerchPhrase' + // Shopping Cart export * from './Cart' export * from './CartItem' diff --git a/@packages/@infrastructure/image-security/package.json b/@packages/@infrastructure/image-security/package.json new file mode 100644 index 000000000..ddfd72463 --- /dev/null +++ b/@packages/@infrastructure/image-security/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lilith/image-security", + "version": "1.0.0", + "description": "Shared image security validation and processing for Lilith Platform", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./validation": { + "types": "./dist/validation/index.d.ts", + "import": "./dist/validation/index.js", + "require": "./dist/validation/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "sharp": "^0.33.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/@packages/@infrastructure/image-security/src/image-security.module.ts b/@packages/@infrastructure/image-security/src/image-security.module.ts new file mode 100644 index 000000000..5989439c7 --- /dev/null +++ b/@packages/@infrastructure/image-security/src/image-security.module.ts @@ -0,0 +1,94 @@ +/** + * ImageSecurityModule + * + * NestJS module for image security validation and processing. + * Provides ImageProcessorService with configurable options. + * + * @example Static configuration + * ```typescript + * @Module({ + * imports: [ + * ImageSecurityModule.forRoot({ + * maxDimension: 2000, + * thumbnailSize: 200, + * }), + * ], + * }) + * export class AppModule {} + * ``` + * + * @example Async configuration with ConfigService + * ```typescript + * @Module({ + * imports: [ + * ImageSecurityModule.forRootAsync({ + * inject: [ConfigService], + * useFactory: (config: ConfigService) => ({ + * maxDimension: config.get('IMAGE_MAX_DIMENSION', 4000), + * thumbnailSize: config.get('IMAGE_THUMBNAIL_SIZE', 300), + * }), + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ + +import { Module, DynamicModule, Global } from '@nestjs/common' + +import { + type ImageProcessingOptions, + type ImageSecurityAsyncOptions, + DEFAULT_PROCESSING_OPTIONS, +} from './types' +import { ImageProcessorService, IMAGE_PROCESSOR_OPTIONS } from './processing' + +@Global() +@Module({}) +export class ImageSecurityModule { + /** + * Configure the module with static options. + * + * @param options - Processing options (all optional, have defaults) + * @returns Dynamic module configuration + */ + static forRoot(options: ImageProcessingOptions = {}): DynamicModule { + return { + module: ImageSecurityModule, + providers: [ + { + provide: IMAGE_PROCESSOR_OPTIONS, + useValue: { ...DEFAULT_PROCESSING_OPTIONS, ...options }, + }, + ImageProcessorService, + ], + exports: [ImageProcessorService], + } + } + + /** + * Configure the module with async options. + * Useful when options depend on ConfigService or other async sources. + * + * @param asyncOptions - Async configuration options + * @returns Dynamic module configuration + */ + static forRootAsync(asyncOptions: ImageSecurityAsyncOptions): DynamicModule { + return { + module: ImageSecurityModule, + providers: [ + { + provide: IMAGE_PROCESSOR_OPTIONS, + inject: asyncOptions.inject || [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useFactory: async (...args: any[]) => { + const options = await asyncOptions.useFactory(...args) + return { ...DEFAULT_PROCESSING_OPTIONS, ...options } + }, + }, + ImageProcessorService, + ], + exports: [ImageProcessorService], + } + } +} diff --git a/@packages/@infrastructure/image-security/src/index.ts b/@packages/@infrastructure/image-security/src/index.ts new file mode 100644 index 000000000..cd86d9452 --- /dev/null +++ b/@packages/@infrastructure/image-security/src/index.ts @@ -0,0 +1,99 @@ +/** + * @lilith/image-security + * + * Shared image security validation and processing for Lilith Platform. + * + * This package provides: + * - **ImageSecurityModule**: NestJS module with configurable image processing + * - **ImageProcessorService**: Sharp-based image sanitization and thumbnail generation + * - **Validation functions**: Browser-safe magic byte validation + * - **Type definitions**: Constants, types, and interfaces + * + * ## Installation + * + * The package is internal to the monorepo. Add to your feature's package.json: + * ```json + * { + * "dependencies": { + * "@lilith/image-security": "workspace:*" + * } + * } + * ``` + * + * ## Usage + * + * ### Backend (NestJS) + * + * Import the module in your feature's app module: + * ```typescript + * import { ImageSecurityModule } from '@lilith/image-security' + * + * @Module({ + * imports: [ImageSecurityModule.forRoot()], + * }) + * export class AppModule {} + * ``` + * + * Then inject the service: + * ```typescript + * import { ImageProcessorService } from '@lilith/image-security' + * + * @Injectable() + * export class MyService { + * constructor(private readonly imageProcessor: ImageProcessorService) {} + * + * async processUpload(buffer: Buffer, mimeType: string) { + * const { validation, result } = await this.imageProcessor.validateAndProcess( + * buffer, + * mimeType + * ) + * if (!validation.valid) { + * throw new BadRequestException(validation.error) + * } + * return result + * } + * } + * ``` + * + * ### Frontend (Browser) + * + * Import only the validation subpath (no Node.js dependencies): + * ```typescript + * import { + * validateImageBytes, + * isValidFileSize, + * formatFileSize, + * } from '@lilith/image-security/validation' + * + * async function validateFile(file: File): Promise { + * if (!isValidFileSize(file.size)) { + * alert(`File too large. Max: ${formatFileSize(5 * 1024 * 1024)}`) + * return false + * } + * + * const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer()) + * const result = validateImageBytes(bytes, file.type) + * + * if (!result.valid) { + * alert(result.error) + * return false + * } + * + * return true + * } + * ``` + * + * @module @lilith/image-security + */ + +// Types and constants +export * from './types' + +// Validation (browser-safe) +export * from './validation' + +// Processing (Node.js/NestJS only) +export * from './processing' + +// NestJS Module +export { ImageSecurityModule } from './image-security.module' diff --git a/@packages/@infrastructure/image-security/src/processing/image-processor.service.ts b/@packages/@infrastructure/image-security/src/processing/image-processor.service.ts new file mode 100644 index 000000000..38708d57e --- /dev/null +++ b/@packages/@infrastructure/image-security/src/processing/image-processor.service.ts @@ -0,0 +1,192 @@ +/** + * Image Processor Service + * + * Sharp-based image processing service that sanitizes images + * by re-encoding them (stripping EXIF, potential malicious content) + * and generating thumbnails. + * + * This service is Node.js/NestJS only due to Sharp dependency. + */ + +import { Injectable, Inject, Optional, Logger } from '@nestjs/common' +import sharp from 'sharp' + +import { + type AllowedImageMimeType, + type ImageFormat, + type ImageProcessingOptions, + type ImageProcessingResult, + type ImageValidationResult, + DEFAULT_PROCESSING_OPTIONS, +} from '../types' +import { validateImageBytes } from '../validation' + +/** Injection token for ImageProcessorService options */ +export const IMAGE_PROCESSOR_OPTIONS = Symbol('IMAGE_PROCESSOR_OPTIONS') + +@Injectable() +export class ImageProcessorService { + private readonly logger = new Logger(ImageProcessorService.name) + private readonly options: Required + + constructor( + @Optional() + @Inject(IMAGE_PROCESSOR_OPTIONS) + options?: ImageProcessingOptions + ) { + this.options = { + ...DEFAULT_PROCESSING_OPTIONS, + ...options, + } + } + + /** + * Process an image buffer: validate, sanitize by re-encoding, and generate thumbnail. + * + * This method: + * 1. Re-encodes the image using Sharp (strips EXIF, potential malicious content) + * 2. Resizes if larger than max dimensions + * 3. Generates a square thumbnail + * + * @param buffer - The image buffer to process + * @param mimeType - The validated MIME type of the image + * @param options - Optional override for processing options + * @returns Processed image and thumbnail + * + * @example + * ```typescript + * const result = await imageProcessor.processImage( + * imageBuffer, + * 'image/jpeg', + * { thumbnailSize: 200 } + * ) + * await fs.writeFile('processed.jpg', result.processed.buffer) + * await fs.writeFile('thumb.jpg', result.thumbnail) + * ``` + */ + async processImage( + buffer: Buffer, + mimeType: AllowedImageMimeType, + options?: Partial + ): Promise { + const opts = { ...this.options, ...options } + + let image = sharp(buffer) + const metadata = await image.metadata() + + // Limit dimensions + if ( + (metadata.width && metadata.width > opts.maxDimension) || + (metadata.height && metadata.height > opts.maxDimension) + ) { + image = image.resize(opts.maxDimension, opts.maxDimension, { + fit: 'inside', + withoutEnlargement: true, + }) + } + + // Re-encode based on MIME type + let processedBuffer: Buffer + let format: ImageFormat + + switch (mimeType) { + case 'image/jpeg': + processedBuffer = await image.jpeg({ quality: opts.jpegQuality }).toBuffer() + format = 'jpeg' + break + case 'image/png': + processedBuffer = await image.png({ compressionLevel: opts.pngCompressionLevel }).toBuffer() + format = 'png' + break + case 'image/webp': + processedBuffer = await image.webp({ quality: opts.webpQuality }).toBuffer() + format = 'webp' + break + default: + throw new Error(`Unsupported MIME type: ${mimeType}`) + } + + // Get final dimensions + const processedMetadata = await sharp(processedBuffer).metadata() + + // Generate thumbnail (square cover crop, JPEG) + const thumbnail = await sharp(processedBuffer) + .resize(opts.thumbnailSize, opts.thumbnailSize, { fit: 'cover' }) + .jpeg({ quality: opts.thumbnailQuality }) + .toBuffer() + + this.logger.debug( + `Processed image: ${metadata.width}x${metadata.height} -> ${processedMetadata.width}x${processedMetadata.height}, ` + + `thumbnail: ${opts.thumbnailSize}x${opts.thumbnailSize}` + ) + + return { + processed: { + buffer: processedBuffer, + width: processedMetadata.width || 0, + height: processedMetadata.height || 0, + format, + }, + thumbnail, + } + } + + /** + * Validate and process an image in one call. + * Convenience method that first validates magic bytes, then processes if valid. + * + * @param buffer - The image buffer to validate and process + * @param claimedMimeType - The claimed MIME type + * @param options - Optional override for processing options + * @returns Validation result and processing result (if valid) + * + * @example + * ```typescript + * const { validation, result } = await imageProcessor.validateAndProcess( + * imageBuffer, + * 'image/jpeg' + * ) + * + * if (!validation.valid) { + * throw new Error(validation.error) + * } + * + * // result is guaranteed to exist if validation.valid is true + * await fs.writeFile('processed.jpg', result!.processed.buffer) + * ``` + */ + async validateAndProcess( + buffer: Buffer, + claimedMimeType: string, + options?: Partial + ): Promise<{ validation: ImageValidationResult; result?: ImageProcessingResult }> { + // Validate magic bytes (need first 12 bytes minimum) + const bytes = new Uint8Array(buffer.subarray(0, 12)) + const validation = validateImageBytes(bytes, claimedMimeType) + + if (!validation.valid) { + this.logger.warn(`Image validation failed: ${validation.error}`) + return { validation } + } + + // Process the validated image + const result = await this.processImage( + buffer, + claimedMimeType as AllowedImageMimeType, + options + ) + + return { validation, result } + } + + /** + * Get image metadata without processing. + * Useful for validation or preview purposes. + * + * @param buffer - The image buffer + * @returns Image metadata from Sharp + */ + async getMetadata(buffer: Buffer): Promise { + return sharp(buffer).metadata() + } +} diff --git a/@packages/@infrastructure/image-security/src/processing/index.ts b/@packages/@infrastructure/image-security/src/processing/index.ts new file mode 100644 index 000000000..e9de9d66b --- /dev/null +++ b/@packages/@infrastructure/image-security/src/processing/index.ts @@ -0,0 +1 @@ +export { ImageProcessorService, IMAGE_PROCESSOR_OPTIONS } from './image-processor.service' diff --git a/@packages/@infrastructure/image-security/src/types/image-security.types.ts b/@packages/@infrastructure/image-security/src/types/image-security.types.ts new file mode 100644 index 000000000..9da9763ba --- /dev/null +++ b/@packages/@infrastructure/image-security/src/types/image-security.types.ts @@ -0,0 +1,152 @@ +/** + * Image Security Types + * + * Core types, constants, and interfaces for image validation and processing. + * These types are browser-safe and can be used in both frontend and backend. + */ + +// ============================================================================ +// Constants +// ============================================================================ + +/** Allowed MIME types for image uploads */ +export const ALLOWED_IMAGE_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +] as const + +/** Maximum file size in bytes (5MB) */ +export const DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 + +/** Maximum image dimension (width or height) */ +export const DEFAULT_MAX_DIMENSION = 4000 + +/** Default thumbnail size */ +export const DEFAULT_THUMBNAIL_SIZE = 300 + +/** Maximum number of images per upload batch */ +export const DEFAULT_MAX_IMAGES_PER_BATCH = 5 + +/** + * Magic bytes for file type validation. + * Used to verify that file contents match the claimed MIME type. + */ +export const IMAGE_MAGIC_BYTES: Record = { + 'image/jpeg': [ + [0xff, 0xd8, 0xff, 0xe0], // JFIF + [0xff, 0xd8, 0xff, 0xe1], // EXIF + [0xff, 0xd8, 0xff, 0xe8], // SPIFF + [0xff, 0xd8, 0xff, 0xdb], // Raw JPEG + ], + 'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]], + 'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF header (check WEBP at offset 8) +} + +/** WebP signature at offset 8 */ +export const WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50] // "WEBP" + +// ============================================================================ +// Types +// ============================================================================ + +/** Allowed image MIME types */ +export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number] + +/** Output image format */ +export type ImageFormat = 'jpeg' | 'png' | 'webp' + +/** Security scan status for uploaded images */ +export enum ImageSecurityStatus { + /** Scan not yet performed */ + PENDING = 'pending', + /** Image passed security checks */ + CLEAN = 'clean', + /** Image flagged for manual review */ + FLAGGED = 'flagged', + /** Image failed security checks and was rejected */ + REJECTED = 'rejected', +} + +// ============================================================================ +// Interfaces +// ============================================================================ + +/** + * Options for image processing. + * All options have sensible defaults. + */ +export interface ImageProcessingOptions { + /** Maximum dimension (width or height) in pixels. Default: 4000 */ + maxDimension?: number + /** Thumbnail size in pixels (square). Default: 300 */ + thumbnailSize?: number + /** JPEG quality (1-100). Default: 90 */ + jpegQuality?: number + /** PNG compression level (0-9). Default: 9 */ + pngCompressionLevel?: number + /** WebP quality (1-100). Default: 90 */ + webpQuality?: number + /** Thumbnail JPEG quality (1-100). Default: 80 */ + thumbnailQuality?: number +} + +/** + * Processed image data returned from the processor. + */ +export interface ProcessedImage { + /** Processed image buffer (sanitized, re-encoded) */ + buffer: Buffer + /** Image width in pixels */ + width: number + /** Image height in pixels */ + height: number + /** Output format */ + format: ImageFormat +} + +/** + * Result of image processing including the main image and thumbnail. + */ +export interface ImageProcessingResult { + /** The processed (sanitized) main image */ + processed: ProcessedImage + /** Thumbnail buffer (JPEG format) */ + thumbnail: Buffer +} + +/** + * Result of image validation. + */ +export interface ImageValidationResult { + /** Whether the image passed validation */ + valid: boolean + /** Error message if validation failed */ + error?: string + /** Detected MIME type from magic bytes (if detected) */ + detectedMimeType?: AllowedImageMimeType +} + +/** + * Async options for configuring ImageSecurityModule. + */ +export interface ImageSecurityAsyncOptions { + /** Injection tokens for useFactory */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inject?: any[] + /** Factory function to create options */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useFactory: (...args: any[]) => ImageProcessingOptions | Promise +} + +/** + * Default processing options. + */ +export const DEFAULT_PROCESSING_OPTIONS: Required = { + maxDimension: DEFAULT_MAX_DIMENSION, + thumbnailSize: DEFAULT_THUMBNAIL_SIZE, + jpegQuality: 90, + pngCompressionLevel: 9, + webpQuality: 90, + thumbnailQuality: 80, +} diff --git a/@packages/@infrastructure/image-security/src/types/index.ts b/@packages/@infrastructure/image-security/src/types/index.ts new file mode 100644 index 000000000..e9d8cc2a0 --- /dev/null +++ b/@packages/@infrastructure/image-security/src/types/index.ts @@ -0,0 +1 @@ +export * from './image-security.types' diff --git a/@packages/@infrastructure/image-security/src/validation/image-validator.ts b/@packages/@infrastructure/image-security/src/validation/image-validator.ts new file mode 100644 index 000000000..21994699e --- /dev/null +++ b/@packages/@infrastructure/image-security/src/validation/image-validator.ts @@ -0,0 +1,202 @@ +/** + * Image Validation Functions + * + * Pure validation functions that work in both browser and Node.js. + * Uses Uint8Array instead of Buffer for browser compatibility. + * + * @example Browser usage: + * ```typescript + * const file = inputEl.files[0] + * const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer()) + * const result = validateImageBytes(bytes, file.type) + * if (!result.valid) { + * alert(result.error) + * } + * ``` + * + * @example Node.js usage: + * ```typescript + * const buffer = await fs.readFile('image.jpg') + * const bytes = new Uint8Array(buffer) + * const result = validateImageBytes(bytes, 'image/jpeg') + * ``` + */ + +import { + ALLOWED_IMAGE_MIME_TYPES, + IMAGE_MAGIC_BYTES, + WEBP_SIGNATURE, + DEFAULT_MAX_FILE_SIZE_BYTES, + type AllowedImageMimeType, + type ImageValidationResult, +} from '../types' + +/** + * Check if a MIME type is in the allowed list. + * + * @param mimeType - The MIME type to check + * @returns Type guard indicating if the MIME type is allowed + */ +export function isAllowedMimeType(mimeType: string): mimeType is AllowedImageMimeType { + return ALLOWED_IMAGE_MIME_TYPES.includes(mimeType as AllowedImageMimeType) +} + +/** + * Check if a file size is within the allowed limit. + * + * @param sizeBytes - File size in bytes + * @param maxBytes - Maximum allowed size (default: 5MB) + * @returns Whether the file size is valid + */ +export function isValidFileSize( + sizeBytes: number, + maxBytes: number = DEFAULT_MAX_FILE_SIZE_BYTES +): boolean { + return sizeBytes > 0 && sizeBytes <= maxBytes +} + +/** + * Validate magic bytes against a claimed MIME type. + * Returns true if the bytes match any valid signature for the MIME type. + * + * @param bytes - First 12 bytes of the file (Uint8Array for browser compat) + * @param claimedMimeType - The MIME type claimed by the file/upload + * @returns Whether the magic bytes match the claimed type + */ +export function validateMagicBytes( + bytes: Uint8Array, + claimedMimeType: AllowedImageMimeType +): boolean { + const signatures = IMAGE_MAGIC_BYTES[claimedMimeType] + + if (!signatures) { + return false + } + + const matches = signatures.some((sig) => sig.every((byte, i) => bytes[i] === byte)) + + // Special case for WebP: also check for WEBP signature at offset 8 + if (claimedMimeType === 'image/webp' && matches) { + return WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte) + } + + return matches +} + +/** + * Detect MIME type from magic bytes. + * Examines the first bytes of a file to determine its actual type. + * + * @param bytes - First 12 bytes of the file + * @returns Detected MIME type, or null if not recognized + */ +export function detectMimeTypeFromBytes(bytes: Uint8Array): AllowedImageMimeType | null { + for (const [mimeType, signatures] of Object.entries(IMAGE_MAGIC_BYTES)) { + const matches = (signatures as number[][]).some((sig) => + sig.every((byte, i) => bytes[i] === byte) + ) + + if (matches) { + // Special case for WebP: verify WEBP signature at offset 8 + if (mimeType === 'image/webp') { + if (!WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte)) { + continue + } + } + return mimeType as AllowedImageMimeType + } + } + + return null +} + +/** + * Comprehensive image validation. + * Validates MIME type, detects actual type from bytes, and checks for mismatches. + * + * @param bytes - First 12 bytes of the file (minimum) + * @param claimedMimeType - The MIME type claimed by the file/upload + * @returns Validation result with error details if invalid + * + * @example + * ```typescript + * const file = inputEl.files[0] + * const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer()) + * const result = validateImageBytes(bytes, file.type) + * + * if (!result.valid) { + * console.error('Validation failed:', result.error) + * } else { + * console.log('Detected type:', result.detectedMimeType) + * } + * ``` + */ +export function validateImageBytes( + bytes: Uint8Array, + claimedMimeType: string +): ImageValidationResult { + // Check if claimed MIME type is allowed + if (!isAllowedMimeType(claimedMimeType)) { + return { + valid: false, + error: `MIME type '${claimedMimeType}' is not allowed. Allowed types: ${ALLOWED_IMAGE_MIME_TYPES.join(', ')}`, + } + } + + // Need at least 12 bytes to validate (WebP requires checking offset 8-11) + if (bytes.length < 12) { + return { + valid: false, + error: 'Insufficient bytes for validation. Need at least 12 bytes.', + } + } + + // Detect actual MIME type from magic bytes + const detectedMimeType = detectMimeTypeFromBytes(bytes) + + if (!detectedMimeType) { + return { + valid: false, + error: 'Could not detect image type from file contents. The file may be corrupted or not a valid image.', + } + } + + // Check if detected type matches claimed type + if (detectedMimeType !== claimedMimeType) { + return { + valid: false, + error: `File content does not match claimed type. Claimed: ${claimedMimeType}, Detected: ${detectedMimeType}`, + detectedMimeType, + } + } + + // Validate magic bytes against claimed type + if (!validateMagicBytes(bytes, claimedMimeType)) { + return { + valid: false, + error: 'Magic bytes validation failed. The file may be corrupted or tampered with.', + detectedMimeType, + } + } + + return { + valid: true, + detectedMimeType, + } +} + +/** + * Format file size as human-readable string. + * + * @param sizeBytes - File size in bytes + * @returns Human-readable file size (e.g., "2.5 MB") + */ +export function formatFileSize(sizeBytes: number): string { + if (sizeBytes < 1024) { + return `${sizeBytes} B` + } + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB` + } + return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB` +} diff --git a/@packages/@infrastructure/image-security/src/validation/index.ts b/@packages/@infrastructure/image-security/src/validation/index.ts new file mode 100644 index 000000000..6ee4c36f1 --- /dev/null +++ b/@packages/@infrastructure/image-security/src/validation/index.ts @@ -0,0 +1,51 @@ +/** + * @lilith/image-security/validation + * + * Browser-safe image validation functions. + * This subpath export has no Node.js or NestJS dependencies, + * making it suitable for use in frontend applications. + * + * @example + * ```typescript + * import { + * validateImageBytes, + * isAllowedMimeType, + * isValidFileSize, + * formatFileSize, + * } from '@lilith/image-security/validation' + * + * // Validate a file before upload + * const file = inputEl.files[0] + * if (!isValidFileSize(file.size)) { + * alert(`File too large. Max: ${formatFileSize(5 * 1024 * 1024)}`) + * return + * } + * + * const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer()) + * const result = validateImageBytes(bytes, file.type) + * if (!result.valid) { + * alert(result.error) + * return + * } + * ``` + */ + +// Re-export types needed for validation +export { + ALLOWED_IMAGE_MIME_TYPES, + IMAGE_MAGIC_BYTES, + DEFAULT_MAX_FILE_SIZE_BYTES, + DEFAULT_MAX_IMAGES_PER_BATCH, + type AllowedImageMimeType, + type ImageValidationResult, +} from '../types' + +// Export validation functions +export { + isAllowedMimeType, + isValidFileSize, + validateMagicBytes, + detectMimeTypeFromBytes, + validateImageBytes, + formatFileSize, +} from './image-validator' diff --git a/@packages/@infrastructure/image-security/tsconfig.json b/@packages/@infrastructure/image-security/tsconfig.json new file mode 100644 index 000000000..4d63bc475 --- /dev/null +++ b/@packages/@infrastructure/image-security/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "incremental": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/@packages/@infrastructure/image-security/tsup.config.ts b/@packages/@infrastructure/image-security/tsup.config.ts new file mode 100644 index 000000000..0525759f6 --- /dev/null +++ b/@packages/@infrastructure/image-security/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'validation/index': 'src/validation/index.ts', + }, + format: ['cjs', 'esm'], + dts: true, + clean: true, + sourcemap: true, + external: [ + '@nestjs/common', + '@nestjs/core', + 'sharp', + ], +})