♻️ 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:
parent
b6e55c0634
commit
aecdf9cdad
15 changed files with 1036 additions and 101 deletions
|
|
@ -15,6 +15,7 @@
|
|||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lilith/image-security": "workspace:*",
|
||||
"zod": "^3.22.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
162
@packages/@core/types/src/models/ecommerce/MerchPhrase.ts
Normal file
162
@packages/@core/types/src/models/ecommerce/MerchPhrase.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
38
@packages/@infrastructure/image-security/package.json
Normal file
38
@packages/@infrastructure/image-security/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
99
@packages/@infrastructure/image-security/src/index.ts
Normal file
99
@packages/@infrastructure/image-security/src/index.ts
Normal 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'
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ImageProcessorService, IMAGE_PROCESSOR_OPTIONS } from './image-processor.service'
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './image-security.types'
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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'
|
||||
12
@packages/@infrastructure/image-security/tsconfig.json
Normal file
12
@packages/@infrastructure/image-security/tsconfig.json
Normal 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"]
|
||||
}
|
||||
17
@packages/@infrastructure/image-security/tsup.config.ts
Normal file
17
@packages/@infrastructure/image-security/tsup.config.ts
Normal 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',
|
||||
],
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue