♻️ Extract image security types to dedicated package

- Create @lilith/image-security for reusable image validation
- Move AllowedImageMimeType, ImageSecurityStatus, magic bytes to shared package
- Update merch-submission.types.ts to import from new package
- Add MerchPhrase model to ecommerce types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-30 04:46:46 -08:00
parent b6e55c0634
commit aecdf9cdad
15 changed files with 1036 additions and 101 deletions

View file

@ -15,6 +15,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@lilith/image-security": "workspace:*",
"zod": "^3.22.0"
}
}

View file

@ -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<AllowedImageMimeType, number[][]> = {
'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
*/

View file

@ -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)
}

View file

@ -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'

View file

@ -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"
}
}

View file

@ -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],
}
}
}

View file

@ -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<boolean> {
* 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'

View file

@ -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<ImageProcessingOptions>
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<ImageProcessingOptions>
): Promise<ImageProcessingResult> {
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<ImageProcessingOptions>
): 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<sharp.Metadata> {
return sharp(buffer).metadata()
}
}

View file

@ -0,0 +1 @@
export { ImageProcessorService, IMAGE_PROCESSOR_OPTIONS } from './image-processor.service'

View file

@ -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<AllowedImageMimeType, number[][]> = {
'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<ImageProcessingOptions>
}
/**
* Default processing options.
*/
export const DEFAULT_PROCESSING_OPTIONS: Required<ImageProcessingOptions> = {
maxDimension: DEFAULT_MAX_DIMENSION,
thumbnailSize: DEFAULT_THUMBNAIL_SIZE,
jpegQuality: 90,
pngCompressionLevel: 9,
webpQuality: 90,
thumbnailQuality: 80,
}

View file

@ -0,0 +1 @@
export * from './image-security.types'

View file

@ -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`
}

View file

@ -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'

View file

@ -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"]
}

View file

@ -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',
],
})