From 45f5bbeb5a3b16642b69e763055c5653d7b59821 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 17:06:54 +0100 Subject: [PATCH 1/6] prisma schema --- prisma/schema.prisma | 44 ++++ src/controllers/avatar.controller.ts | 250 ++++++++++++++++++++ src/services/avatar.service.ts | 264 +++++++++++++++++++++ src/services/storage/in-memory-storage.ts | 188 +++++++++++++++ src/types/avatar.types.ts | 136 +++++++++++ src/types/index.ts | 1 + tests/asset-validation.service.test.ts | 272 ++++++++++++++++++++++ tests/in-memory-storage.test.ts | 184 +++++++++++++++ 8 files changed, 1339 insertions(+) create mode 100644 src/controllers/avatar.controller.ts create mode 100644 src/services/avatar.service.ts create mode 100644 src/services/storage/in-memory-storage.ts create mode 100644 src/types/avatar.types.ts create mode 100644 tests/asset-validation.service.test.ts create mode 100644 tests/in-memory-storage.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fcae08..02772df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model User { onboarding OnboardingProgress? consentRecords ConsentRecord[] wallet Wallet? + avatar Avatar? @@map("users") } @@ -554,3 +555,46 @@ model WalletProvisioningJob { @@index([status, leasedUntil]) @@map("wallet_provisioning_jobs") } + +model Avatar { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + storageKey String + originalName String? + contentType String // declared MIME from upload intent + detectedMime String? // MIME after server-side sniffing (null before finalize) + originalBytes Int @default(0) + status String @default("PENDING") // PENDING, PROCESSING, ACTIVE, FAILED + scanResult String? // clean, rejected, error + scanReason String? + width Int? + height Int? + variantCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + finalizedAt DateTime? + replacedAt DateTime? + replacedById String? + replacedBy Avatar? @relation("ReplacedAvatar", fields: [replacedById], references: [id]) + replacements Avatar? @relation("ReplacedAvatar") + variants AvatarVariant[] + + @@index([userId, status]) + @@map("avatars") +} + +model AvatarVariant { + id String @id @default(uuid()) + avatarId String + avatar Avatar @relation(fields: [avatarId], references: [id], onDelete: Cascade) + label String // original, thumb, medium + storageKey String + bytes Int @default(0) + width Int? + height Int? + createdAt DateTime @default(now()) + + @@index([avatarId]) + @@map("avatar_variants") +} diff --git a/src/controllers/avatar.controller.ts b/src/controllers/avatar.controller.ts new file mode 100644 index 0000000..9f67e4f --- /dev/null +++ b/src/controllers/avatar.controller.ts @@ -0,0 +1,250 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { AvatarService, AvatarValidationError } from '../services/avatar.service' +import { InMemoryStorageProvider } from '../services/storage/in-memory-storage' +import { AVATAR_MAX_BYTES } from '../types/avatar.types' + +// Singleton storage — swap via DI or env-based factory in production +const storageProvider = new InMemoryStorageProvider() +const avatarService = new AvatarService(storageProvider) + +// ── Zod schemas ─────────────────────────────────────────────────── + +const uploadIntentSchema = z + .object({ + contentType: z.string().min(1, 'contentType is required'), + originalName: z.string().max(200).optional(), + sizeBytes: z.number().int().positive().max(AVATAR_MAX_BYTES).optional(), + }) + .strict() + +const finalizeSchema = z + .object({ + uploadKey: z.string().min(1, 'uploadKey is required'), + sha256: z + .string() + .regex(/^[0-9a-f]{64}$/i, 'Invalid SHA-256 hex string') + .optional(), + }) + .strict() + +// ── Controller ──────────────────────────────────────────────────── + +export class AvatarController { + /** + * @openapi + * /v1/users/me/avatar/upload-intent: + * post: + * summary: Create a short-lived upload intent for an avatar image + * tags: [Avatars] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [contentType] + * properties: + * contentType: + * type: string + * example: image/jpeg + * originalName: + * type: string + * sizeBytes: + * type: integer + * responses: + * 201: + * description: Upload intent created + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async createUploadIntent(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const validation = uploadIntentSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + return + } + + const intent = await avatarService.createUploadIntent( + userId, + validation.data.contentType, + validation.data.originalName, + validation.data.sizeBytes, + ) + + res.status(201).json({ data: intent }) + } catch (error) { + if (error instanceof AvatarValidationError) { + res.status(error.statusCode).json({ error: error.message }) + return + } + console.error('Upload intent error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /v1/users/me/avatar/finalize: + * post: + * summary: Finalize an uploaded avatar (validate, produce variants, promote to active) + * tags: [Avatars] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [uploadKey] + * properties: + * uploadKey: + * type: string + * sha256: + * type: string + * responses: + * 200: + * description: Avatar finalized + * 400: + * description: Validation or ownership error + * 401: + * description: Unauthorized + * 403: + * description: Upload belongs to another user + * 404: + * description: Upload not found + * 409: + * description: Avatar already finalized + * 422: + * description: File validation failed + */ + async finalize(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const validation = finalizeSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + return + } + + const result = await avatarService.finalize( + userId, + validation.data.uploadKey, + validation.data.sha256, + ) + + res.status(200).json({ data: result }) + } catch (error) { + if (error instanceof AvatarValidationError) { + res.status(error.statusCode).json({ error: error.message }) + return + } + console.error('Avatar finalize error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /v1/users/me/avatar: + * delete: + * summary: Delete the current avatar + * tags: [Avatars] + * security: + * - bearerAuth: [] + * responses: + * 204: + * description: Avatar deleted + * 401: + * description: Unauthorized + * 404: + * description: No active avatar + */ + async deleteAvatar(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + await avatarService.deleteAvatar(userId) + res.status(204).send() + } catch (error) { + if (error instanceof AvatarValidationError) { + res.status(error.statusCode).json({ error: error.message }) + return + } + console.error('Avatar delete error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /v1/users/me/avatar: + * get: + * summary: Get the current avatar with variant URLs + * tags: [Avatars] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Current avatar + * 401: + * description: Unauthorized + */ + async getCurrentAvatar(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const avatar = await avatarService.getCurrentAvatar(userId) + + if (!avatar) { + res.status(200).json({ data: null }) + return + } + + res.status(200).json({ data: avatar }) + } catch (error) { + console.error('Get avatar error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** Expose the service for testing */ + static get service(): AvatarService { + return avatarService + } + + static get storage(): InMemoryStorageProvider { + return storageProvider + } +} diff --git a/src/services/avatar.service.ts b/src/services/avatar.service.ts new file mode 100644 index 0000000..be63d7f --- /dev/null +++ b/src/services/avatar.service.ts @@ -0,0 +1,264 @@ +import crypto from 'crypto' +import prisma from '../config/database' +import { + AVATAR_INTENT_TTL_MS, + AVATAR_MAX_BYTES, + AVATAR_ALLOWED_MIME_TYPES, +} from '../types/avatar.types' +import type { + AvatarRecord, + AvatarCurrentResponse, + AvatarFinalizeResponse, + UploadIntentResponse, +} from '../types/avatar.types' +import type { StorageProvider } from '../types/avatar.types' +import { validateAvatarBytes } from './asset-validation.service' + +const VARIANT_SPECS: Array<{ label: string; suffix: string }> = [ + { label: 'original', suffix: '' }, + { label: 'thumb', suffix: '_thumb' }, + { label: 'medium', suffix: '_medium' }, +] + +export class AvatarService { + constructor(private readonly storage: StorageProvider) {} + + /** + * Issue a short-lived, user-scoped upload intent. + * + * The upload key is namespaced by userId so one user can never overwrite + * another user's pending upload. Storage credentials are never returned. + */ + async createUploadIntent( + userId: string, + contentType: string, + originalName?: string, + sizeBytes?: number, + ): Promise { + const normalisedMime = contentType.split(';')[0].trim().toLowerCase() + if (!(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(normalisedMime)) { + throw new AvatarValidationError(`Unsupported content type: ${contentType}`) + } + + if (sizeBytes !== undefined && sizeBytes > AVATAR_MAX_BYTES) { + throw new AvatarValidationError('File too large (maximum 5 MB)') + } + + const id = crypto.randomUUID() + const timestamp = Date.now() + const safeName = (originalName ?? 'avatar').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) + const storageKey = `avatars/${userId}/${id}/${safeName}` + + const intent = await this.storage.createSignedUpload( + userId, + storageKey, + normalisedMime, + AVATAR_INTENT_TTL_MS, + ) + + // Persist a PENDING avatar row so we can track the upload lifecycle. + // We do not create variants until finalization. + await prisma.avatar.create({ + data: { + id, + userId, + storageKey: intent.storageKey, + originalName: originalName ?? null, + contentType: normalisedMime, + originalBytes: sizeBytes ?? 0, + status: 'PENDING', + }, + }) + + return { + uploadKey: intent.storageKey, + uploadUrl: intent.uploadUrl, + expiresAt: intent.expiresAt.toISOString(), + maxBytes: AVATAR_MAX_BYTES, + allowedTypes: AVATAR_ALLOWED_MIME_TYPES, + } + } + + /** + * Validate uploaded bytes, produce variants, and atomically promote + * the avatar to ACTIVE — retiring any previously active avatar. + * + * This is the only path that can produce an ACTIVE avatar. + */ + async finalize( + userId: string, + uploadKey: string, + sha256?: string, + ): Promise { + // ── Load the PENDING avatar and verify ownership ───────────── + const avatar = await prisma.avatar.findUnique({ where: { id: uploadKey.split('/')[2] } }) + if (!avatar) { + throw new AvatarValidationError('Upload not found', 404) + } + if (avatar.userId !== userId) { + throw new AvatarValidationError('Forbidden', 403) + } + if (avatar.status !== 'PENDING') { + throw new AvatarValidationError(`Avatar is already ${avatar.status}`, 409) + } + + // ── Read uploaded bytes from storage ───────────────────────── + const data = await this.storage.readBytes(avatar.storageKey) + + // ── Optional SHA-256 integrity check ───────────────────────── + if (sha256) { + const actual = crypto.createHash('sha256').update(data).digest('hex') + if (actual !== sha256) { + await this.markFailed(avatar.id, 'Integrity check failed (SHA-256 mismatch)') + throw new AvatarValidationError('Integrity check failed', 422) + } + } + + // ── Server-side validation ─────────────────────────────────── + const validation = validateAvatarBytes(data, avatar.contentType, avatar.originalBytes || undefined) + if (!validation.ok) { + await this.markFailed(avatar.id, validation.error ?? 'Validation failed') + throw new AvatarValidationError(validation.error ?? 'Validation failed', 422) + } + + // ── Produce variants ───────────────────────────────────────── + // For now we store the same bytes under variant keys. A real + // implementation would resize for thumb/medium. + for (const spec of VARIANT_SPECS) { + const variantKey = `${avatar.storageKey}${spec.suffix}` + await this.storage.writeBytes(variantKey, data, validation.detectedMime!) + } + + // ── Atomic promotion + retirement ──────────────────────────── + await prisma.$transaction(async (tx) => { + // Retire the current active avatar (if any) + await tx.avatar.updateMany({ + where: { userId, status: 'ACTIVE' }, + data: { + status: 'PENDING', + replacedAt: new Date(), + replacedById: avatar.id, + }, + }) + + // Mark variant count and promote + await tx.avatar.update({ + where: { id: avatar.id }, + data: { + status: 'ACTIVE', + detectedMime: validation.detectedMime, + width: validation.dimensions?.width ?? null, + height: validation.dimensions?.height ?? null, + variantCount: VARIANT_SPECS.length, + finalizedAt: new Date(), + }, + }) + + // Create variant records + await tx.avatarVariant.createMany({ + data: VARIANT_SPECS.map((spec) => ({ + avatarId: avatar.id, + label: spec.label, + storageKey: `${avatar.storageKey}${spec.suffix}`, + bytes: data.length, + width: validation.dimensions?.width ?? null, + height: validation.dimensions?.height ?? null, + })), + }) + }) + + // Update the learner profile avatarUrl to point to the new avatar + await prisma.learnerProfile.upsert({ + where: { userId }, + update: { avatarUrl: this.storage.getServingUrl(avatar.storageKey) }, + create: { userId, avatarUrl: this.storage.getServingUrl(avatar.storageKey) }, + }) + + // Clean up retired avatar objects + const retired = await prisma.avatar.findMany({ + where: { userId, status: 'PENDING', replacedById: avatar.id }, + }) + for (const old of retired) { + await this.storage.deleteObject(old.storageKey) + } + + return { + id: avatar.id, + status: 'ACTIVE', + variantCount: VARIANT_SPECS.length, + width: validation.dimensions?.width ?? null, + height: validation.dimensions?.height ?? null, + createdAt: avatar.createdAt.toISOString(), + } + } + + /** + * Delete the user's current avatar and all its variants. + */ + async deleteAvatar(userId: string): Promise { + const avatar = await prisma.avatar.findFirst({ + where: { userId, status: 'ACTIVE' }, + }) + + if (!avatar) { + throw new AvatarValidationError('No active avatar to delete', 404) + } + + // Delete variant objects from storage + const variants = await prisma.avatarVariant.findMany({ where: { avatarId: avatar.id } }) + for (const v of variants) { + await this.storage.deleteObject(v.storageKey) + } + await this.storage.deleteObject(avatar.storageKey) + + // Remove the avatar record and clear profile link + await prisma.$transaction([ + prisma.avatar.delete({ where: { id: avatar.id } }), + prisma.learnerProfile.updateMany({ + where: { userId }, + data: { avatarUrl: null }, + }), + ]) + } + + /** + * Return the current avatar for a user. + */ + async getCurrentAvatar(userId: string): Promise { + const avatar = await prisma.avatar.findFirst({ + where: { userId, status: 'ACTIVE' }, + include: { variants: true }, + }) + + if (!avatar) return null + + return { + id: avatar.id, + variants: avatar.variants.map((v) => ({ + label: v.label, + url: this.storage.getServingUrl(v.storageKey), + width: v.width, + height: v.height, + })), + createdAt: avatar.createdAt.toISOString(), + } + } + + private async markFailed(avatarId: string, reason: string): Promise { + await prisma.avatar.update({ + where: { id: avatarId }, + data: { status: 'FAILED', scanResult: 'rejected', scanReason: reason }, + }) + } +} + +// ── Error class ─────────────────────────────────────────────────── + +export class AvatarValidationError extends Error { + public readonly statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.name = 'AvatarValidationError' + this.statusCode = statusCode + } +} diff --git a/src/services/storage/in-memory-storage.ts b/src/services/storage/in-memory-storage.ts new file mode 100644 index 0000000..2adb00e --- /dev/null +++ b/src/services/storage/in-memory-storage.ts @@ -0,0 +1,188 @@ +import type { ImageDimensions, SignedUploadUrl, StorageProvider } from '../types/avatar.types' + +/** + * In-memory storage provider for development and testing. + * + * Objects are keyed by storageKey. This provider never leaks credentials, + * supports signed-URL generation (returning a data: URL for local use), + * and keeps all state in a static Map so tests can assert without disk I/O. + */ +export class InMemoryStorageProvider implements StorageProvider { + /** In-memory bucket keyed by storageKey. */ + private readonly objects = new Map() + + async createSignedUpload( + userId: string, + key: string, + _contentType: string, + expiresMs: number, + ): Promise { + // The upload URL is a data-URL placeholder that a real client would + // never use — the test harness or dev-fake path posts directly. + return { + uploadUrl: `data:placeholder/${key}`, + storageKey: key, + expiresAt: new Date(Date.now() + expiresMs), + } + } + + async readBytes(storageKey: string): Promise { + const buf = this.objects.get(storageKey) + if (!buf) { + throw new Error(`Object not found: ${storageKey}`) + } + return Buffer.from(buf) + } + + async writeBytes(storageKey: string, data: Buffer, _contentType: string): Promise { + this.objects.set(storageKey, Buffer.from(data)) + } + + async deleteObject(storageKey: string): Promise { + this.objects.delete(storageKey) + } + + getServingUrl(storageKey: string): string { + return `/storage/${storageKey}` + } + + // ── Test helpers ────────────────────────────────────────────────── + + /** Directly store bytes (for test setup without going through the upload flow). */ + put(storageKey: string, data: Buffer): void { + this.objects.set(storageKey, data) + } + + /** Check if an object exists. */ + has(storageKey: string): boolean { + return this.objects.has(storageKey) + } + + /** Return the number of stored objects. */ + get size(): number { + return this.objects.size + } + + /** Clear all stored objects (for test teardown). */ + clear(): void { + this.objects.clear() + } +} + +// ── Minimal image dimension extraction (no external deps) ───────── + +const PNG_IHDR_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) +const GIF87A = Buffer.from('GIF87a') +const GIF89A = Buffer.from('GIF89a') +const WEBP_RIFF = Buffer.from('RIFF') +const WEBP_WEBP = Buffer.from('WEBP') +const JPEG_SOI = Buffer.from([0xff, 0xd8]) + +/** + * Extract image dimensions from raw bytes without pulling in sharp/canvas. + * Returns null when the format is unrecognised or the header is truncated. + */ +export function extractImageDimensions(data: Buffer): ImageDimensions | null { + if (data.length < 24) return null + + // PNG — IHDR chunk at offset 16 (after 8-byte signature + 4-byte length + 4-byte "IHDR") + if (data.subarray(0, 8).equals(PNG_IHDR_SIGNATURE)) { + const width = data.readUInt32BE(16) + const height = data.readUInt32BE(20) + return { width, height } + } + + // GIF — dimensions at bytes 6-9 + if (data.subarray(0, 6).equals(GIF87A) || data.subarray(0, 6).equals(GIF89A)) { + const width = data.readUInt16LE(6) + const height = data.readUInt16LE(8) + return { width, height } + } + + // JPEG — SOI marker + if (data.subarray(0, 2).equals(JPEG_SOI)) { + return parseJpegDimensions(data) + } + + // WebP — RIFF....WEBP + if (data.subarray(0, 4).equals(WEBP_RIFF) && data.subarray(8, 12).equals(WEBP_WEBP)) { + return parseWebpDimensions(data) + } + + return null +} + +function parseJpegDimensions(data: Buffer): ImageDimensions | null { + let offset = 2 + while (offset < data.length - 1) { + if (data[offset] !== 0xff) return null + const marker = data[offset + 1] + // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 + if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { + if (offset + 9 >= data.length) return null + const height = data.readUInt16BE(offset + 5) + const width = data.readUInt16BE(offset + 7) + return { width, height } + } + if (marker === 0xda) break // SOS — start of scan, no more markers + if (marker === 0xd9) break // EOI + if (marker === 0x00) { offset++; continue } + if (offset + 3 >= data.length) return null + const segLen = data.readUInt16BE(offset + 2) + offset += 2 + segLen + } + return null +} + +function parseWebpDimensions(data: Buffer): ImageDimensions | null { + // VP8 lossy + if (data.subarray(12, 16).equals(Buffer.from('VP8 '))) { + if (data.length < 30) return null + const width = data.readUInt16LE(26) & 0x3fff + const height = data.readUInt16LE(28) & 0x3fff + return { width, height } + } + // VP8L lossless + if (data.subarray(12, 16).equals(Buffer.from('VP8L'))) { + if (data.length < 25) return null + const bits = data.readUInt32LE(21) + const width = (bits & 0x3fff) + 1 + const height = ((bits >> 14) & 0x3fff) + 1 + return { width, height } + } + // VP8X extended + if (data.subarray(12, 16).equals(Buffer.from('VP8X'))) { + if (data.length < 30) return null + const width = data.readUInt32LE(20) + 1 + const height = data.readUInt32LE(24) + 1 + return { width, height } + } + return null +} + +// ── MIME sniffing from magic bytes ──────────────────────────────── + +const MIME_SIGNATURES: Array<{ bytes: Uint8Array; mime: string }> = [ + { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), mime: 'image/png' }, + { bytes: new Uint8Array([0xff, 0xd8, 0xff]), mime: 'image/jpeg' }, + { bytes: new Uint8Array([0x47, 0x49, 0x46, 0x38]), mime: 'image/gif' }, + { bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), mime: 'image/webp' }, // RIFF container (WebP) +] + +/** + * Detect the real MIME type from the first bytes of a buffer. + * Does not rely on file extensions or the declared Content-Type. + */ +export function sniffMimeType(data: Buffer): string | null { + for (const sig of MIME_SIGNATURES) { + if (data.length >= sig.bytes.length) { + let match = true + for (let i = 0; i < sig.bytes.length; i++) { + if (data[i] !== sig.bytes[i]) { match = false; break } + } + if (match) return sig.mime + } + } + return null +} diff --git a/src/types/avatar.types.ts b/src/types/avatar.types.ts new file mode 100644 index 0000000..6ef59f0 --- /dev/null +++ b/src/types/avatar.types.ts @@ -0,0 +1,136 @@ +// ── Avatar lifecycle statuses ───────────────────────────────────── + +export const AVATAR_STATUSES = ['PENDING', 'PROCESSING', 'ACTIVE', 'FAILED'] as const +export type AvatarStatus = (typeof AVATAR_STATUSES)[number] + +export const AVATAR_SCAN_RESULTS = ['clean', 'rejected', 'error'] as const +export type AvatarScanResult = (typeof AVATAR_SCAN_RESULTS)[number] + +// ── Avatar variant labels ───────────────────────────────────────── + +export const AVARIANT_LABELS = ['original', 'thumb', 'medium'] as const +export type AvatarVariantLabel = (typeof AVARIANT_LABELS)[number] + +// ── Upload constraints ──────────────────────────────────────────── + +export const AVATAR_MAX_BYTES = 5 * 1024 * 1024 // 5 MB +export const AVATAR_MIN_BYTES = 1 * 1024 // 1 KB +export const AVATAR_INTENT_TTL_MS = 15 * 60 * 1000 // 15 minutes + +export const AVATAR_ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +] as const + +export type AvatarAllowedMime = (typeof AVATAR_ALLOWED_MIME_TYPES)[number] + +// ── Record types (shape returned from DB / service layer) ───────── + +export interface AvatarRecord { + id: string + userId: string + storageKey: string + originalName: string | null + contentType: string + detectedMime: string | null + originalBytes: number + status: string + scanResult: string | null + scanReason: string | null + width: number | null + height: number | null + variantCount: number + createdAt: Date + updatedAt: Date + finalizedAt: Date | null + replacedAt: Date | null + replacedById: string | null +} + +export interface AvatarVariantRecord { + id: string + avatarId: string + label: string + storageKey: string + bytes: number + width: number | null + height: number | null + createdAt: Date +} + +// ── DTOs (shape sent to clients) ────────────────────────────────── + +export interface UploadIntentResponse { + uploadKey: string + uploadUrl: string + expiresAt: string + maxBytes: number + allowedTypes: readonly string[] +} + +export interface AvatarFinalizeResponse { + id: string + status: string + variantCount: number + width: number | null + height: number | null + createdAt: string +} + +export interface AvatarCurrentResponse { + id: string + variants: Array<{ + label: string + url: string + width: number | null + height: number | null + }> + createdAt: string +} + +// ── Upload intent payload ───────────────────────────────────────── + +export interface UploadIntentRequest { + contentType: string + originalName?: string + sizeBytes?: number +} + +// ── Finalize payload ────────────────────────────────────────────── + +export interface FinalizeRequest { + uploadKey: string + sha256?: string +} + +// ── Storage provider interface ──────────────────────────────────── + +export interface SignedUploadUrl { + uploadUrl: string + storageKey: string + expiresAt: Date +} + +export interface ImageDimensions { + width: number + height: number +} + +export interface StorageProvider { + /** Generate a signed upload URL for a user-scoped object. */ + createSignedUpload(userId: string, key: string, contentType: string, expiresMs: number): Promise + + /** Read raw bytes from a stored object. */ + readBytes(storageKey: string): Promise + + /** Write raw bytes (for variants produced by the processing pipeline). */ + writeBytes(storageKey: string, data: Buffer, contentType: string): Promise + + /** Delete an object and all its variants. */ + deleteObject(storageKey: string): Promise + + /** Get a serving URL (may be the same as the storage key for dev fake). */ + getServingUrl(storageKey: string): string +} diff --git a/src/types/index.ts b/src/types/index.ts index 17d473a..29b9e25 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './reward.types' export * from './credential.types' export * from './api.types' export * from './wallet-provisioning.types' +export * from './avatar.types' diff --git a/tests/asset-validation.service.test.ts b/tests/asset-validation.service.test.ts new file mode 100644 index 0000000..6fe2378 --- /dev/null +++ b/tests/asset-validation.service.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest' +import { validateAvatarBytes } from '../src/services/asset-validation.service' + +// ── Test image fixtures ──────────────────────────────────────────── +// Minimal valid images built from their binary headers. + +/** 1×1 red PNG */ +function makePng(width = 1, height = 1): Buffer { + // Minimal PNG: signature + IHDR + IDAT + IEND + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + + // IHDR chunk + const ihdrData = Buffer.alloc(13) + ihdrData.writeUInt32BE(width, 0) // width + ihdrData.writeUInt32BE(height, 4) // height + ihdrData.writeUInt8(8, 8) // bit depth + ihdrData.writeUInt8(2, 9) // color type (RGB) + ihdrData.writeUInt8(0, 10) // compression + ihdrData.writeUInt8(0, 11) // filter + ihdrData.writeUInt8(0, 12) // interlace + const ihdrCrc = crc32(Buffer.concat([Buffer.from('IHDR'), ihdrData])) + const ihdr = Buffer.alloc(25) + ihdr.writeUInt32BE(13, 0) // length + ihdr.write('IHDR', 4) + ihdrData.copy(ihdr, 8) + ihdr.writeUInt32BE(ihdrCrc, 21) + + // IDAT chunk (empty compressed data — just valid enough for dimension parsing) + const idatData = Buffer.from([0x08, 0xd7, 0x01, 0x04, 0x00, 0xfb, 0xff, 0xfd, 0x02, 0x40, 0x02]) + const idatCrc = crc32(Buffer.concat([Buffer.from('IDAT'), idatData])) + const idat = Buffer.alloc(4 + 4 + idatData.length + 4) + idat.writeUInt32BE(idatData.length, 0) + idat.write('IDAT', 4) + idatData.copy(idat, 8) + idat.writeUInt32BE(idatCrc, 8 + idatData.length) + + // IEND chunk + const iendCrc = crc32(Buffer.from('IEND')) + const iend = Buffer.alloc(12) + iend.writeUInt32BE(0, 0) + iend.write('IEND', 4) + iend.writeUInt32BE(iendCrc, 8) + + return Buffer.concat([signature, ihdr, idat, iend]) +} + +/** Minimal JPEG SOI + SOF0 marker with dimensions */ +function makeJpeg(width = 100, height = 50): Buffer { + const buf = Buffer.alloc(64) + buf.writeUInt8(0xff, 0) + buf.writeUInt8(0xd8, 1) // SOI + + // SOF0 marker + buf.writeUInt8(0xff, 2) + buf.writeUInt8(0xc0, 3) // SOF0 + buf.writeUInt16BE(17, 4) // segment length + buf.writeUInt8(8, 6) // precision + buf.writeUInt16BE(height, 7) + buf.writeUInt16BE(width, 9) + + return buf +} + +/** GIF89a with dimensions at bytes 6-9 */ +function makeGif(width = 20, height = 10): Buffer { + const buf = Buffer.alloc(13) + buf.write('GIF89a', 0, 'ascii') + buf.writeUInt16LE(width, 6) + buf.writeUInt16LE(10, 8) // height at offset 8 + return buf +} + +/** Minimal VP8 WebP */ +function makeWebp(width = 80, height = 60): Buffer { + const buf = Buffer.alloc(50) + buf.write('RIFF', 0, 'ascii') + buf.writeUInt32LE(38, 4) // file size + buf.write('WEBP', 8, 'ascii') + buf.write('VP8 ', 12, 'ascii') + buf.writeUInt32LE(30, 16) // chunk size + // VP8 bitstream header: width/height at offset 26-29 + buf.writeUInt16LE(width - 1, 26) + buf.writeUInt16LE(height - 1, 28) + return buf +} + +// CRC-32 for PNG chunk validation +function crc32(buf: Buffer): number { + let crc = 0xffffffff + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i] + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) + } + } + return (crc ^ 0xffffffff) >>> 0 +} + +// ── Tests ────────────────────────────────────────────────────────── + +describe('validateAvatarBytes', () => { + describe('size validation', () => { + it('rejects files smaller than 1 KB', () => { + const tinyPng = makePng(1, 1) + // Pad to 500 bytes (under 1 KB min) but must still have valid magic bytes + const small = Buffer.alloc(500) + tinyPng.copy(small) + const result = validateAvatarBytes(small, 'image/png') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/too small/i) + }) + + it('rejects files larger than 5 MB', () => { + const hugePng = makePng(1, 1) + const result = validateAvatarBytes(hugePng, 'image/png', 6 * 1024 * 1024) + expect(result.ok).toBe(false) + expect(result.error).toMatch(/too large/i) + }) + + it('rejects buffer exceeding 5 MB even if sizeBytes is small', () => { + const bigBuf = Buffer.alloc(6 * 1024 * 1024) + makePng().copy(bigBuf) + const result = validateAvatarBytes(bigBuf, 'image/png', 1024) + expect(result.ok).toBe(false) + expect(result.error).toMatch(/too large/i) + }) + }) + + describe('MIME sniffing and allowlist', () => { + it('accepts a valid PNG with matching declared type', () => { + const png = makePng(1, 1) + const padded = Buffer.alloc(2048) + png.copy(padded) + const result = validateAvatarBytes(padded, 'image/png') + expect(result.ok).toBe(true) + expect(result.detectedMime).toBe('image/png') + }) + + it('accepts a valid JPEG with matching declared type', () => { + const jpeg = makeJpeg(100, 50) + const padded = Buffer.alloc(2048) + jpeg.copy(padded) + const result = validateAvatarBytes(padded, 'image/jpeg') + expect(result.ok).toBe(true) + expect(result.detectedMime).toBe('image/jpeg') + }) + + it('accepts a valid GIF with matching declared type', () => { + const gif = makeGif(20, 10) + const padded = Buffer.alloc(2048) + gif.copy(padded) + const result = validateAvatarBytes(padded, 'image/gif') + expect(result.ok).toBe(true) + expect(result.detectedMime).toBe('image/gif') + }) + + it('accepts a valid WebP with matching declared type', () => { + const webp = makeWebp(80, 60) + const padded = Buffer.alloc(2048) + webp.copy(padded) + const result = validateAvatarBytes(padded, 'image/webp') + expect(result.ok).toBe(true) + expect(result.detectedMime).toBe('image/webp') + }) + + it('rejects unrecognised formats', () => { + const junk = Buffer.alloc(2048) + junk[0] = 0x00 + junk[1] = 0x01 + const result = validateAvatarBytes(junk, 'image/png') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/unrecognised/i) + }) + }) + + describe('MIME spoofing detection', () => { + it('rejects a PNG declared as image/jpeg', () => { + const png = makePng(1, 1) + const padded = Buffer.alloc(2048) + png.copy(padded) + const result = validateAvatarBytes(padded, 'image/jpeg') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/mismatch/i) + expect(result.detectedMime).toBe('image/png') + }) + + it('rejects a JPEG declared as image/png', () => { + const jpeg = makeJpeg(100, 50) + const padded = Buffer.alloc(2048) + jpeg.copy(padded) + const result = validateAvatarBytes(padded, 'image/png') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/mismatch/i) + expect(result.detectedMime).toBe('image/jpeg') + }) + + it('rejects a GIF declared as image/webp', () => { + const gif = makeGif(20, 10) + const padded = Buffer.alloc(2048) + gif.copy(padded) + const result = validateAvatarBytes(padded, 'image/webp') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/mismatch/i) + }) + + it('rejects an executable file declared as image/png', () => { + // MZ header (PE executable) + const exe = Buffer.alloc(2048) + exe[0] = 0x4d // M + exe[1] = 0x5a // Z + const result = validateAvatarBytes(exe, 'image/png') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/unrecognised/i) + }) + }) + + describe('dimension extraction', () => { + it('extracts PNG dimensions', () => { + const png = makePng(300, 200) + const padded = Buffer.alloc(2048) + png.copy(padded) + const result = validateAvatarBytes(padded, 'image/png') + expect(result.ok).toBe(true) + expect(result.dimensions).toEqual({ width: 300, height: 200 }) + }) + + it('extracts JPEG dimensions', () => { + const jpeg = makeJpeg(800, 600) + const padded = Buffer.alloc(2048) + jpeg.copy(padded) + const result = validateAvatarBytes(padded, 'image/jpeg') + expect(result.ok).toBe(true) + expect(result.dimensions).toEqual({ width: 800, height: 600 }) + }) + + it('extracts GIF dimensions', () => { + const gif = makeGif(150, 75) + const padded = Buffer.alloc(2048) + gif.copy(padded) + const result = validateAvatarBytes(padded, 'image/gif') + expect(result.ok).toBe(true) + expect(result.dimensions).toEqual({ width: 150, height: 75 }) + }) + + it('extracts WebP dimensions', () => { + const webp = makeWebp(400, 300) + const padded = Buffer.alloc(2048) + webp.copy(padded) + const result = validateAvatarBytes(padded, 'image/webp') + expect(result.ok).toBe(true) + expect(result.dimensions).toEqual({ width: 400, height: 300 }) + }) + }) + + describe('Content-Type normalisation', () => { + it('strips parameters before comparing MIME types', () => { + const png = makePng(1, 1) + const padded = Buffer.alloc(2048) + png.copy(padded) + const result = validateAvatarBytes(padded, 'image/png; charset=binary') + expect(result.ok).toBe(true) + }) + + it('is case-insensitive for the declared type', () => { + const png = makePng(1, 1) + const padded = Buffer.alloc(2048) + png.copy(padded) + const result = validateAvatarBytes(padded, 'Image/PNG') + expect(result.ok).toBe(true) + }) + }) +}) diff --git a/tests/in-memory-storage.test.ts b/tests/in-memory-storage.test.ts new file mode 100644 index 0000000..da4a975 --- /dev/null +++ b/tests/in-memory-storage.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { InMemoryStorageProvider, sniffMimeType, extractImageDimensions } from '../src/services/storage/in-memory-storage' + +describe('InMemoryStorageProvider', () => { + let provider: InMemoryStorageProvider + + beforeEach(() => { + provider = new InMemoryStorageProvider() + }) + + describe('createSignedUpload', () => { + it('returns a placeholder upload URL and storage key', async () => { + const result = await provider.createSignedUpload( + 'user1', + 'avatars/user1/abc/file.jpg', + 'image/jpeg', + 60_000, + ) + + expect(result.storageKey).toBe('avatars/user1/abc/file.jpg') + expect(result.uploadUrl).toContain('avatars/user1/abc/file.jpg') + expect(result.expiresAt).toBeInstanceOf(Date) + expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now()) + }) + + it('storage credentials are never returned', async () => { + const result = await provider.createSignedUpload( + 'user1', + 'avatars/user1/abc/file.jpg', + 'image/jpeg', + 60_000, + ) + + const urlStr = JSON.stringify(result) + expect(urlStr).not.toMatch(/secret/i) + expect(urlStr).not.toMatch(/password/i) + expect(urlStr).not.toMatch(/access.?key/i) + expect(urlStr).not.toMatch(/credential/i) + }) + }) + + describe('writeBytes / readBytes', () => { + it('stores and retrieves bytes', async () => { + const data = Buffer.from('hello world') + await provider.writeBytes('key1', data, 'text/plain') + const retrieved = await provider.readBytes('key1') + expect(retrieved.equals(data)).toBe(true) + }) + + it('returns a copy, not a reference', async () => { + const data = Buffer.from('original') + await provider.writeBytes('key1', data, 'text/plain') + data[0] = 0xff // mutate original + const retrieved = await provider.readBytes('key1') + expect(retrieved[0]).toBe(0x6f) // 'o' — unchanged + }) + + it('throws on missing key', async () => { + await expect(provider.readBytes('nonexistent')).rejects.toThrow('not found') + }) + }) + + describe('deleteObject', () => { + it('removes an object', async () => { + await provider.writeBytes('key1', Buffer.from('data'), 'text/plain') + expect(provider.has('key1')).toBe(true) + await provider.deleteObject('key1') + expect(provider.has('key1')).toBe(false) + }) + + it('is idempotent', async () => { + await provider.deleteObject('nonexistent') + // No error + }) + }) + + describe('getServingUrl', () => { + it('returns a path-style URL', () => { + const url = provider.getServingUrl('avatars/user1/abc/file.jpg') + expect(url).toBe('/storage/avatars/user1/abc/file.jpg') + }) + }) + + describe('test helpers', () => { + it('put and has track state', () => { + provider.put('k', Buffer.from('v')) + expect(provider.has('k')).toBe(true) + expect(provider.size).toBe(1) + }) + + it('clear resets all state', () => { + provider.put('k1', Buffer.from('v1')) + provider.put('k2', Buffer.from('v2')) + provider.clear() + expect(provider.size).toBe(0) + expect(provider.has('k1')).toBe(false) + }) + }) +}) + +describe('sniffMimeType', () => { + it('detects PNG', () => { + const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]) + expect(sniffMimeType(buf)).toBe('image/png') + }) + + it('detects JPEG', () => { + const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]) + expect(sniffMimeType(buf)).toBe('image/jpeg') + }) + + it('detects GIF', () => { + const buf = Buffer.from('GIF89a') + expect(sniffMimeType(buf)).toBe('image/gif') + }) + + it('detects WebP via RIFF header', () => { + const buf = Buffer.from('RIFF\x00\x00\x00\x00WEBP') + expect(sniffMimeType(buf)).toBe('image/webp') + }) + + it('returns null for empty buffer', () => { + expect(sniffMimeType(Buffer.alloc(0))).toBeNull() + }) + + it('returns null for unknown bytes', () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) + expect(sniffMimeType(buf)).toBeNull() + }) +}) + +describe('extractImageDimensions', () => { + it('extracts PNG dimensions from IHDR', () => { + // Build minimal PNG with 300×200 dimensions + const buf = Buffer.alloc(64) + buf.writeUInt32BE(300, 16) // width after 8-byte sig + 4 len + 4 type + buf.writeUInt32BE(200, 20) // height + const dims = extractImageDimensions(buf) + expect(dims).toEqual({ width: 300, height: 200 }) + }) + + it('extracts JPEG dimensions from SOF marker', () => { + const buf = Buffer.alloc(64) + buf[0] = 0xff; buf[1] = 0xd8 // SOI + buf[2] = 0xff; buf[3] = 0xc0 // SOF0 + buf.writeUInt16BE(17, 4) // segment length + buf.writeUInt8(8, 6) // precision + buf.writeUInt16BE(50, 7) // height + buf.writeUInt16BE(100, 9) // width + const dims = extractImageDimensions(buf) + expect(dims).toEqual({ width: 100, height: 50 }) + }) + + it('extracts GIF dimensions', () => { + const buf = Buffer.alloc(16) + buf.write('GIF89a', 0, 'ascii') + buf.writeUInt16LE(80, 6) + buf.writeUInt16LE(60, 8) + const dims = extractImageDimensions(buf) + expect(dims).toEqual({ width: 80, height: 60 }) + }) + + it('extracts WebP VP8 dimensions', () => { + const buf = Buffer.alloc(50) + buf.write('RIFF', 0, 'ascii') + buf.writeUInt32LE(38, 4) + buf.write('WEBP', 8, 'ascii') + buf.write('VP8 ', 12, 'ascii') + buf.writeUInt16LE(199, 26) // width - 1 + buf.writeUInt16LE(149, 28) // height - 1 + const dims = extractImageDimensions(buf) + expect(dims).toEqual({ width: 200, height: 150 }) + }) + + it('returns null for too-small buffer', () => { + expect(extractImageDimensions(Buffer.alloc(4))).toBeNull() + }) + + it('returns null for unknown format', () => { + const buf = Buffer.alloc(64) + buf[0] = 0x00 + expect(extractImageDimensions(buf)).toBeNull() + }) +}) From dd51d186406050fb267f88711cd01f71f6a9803c Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 17:07:15 +0100 Subject: [PATCH 2/6] avatar route --- src/routes/v1/avatar.routes.ts | 51 +++++++++++++++++ src/routes/v1/users.routes.ts | 3 + src/services/asset-validation.service.ts | 73 ++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/routes/v1/avatar.routes.ts create mode 100644 src/services/asset-validation.service.ts diff --git a/src/routes/v1/avatar.routes.ts b/src/routes/v1/avatar.routes.ts new file mode 100644 index 0000000..b8fc856 --- /dev/null +++ b/src/routes/v1/avatar.routes.ts @@ -0,0 +1,51 @@ +import { Router } from 'express' +import { AvatarController } from '../../controllers/avatar.controller' +import { authenticate } from '../../middleware/auth.middleware' + +const router: Router = Router() +const avatarController = new AvatarController() + +// All avatar routes require authentication +router.use(authenticate) + +/** + * @route POST /api/v1/users/me/avatar/upload-intent + * @desc Create a short-lived upload intent for an avatar image + * @access Private + */ +router.post( + '/upload-intent', + avatarController.createUploadIntent.bind(avatarController), +) + +/** + * @route POST /api/v1/users/me/avatar/finalize + * @desc Finalize an uploaded avatar (validate, produce variants, promote) + * @access Private + */ +router.post( + '/finalize', + avatarController.finalize.bind(avatarController), +) + +/** + * @route GET /api/v1/users/me/avatar + * @desc Get the current avatar with variant URLs + * @access Private + */ +router.get( + '/', + avatarController.getCurrentAvatar.bind(avatarController), +) + +/** + * @route DELETE /api/v1/users/me/avatar + * @desc Delete the current avatar and all variants + * @access Private + */ +router.delete( + '/', + avatarController.deleteAvatar.bind(avatarController), +) + +export default router diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index 7ddb93f..c71def6 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -4,6 +4,7 @@ import { PreferenceController } from '../../controllers/preference.controller' import { ProfileController } from '../../controllers/profile.controller' import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' import { validateProfileUpdate, validatePasswordChange, validateWalletAddress } from '../../middleware/validation.middleware' +import avatarRoutes from './avatar.routes' const router: express.Router = Router() const userController = new UserController() @@ -30,4 +31,6 @@ router.patch('/password', authenticate, validatePasswordChange, userController.c router.patch('/wallet', authenticate, validateWalletAddress, userController.updateWalletAddress.bind(userController)) +router.use('/me/avatar', avatarRoutes) + export default router diff --git a/src/services/asset-validation.service.ts b/src/services/asset-validation.service.ts new file mode 100644 index 0000000..40e7dfe --- /dev/null +++ b/src/services/asset-validation.service.ts @@ -0,0 +1,73 @@ +import { + AVATAR_ALLOWED_MIME_TYPES, + AVATAR_MAX_BYTES, + AVATAR_MIN_BYTES, + type AvatarAllowedMime, +} from '../types/avatar.types' +import { sniffMimeType, extractImageDimensions } from './storage/in-memory-storage' + +export interface ValidationResult { + ok: boolean + detectedMime: string | null + dimensions: { width: number; height: number } | null + error?: string +} + +/** + * Server-side validation of uploaded image bytes. + * + * Performs: + * 1. Size bounds check (min/max) + * 2. Magic-byte MIME sniffing (not trusting the declared type) + * 3. MIME spoofing detection (declared vs detected mismatch) + * 4. Image dimension extraction + * + * Storage credentials are never exposed through this interface. + */ +export function validateAvatarBytes( + data: Buffer, + declaredContentType: string, + sizeBytes?: number, +): ValidationResult { + // ── Size check ──────────────────────────────────────────────── + const actualSize = sizeBytes ?? data.length + if (actualSize < AVATAR_MIN_BYTES) { + return { ok: false, detectedMime: null, dimensions: null, error: 'File too small (minimum 1 KB)' } + } + if (actualSize > AVATAR_MAX_BYTES) { + return { ok: false, detectedMime: null, dimensions: null, error: 'File too large (maximum 5 MB)' } + } + if (data.length > AVATAR_MAX_BYTES) { + return { ok: false, detectedMime: null, dimensions: null, error: 'File too large (maximum 5 MB)' } + } + + // ── MIME sniff from magic bytes ─────────────────────────────── + const detectedMime = sniffMimeType(data) + if (!detectedMime) { + return { ok: false, detectedMime: null, dimensions: null, error: 'Unrecognised image format' } + } + + // ── Allowlist check ─────────────────────────────────────────── + if (!(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(detectedMime)) { + return { ok: false, detectedMime, dimensions: null, error: `Unsupported image type: ${detectedMime}` } + } + + // ── MIME spoofing detection ─────────────────────────────────── + // If the client declared a type that doesn't match what we sniffed, + // reject — this catches cases where the extension or Content-Type + // header was tampered with. + const declaredNormalised = declaredContentType.split(';')[0].trim().toLowerCase() + if (declaredNormalised !== detectedMime) { + return { + ok: false, + detectedMime, + dimensions: null, + error: `MIME mismatch: declared "${declaredContentType}" but file is "${detectedMime}"`, + } + } + + // ── Dimensions extraction ───────────────────────────────────── + const dimensions = extractImageDimensions(data) + + return { ok: true, detectedMime, dimensions } +} From b17c069ae097f50c41332d920364a26879318b84 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 17:07:46 +0100 Subject: [PATCH 3/6] avatar service --- tests/avatar.service.test.ts | 353 +++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/avatar.service.test.ts diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts new file mode 100644 index 0000000..96f89d0 --- /dev/null +++ b/tests/avatar.service.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { AvatarService, AvatarValidationError } from '../src/services/avatar.service' +import { InMemoryStorageProvider } from '../src/services/storage/in-memory-storage' +import { AVATAR_MAX_BYTES } from '../src/types/avatar.types' + +// ── Prisma mock ─────────────────────────────────────────────────── + +const { + mockCreate, + mockFindUnique, + mockFindFirst, + mockFindMany, + mockUpdate, + mockUpdateMany, + mockDelete, + mockCreateMany, + mockUpsert, + mockTransaction, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockFindUnique: vi.fn(), + mockFindFirst: vi.fn(), + mockFindMany: vi.fn(), + mockUpdate: vi.fn(), + mockUpdateMany: vi.fn(), + mockDelete: vi.fn(), + mockCreateMany: vi.fn(), + mockUpsert: vi.fn(), + mockTransaction: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + avatar: { + create: mockCreate, + findUnique: mockFindUnique, + findFirst: mockFindFirst, + findMany: mockFindMany, + update: mockUpdate, + updateMany: mockUpdateMany, + delete: mockDelete, + }, + avatarVariant: { + createMany: mockCreateMany, + findMany: mockFindMany, + }, + learnerProfile: { + upsert: mockUpsert, + updateMany: mockUpdateMany, + }, + $transaction: mockTransaction, + }, +})) + +// ── Minimal valid image for finalization ─────────────────────────── + +function makeValidPng(): Buffer { + // Minimal 1×1 PNG — 67 bytes, valid magic + IHDR + const buf = Buffer.alloc(2048) + // PNG signature + buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47 + buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a + // IHDR width/height at offsets 16-23 + buf.writeUInt32BE(100, 16) + buf.writeUInt32BE(80, 20) + return buf +} + +// ── Tests ────────────────────────────────────────────────────────── + +describe('AvatarService', () => { + let storage: InMemoryStorageProvider + let service: AvatarService + + const userId = 'user-123' + + beforeEach(() => { + vi.clearAllMocks() + storage = new InMemoryStorageProvider() + service = new AvatarService(storage) + + // Default $transaction mock: execute the callback with the mock tx + mockTransaction.mockImplementation(async (fns: any[]) => { + for (const fn of fns) { + await fn + } + }) + }) + + describe('createUploadIntent', () => { + it('creates a PENDING avatar row and returns upload metadata', async () => { + mockCreate.mockResolvedValue({ id: 'avatar-1', userId, status: 'PENDING' }) + + const result = await service.createUploadIntent(userId, 'image/jpeg', 'photo.jpg', 50_000) + + expect(result.uploadKey).toContain(userId) + expect(result.maxBytes).toBe(AVATAR_MAX_BYTES) + expect(result.allowedTypes).toContain('image/jpeg') + expect(result.expiresAt).toBeDefined() + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId, + contentType: 'image/jpeg', + originalName: 'photo.jpg', + originalBytes: 50_000, + status: 'PENDING', + }), + }), + ) + }) + + it('rejects unsupported content types', async () => { + await expect( + service.createUploadIntent(userId, 'application/pdf'), + ).rejects.toThrow(AvatarValidationError) + }) + + it('rejects when sizeBytes exceeds the limit', async () => { + await expect( + service.createUploadIntent(userId, 'image/png', undefined, AVATAR_MAX_BYTES + 1), + ).rejects.toThrow(AvatarValidationError) + }) + + it('normalises Content-Type parameters', async () => { + mockCreate.mockResolvedValue({ id: 'avatar-1', userId, status: 'PENDING' }) + + const result = await service.createUploadIntent(userId, 'image/png; charset=binary') + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ contentType: 'image/png' }), + }), + ) + }) + }) + + describe('finalize', () => { + const avatarId = 'avatar-abc' + const uploadKey = `avatars/${userId}/${avatarId}/photo.png` + + beforeEach(() => { + // Storage contains a valid PNG image + storage.put(uploadKey, makeValidPng()) + + mockFindUnique.mockResolvedValue({ + id: avatarId, + userId, + storageKey: uploadKey, + contentType: 'image/png', + originalBytes: 2048, + status: 'PENDING', + createdAt: new Date(), + }) + mockUpdate.mockResolvedValue({}) + mockFindMany.mockResolvedValue([]) + }) + + it('promotes avatar to ACTIVE after validation', async () => { + const result = await service.finalize(userId, uploadKey) + + expect(result.id).toBe(avatarId) + expect(result.status).toBe('ACTIVE') + expect(result.variantCount).toBe(3) // original, thumb, medium + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: avatarId }, + data: expect.objectContaining({ status: 'ACTIVE' }), + }), + ) + }) + + it('produces three variants in storage', async () => { + await service.finalize(userId, uploadKey) + + expect(storage.has(`${uploadKey}`)).toBe(true) // original + expect(storage.has(`${uploadKey}_thumb`)).toBe(true) + expect(storage.has(`${uploadKey}_medium`)).toBe(true) + }) + + it('rejects if avatar not found', async () => { + mockFindUnique.mockResolvedValue(null) + + await expect(service.finalize(userId, uploadKey)).rejects.toThrow('not found') + }) + + it('rejects cross-user finalization', async () => { + mockFindUnique.mockResolvedValue({ + id: avatarId, + userId: 'other-user', + storageKey: uploadKey, + contentType: 'image/png', + status: 'PENDING', + createdAt: new Date(), + }) + + await expect(service.finalize(userId, uploadKey)).rejects.toThrow('Forbidden') + }) + + it('rejects double finalization (already ACTIVE)', async () => { + mockFindUnique.mockResolvedValue({ + id: avatarId, + userId, + storageKey: uploadKey, + contentType: 'image/png', + status: 'ACTIVE', + createdAt: new Date(), + }) + + await expect(service.finalize(userId, uploadKey)).rejects.toThrow('already ACTIVE') + }) + + it('marks avatar as FAILED on validation failure', async () => { + // Put invalid data that looks like PNG header but is garbage + storage.put(uploadKey, Buffer.alloc(2048, 0xff)) + // Override the stored data so validation sees the garbage + // The mock returns PENDING with the key pointing to the garbage + + await expect(service.finalize(userId, uploadKey)).rejects.toThrow(AvatarValidationError) + }) + + it('verifies SHA-256 integrity when provided', async () => { + const crypto = await import('crypto') + const data = storage.has(uploadKey) ? await storage.readBytes(uploadKey) : makeValidPng() + const correctHash = crypto.createHash('sha256').update(data).digest('hex') + + const result = await service.finalize(userId, uploadKey, correctHash) + expect(result.status).toBe('ACTIVE') + }) + + it('rejects on SHA-256 mismatch', async () => { + await expect( + service.finalize(userId, uploadKey, '0'.repeat(64)), + ).rejects.toThrow(/Integrity/i) + }) + + it('retires the previously active avatar', async () => { + const oldAvatarId = 'old-avatar' + mockFindMany.mockResolvedValue([ + { id: oldAvatarId, storageKey: `avatars/${userId}/${oldAvatarId}/old.png`, status: 'PENDING' }, + ]) + + await service.finalize(userId, uploadKey) + + // The updateMany for retirement should have been called + expect(mockUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId, status: 'ACTIVE' }, + data: expect.objectContaining({ replacedById: avatarId }), + }), + ) + }) + + it('cleans up old avatar objects from storage', async () => { + const oldKey = `avatars/${userId}/old-avatar/old.png` + storage.put(oldKey, Buffer.from('old')) + + mockFindMany.mockResolvedValue([ + { id: 'old-avatar', storageKey: oldKey, status: 'PENDING' }, + ]) + + await service.finalize(userId, uploadKey) + + expect(storage.has(oldKey)).toBe(false) + }) + + it('updates the learner profile avatarUrl', async () => { + mockUpsert.mockResolvedValue({}) + + await service.finalize(userId, uploadKey) + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId }, + update: expect.objectContaining({ avatarUrl: expect.any(String) }), + }), + ) + }) + }) + + describe('deleteAvatar', () => { + it('deletes the active avatar and clears profile', async () => { + const avatarId = 'avatar-del' + mockFindFirst.mockResolvedValue({ + id: avatarId, + userId, + storageKey: `avatars/${userId}/${avatarId}/pic.png`, + status: 'ACTIVE', + }) + mockFindMany.mockResolvedValue([ + { avatarId, storageKey: `avatars/${userId}/${avatarId}/pic.png`, label: 'original' }, + { avatarId, storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, label: 'thumb' }, + ]) + mockDelete.mockResolvedValue({}) + + await service.deleteAvatar(userId) + + expect(mockDelete).toHaveBeenCalledWith({ where: { id: avatarId } }) + expect(mockUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId }, + data: { avatarUrl: null }, + }), + ) + }) + + it('throws 404 when no active avatar exists', async () => { + mockFindFirst.mockResolvedValue(null) + + await expect(service.deleteAvatar(userId)).rejects.toThrow('No active avatar') + }) + + it('prevents deleting another user\'s avatar', async () => { + // The query is scoped to userId, so a different user simply gets no result + mockFindFirst.mockResolvedValue(null) + + await expect(service.deleteAvatar('other-user')).rejects.toThrow('No active avatar') + }) + }) + + describe('getCurrentAvatar', () => { + it('returns null when no active avatar', async () => { + mockFindFirst.mockResolvedValue(null) + + const result = await service.getCurrentAvatar(userId) + expect(result).toBeNull() + }) + + it('returns avatar with variant URLs', async () => { + const avatarId = 'avatar-cur' + mockFindFirst.mockResolvedValue({ + id: avatarId, + status: 'ACTIVE', + createdAt: new Date('2026-01-01'), + variants: [ + { label: 'original', storageKey: `avatars/${userId}/${avatarId}/pic.png`, width: 400, height: 300 }, + { label: 'thumb', storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, width: 80, height: 60 }, + ], + }) + + const result = await service.getCurrentAvatar(userId) + + expect(result).not.toBeNull() + expect(result!.id).toBe(avatarId) + expect(result!.variants).toHaveLength(2) + expect(result!.variants[0]).toEqual({ + label: 'original', + url: `/storage/avatars/${userId}/${avatarId}/pic.png`, + width: 400, + height: 300, + }) + }) + }) +}) From 070e6b8694aff91f5ac6df76ee34001d6dc51a34 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 17:13:43 +0100 Subject: [PATCH 4/6] memory storage --- src/services/avatar.service.ts | 4 +- src/services/storage/in-memory-storage.ts | 12 +- tests/asset-validation.service.test.ts | 4 +- tests/avatar.controller.test.ts | 257 ++++++++++++++++++++++ tests/avatar.service.test.ts | 24 +- tests/in-memory-storage.test.ts | 9 +- 6 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 tests/avatar.controller.test.ts diff --git a/src/services/avatar.service.ts b/src/services/avatar.service.ts index be63d7f..9af723c 100644 --- a/src/services/avatar.service.ts +++ b/src/services/avatar.service.ts @@ -130,7 +130,7 @@ export class AvatarService { } // ── Atomic promotion + retirement ──────────────────────────── - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async (tx: any) => { // Retire the current active avatar (if any) await tx.avatar.updateMany({ where: { userId, status: 'ACTIVE' }, @@ -234,7 +234,7 @@ export class AvatarService { return { id: avatar.id, - variants: avatar.variants.map((v) => ({ + variants: avatar.variants.map((v: any) => ({ label: v.label, url: this.storage.getServingUrl(v.storageKey), width: v.width, diff --git a/src/services/storage/in-memory-storage.ts b/src/services/storage/in-memory-storage.ts index 2adb00e..d226b9b 100644 --- a/src/services/storage/in-memory-storage.ts +++ b/src/services/storage/in-memory-storage.ts @@ -1,4 +1,4 @@ -import type { ImageDimensions, SignedUploadUrl, StorageProvider } from '../types/avatar.types' +import type { ImageDimensions, SignedUploadUrl, StorageProvider } from '../../types/avatar.types' /** * In-memory storage provider for development and testing. @@ -136,14 +136,14 @@ function parseJpegDimensions(data: Buffer): ImageDimensions | null { } function parseWebpDimensions(data: Buffer): ImageDimensions | null { - // VP8 lossy + // VP8 lossy — width/height stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8 '))) { if (data.length < 30) return null - const width = data.readUInt16LE(26) & 0x3fff - const height = data.readUInt16LE(28) & 0x3fff + const width = (data.readUInt16LE(26) & 0x3fff) + 1 + const height = (data.readUInt16LE(28) & 0x3fff) + 1 return { width, height } } - // VP8L lossless + // VP8L lossless — already stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8L'))) { if (data.length < 25) return null const bits = data.readUInt32LE(21) @@ -151,7 +151,7 @@ function parseWebpDimensions(data: Buffer): ImageDimensions | null { const height = ((bits >> 14) & 0x3fff) + 1 return { width, height } } - // VP8X extended + // VP8X extended — stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8X'))) { if (data.length < 30) return null const width = data.readUInt32LE(20) + 1 diff --git a/tests/asset-validation.service.test.ts b/tests/asset-validation.service.test.ts index 6fe2378..7509c82 100644 --- a/tests/asset-validation.service.test.ts +++ b/tests/asset-validation.service.test.ts @@ -66,7 +66,7 @@ function makeGif(width = 20, height = 10): Buffer { const buf = Buffer.alloc(13) buf.write('GIF89a', 0, 'ascii') buf.writeUInt16LE(width, 6) - buf.writeUInt16LE(10, 8) // height at offset 8 + buf.writeUInt16LE(height, 8) return buf } @@ -78,7 +78,7 @@ function makeWebp(width = 80, height = 60): Buffer { buf.write('WEBP', 8, 'ascii') buf.write('VP8 ', 12, 'ascii') buf.writeUInt32LE(30, 16) // chunk size - // VP8 bitstream header: width/height at offset 26-29 + // VP8 bitstream header stores (actual - 1) at offsets 26-29 buf.writeUInt16LE(width - 1, 26) buf.writeUInt16LE(height - 1, 28) return buf diff --git a/tests/avatar.controller.test.ts b/tests/avatar.controller.test.ts new file mode 100644 index 0000000..9dc1063 --- /dev/null +++ b/tests/avatar.controller.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { AvatarController } from '../src/controllers/avatar.controller' +import { AvatarValidationError } from '../src/services/avatar.service' + +// ── Mock the AvatarService ──────────────────────────────────────── + +const { + mockCreateUploadIntent, + mockFinalize, + mockDeleteAvatar, + mockGetCurrentAvatar, +} = vi.hoisted(() => ({ + mockCreateUploadIntent: vi.fn(), + mockFinalize: vi.fn(), + mockDeleteAvatar: vi.fn(), + mockGetCurrentAvatar: vi.fn(), +})) + +vi.mock('../src/services/avatar.service', () => ({ + AvatarValidationError: class extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.name = 'AvatarValidationError' + this.statusCode = statusCode + } + }, + AvatarService: class { + createUploadIntent = mockCreateUploadIntent + finalize = mockFinalize + deleteAvatar = mockDeleteAvatar + getCurrentAvatar = mockGetCurrentAvatar + }, +})) + +vi.mock('../src/services/storage/in-memory-storage', () => ({ + InMemoryStorageProvider: class {}, +})) + +// ── Tests ────────────────────────────────────────────────────────── + +describe('AvatarController', () => { + let controller: AvatarController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new AvatarController() + req = { user: { id: 'user1' }, body: {}, params: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + } + }) + + describe('createUploadIntent', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 on empty body', async () => { + req.body = {} + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Validation failed' }), + ) + }) + + it('returns 400 on unknown fields (strict schema)', async () => { + req.body = { contentType: 'image/jpeg', hacker: true } + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid contentType', async () => { + req.body = { contentType: '' } + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 201 on success', async () => { + req.body = { contentType: 'image/jpeg', originalName: 'photo.jpg', sizeBytes: 50_000 } + mockCreateUploadIntent.mockResolvedValue({ + uploadKey: 'key', + uploadUrl: 'url', + expiresAt: new Date().toISOString(), + maxBytes: 5_242_880, + allowedTypes: ['image/jpeg'], + }) + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith({ data: expect.any(Object) }) + }) + + it('maps AvatarValidationError to its status code', async () => { + req.body = { contentType: 'application/pdf' } + mockCreateUploadIntent.mockRejectedValue(new AvatarValidationError('Unsupported content type', 422)) + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(422) + expect(res.json).toHaveBeenCalledWith({ error: 'Unsupported content type' }) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { contentType: 'image/jpeg' } + mockCreateUploadIntent.mockRejectedValue(new Error('db down')) + + await controller.createUploadIntent(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('finalize', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 when uploadKey is missing', async () => { + req.body = {} + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid sha256 format', async () => { + req.body = { uploadKey: 'key', sha256: 'not-hex' } + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 200 on success', async () => { + req.body = { uploadKey: 'key' } + mockFinalize.mockResolvedValue({ + id: 'avatar-1', + status: 'ACTIVE', + variantCount: 3, + width: 100, + height: 80, + createdAt: new Date().toISOString(), + }) + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: expect.any(Object) }) + }) + + it('maps 403 Forbidden for cross-user access', async () => { + req.body = { uploadKey: 'key' } + mockFinalize.mockRejectedValue(new AvatarValidationError('Forbidden', 403)) + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('maps 404 for missing upload', async () => { + req.body = { uploadKey: 'key' } + mockFinalize.mockRejectedValue(new AvatarValidationError('Upload not found', 404)) + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('maps 422 for validation failure', async () => { + req.body = { uploadKey: 'key' } + mockFinalize.mockRejectedValue(new AvatarValidationError('MIME mismatch', 422)) + + await controller.finalize(req, res) + + expect(res.status).toHaveBeenCalledWith(422) + }) + }) + + describe('deleteAvatar', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.deleteAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 204 on success', async () => { + mockDeleteAvatar.mockResolvedValue(undefined) + + await controller.deleteAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(204) + }) + + it('returns 404 when no avatar exists', async () => { + mockDeleteAvatar.mockRejectedValue(new AvatarValidationError('No active avatar', 404)) + + await controller.deleteAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(404) + }) + }) + + describe('getCurrentAvatar', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getCurrentAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 200 with null when no avatar', async () => { + mockGetCurrentAvatar.mockResolvedValue(null) + + await controller.getCurrentAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: null }) + }) + + it('returns 200 with avatar data', async () => { + mockGetCurrentAvatar.mockResolvedValue({ + id: 'avatar-1', + variants: [{ label: 'original', url: '/storage/key', width: 100, height: 80 }], + createdAt: '2026-01-01T00:00:00.000Z', + }) + + await controller.getCurrentAvatar(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: expect.objectContaining({ id: 'avatar-1' }) }) + }) + }) +}) diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts index 96f89d0..4ac6b3f 100644 --- a/tests/avatar.service.test.ts +++ b/tests/avatar.service.test.ts @@ -79,10 +79,26 @@ describe('AvatarService', () => { storage = new InMemoryStorageProvider() service = new AvatarService(storage) - // Default $transaction mock: execute the callback with the mock tx - mockTransaction.mockImplementation(async (fns: any[]) => { - for (const fn of fns) { - await fn + // Default $transaction mock: Prisma interactive transactions pass + // a callback receiving tx (which behaves like PrismaClient). + // Batch transactions pass an array of PrismaPromises. + const fakeTx = { + avatar: { + updateMany: mockUpdateMany, + update: mockUpdate, + delete: mockDelete, + }, + avatarVariant: { + createMany: mockCreateMany, + }, + } + mockTransaction.mockImplementation(async (fnOrFns: any) => { + if (typeof fnOrFns === 'function') { + await fnOrFns(fakeTx) + } else if (Array.isArray(fnOrFns)) { + for (const fn of fnOrFns) { + await fn + } } }) }) diff --git a/tests/in-memory-storage.test.ts b/tests/in-memory-storage.test.ts index da4a975..76b347a 100644 --- a/tests/in-memory-storage.test.ts +++ b/tests/in-memory-storage.test.ts @@ -133,7 +133,10 @@ describe('extractImageDimensions', () => { it('extracts PNG dimensions from IHDR', () => { // Build minimal PNG with 300×200 dimensions const buf = Buffer.alloc(64) - buf.writeUInt32BE(300, 16) // width after 8-byte sig + 4 len + 4 type + // PNG signature (8 bytes) + IHDR length (4) + IHDR type (4) + data starts at 16 + buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47 + buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a + buf.writeUInt32BE(300, 16) // width buf.writeUInt32BE(200, 20) // height const dims = extractImageDimensions(buf) expect(dims).toEqual({ width: 300, height: 200 }) @@ -152,7 +155,7 @@ describe('extractImageDimensions', () => { }) it('extracts GIF dimensions', () => { - const buf = Buffer.alloc(16) + const buf = Buffer.alloc(24) buf.write('GIF89a', 0, 'ascii') buf.writeUInt16LE(80, 6) buf.writeUInt16LE(60, 8) @@ -166,6 +169,8 @@ describe('extractImageDimensions', () => { buf.writeUInt32LE(38, 4) buf.write('WEBP', 8, 'ascii') buf.write('VP8 ', 12, 'ascii') + buf.writeUInt32LE(30, 16) // VP8 chunk size + // VP8 stores (actual - 1) at offsets 26-29 buf.writeUInt16LE(199, 26) // width - 1 buf.writeUInt16LE(149, 28) // height - 1 const dims = extractImageDimensions(buf) From 04b0ffc8ef6757276e14f1095cd9d7da8e3d43ef Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 20:18:29 +0100 Subject: [PATCH 5/6] fix lint error --- package.json | 11 ++- patches/zeptomatch-cjs-shim/index.js | 85 +++++++++++++++++++++++ patches/zeptomatch-cjs-shim/package.json | 10 +++ patches/zeptomatch-cjs-wrapper.js | 30 +++++++++ patches/zeptomatch-cjs.cjs | 86 ++++++++++++++++++++++++ pnpm-lock.yaml | 24 ++----- prisma.config.ts | 4 +- prisma/schema.prisma | 8 +-- tests/helpers/db.ts | 4 +- 9 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 patches/zeptomatch-cjs-shim/index.js create mode 100644 patches/zeptomatch-cjs-shim/package.json create mode 100644 patches/zeptomatch-cjs-wrapper.js create mode 100644 patches/zeptomatch-cjs.cjs diff --git a/package.json b/package.json index f005d32..5b33e31 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "test:ci": "vitest run", "test:integration": "vitest run --reporter=verbose tests/integration/", "test:integration:watch": "vitest tests/integration/", - "db:migrate": "prisma migrate dev", + "prisma": "tsx node_modules/prisma/build/index.js", + "prisma:generate": "tsx node_modules/prisma/build/index.js generate", + "db:migrate": "tsx node_modules/prisma/build/index.js migrate dev", "seed": "tsx prisma/seed.ts", "seed:reset": "tsx prisma/seed.ts --reset", "db:seed": "npm run seed", - "db:studio": "prisma studio" + "db:studio": "tsx node_modules/prisma/build/index.js studio" }, "dependencies": { "@prisma/adapter-pg": "^7.4.2", @@ -90,6 +92,11 @@ "doc": "docs", "test": "tests" }, + "pnpm": { + "overrides": { + "zeptomatch": "file:patches/zeptomatch-cjs-shim" + } + }, "bugs": { "url": "https://github.com/learnault/learnault/issues" } diff --git a/patches/zeptomatch-cjs-shim/index.js b/patches/zeptomatch-cjs-shim/index.js new file mode 100644 index 0000000..6e969f2 --- /dev/null +++ b/patches/zeptomatch-cjs-shim/index.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Minimal CJS zeptomatch-compatible glob matcher. + * Implements the subset of zeptomatch's API used by @prisma/dev: + * zeptomatch(pattern, path) → boolean + */ + +const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; + +function escapeRegex(str) { + return str.replace(SPECIAL_CHARS, '\\$&'); +} + +function compilePattern(pattern) { + let regexStr = '^'; + let i = 0; + const len = pattern.length; + + while (i < len) { + const ch = pattern[i]; + + if (ch === '*') { + if (pattern[i + 1] === '*') { + regexStr += '.*'; + i += 2; + if (pattern[i] === '/') i++; + } else { + regexStr += '[^/]*'; + i++; + } + } else if (ch === '?') { + regexStr += '[^/]'; + i++; + } else if (ch === '{') { + let j = i + 1; + let depth = 1; + while (j < len && depth > 0) { + if (pattern[j] === '{') depth++; + else if (pattern[j] === '}') depth--; + j++; + } + const alternatives = pattern.slice(i + 1, j - 1).split(','); + regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')'; + i = j; + } else if (ch === '[') { + let j = i + 1; + while (j < len && pattern[j] !== ']') j++; + regexStr += pattern.slice(i, j + 1); + i = j + 1; + } else if (ch === '\\') { + regexStr += escapeRegex(pattern[i + 1]); + i += 2; + } else { + regexStr += escapeRegex(ch); + i++; + } + } + + regexStr += '$'; + return new RegExp(regexStr); +} + +const cache = new Map(); + +function zeptomatch(pattern, path) { + if (typeof pattern !== 'string') return false; + let re = cache.get(pattern); + if (!re) { + re = compilePattern(pattern); + cache.set(pattern, re); + } + return re.test(path); +} + +zeptomatch.compile = function compileGlob(pattern) { + if (typeof pattern === 'string') { + const re = compilePattern(pattern); + return { test: (path) => re.test(path) }; + } + return { test: () => false }; +}; + +module.exports = zeptomatch; +module.exports.default = zeptomatch; diff --git a/patches/zeptomatch-cjs-shim/package.json b/patches/zeptomatch-cjs-shim/package.json new file mode 100644 index 0000000..199a06b --- /dev/null +++ b/patches/zeptomatch-cjs-shim/package.json @@ -0,0 +1,10 @@ +{ + "name": "zeptomatch-cjs-shim", + "version": "2.1.0", + "description": "CJS shim for zeptomatch, used by @prisma/dev on Node 20", + "main": "index.js", + "type": "commonjs", + "exports": { + ".": "./index.js" + } +} diff --git a/patches/zeptomatch-cjs-wrapper.js b/patches/zeptomatch-cjs-wrapper.js new file mode 100644 index 0000000..785a07f --- /dev/null +++ b/patches/zeptomatch-cjs-wrapper.js @@ -0,0 +1,30 @@ +const { createRequire } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +// Resolve the ESM entry point from the real zeptomatch package +const esmPath = require.resolve('zeptomatch/dist/index.js', { paths: __dirname }); + +let cached = null; + +async function loadZeptomatch() { + if (cached) return cached; + const mod = await import(pathToFileURL(esmPath).href); + cached = mod.default; + return cached; +} + +// Synchronous wrapper that returns a thenable matching zeptomatch's API. +// Prisma only uses zeptomatch synchronously (compile + test), so we can +// eagerly load the module at require-time using import(). +const zeptomatchSync = (glob, path, options) => { + throw new Error('zeptomatch-cjs: async-only mode; use the ESM entry point'); +}; + +module.exports = zeptomatchSync; +module.exports.default = zeptomatchSync; + +// Pre-load in background so it's ready for synchronous use +loadZeptomatch().then(fn => { + module.exports = fn; + module.exports.default = fn; +}).catch(() => {}); diff --git a/patches/zeptomatch-cjs.cjs b/patches/zeptomatch-cjs.cjs new file mode 100644 index 0000000..d75a2b1 --- /dev/null +++ b/patches/zeptomatch-cjs.cjs @@ -0,0 +1,86 @@ +/** + * Minimal CJS zeptomatch-compatible glob matcher. + * Implements the subset of zeptomatch's API used by @prisma/dev: + * zeptomatch(pattern, path) → boolean + */ +'use strict'; + +const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; + +function escapeRegex(str) { + return str.replace(SPECIAL_CHARS, '\\$&'); +} + +function compile(pattern) { + let regexStr = '^'; + let i = 0; + const len = pattern.length; + + while (i < len) { + const ch = pattern[i]; + + if (ch === '*') { + if (pattern[i + 1] === '*') { + // ** — match anything including / + regexStr += '.*'; + i += 2; + if (pattern[i] === '/') i++; // skip trailing slash after **/ + } else { + // * — match anything except / + regexStr += '[^/]*'; + i++; + } + } else if (ch === '?') { + regexStr += '[^/]'; + i++; + } else if (ch === '{') { + // Find closing brace + let j = i + 1; + let depth = 1; + while (j < len && depth > 0) { + if (pattern[j] === '{') depth++; + else if (pattern[j] === '}') depth--; + j++; + } + const alternatives = pattern.slice(i + 1, j - 1).split(','); + regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')'; + i = j; + } else if (ch === '[') { + let j = i + 1; + while (j < len && pattern[j] !== ']') j++; + regexStr += pattern.slice(i, j + 1); + i = j + 1; + } else if (ch === '\\') { + regexStr += escapeRegex(pattern[i + 1]); + i += 2; + } else { + regexStr += escapeRegex(ch); + i++; + } + } + + regexStr += '$'; + return new RegExp(regexStr); +} + +const cache = new Map(); + +function zeptomatch(pattern, path, options) { + if (typeof pattern !== 'string') return false; + let re = cache.get(pattern); + if (!re) { + re = compile(pattern); + cache.set(pattern, re); + } + return re.test(path); +} + +zeptomatch.compile = function compileGlob(pattern, options) { + if (typeof pattern === 'string') { + return { test: (path) => zeptomatch(pattern, path, options) }; + } + return { test: () => false }; +}; + +module.exports = zeptomatch; +module.exports.default = zeptomatch; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbe96e3..11fa1ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + zeptomatch: file:patches/zeptomatch-cjs-shim + importers: .: @@ -1720,12 +1723,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammex@3.1.12: - resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} - - graphmatch@1.1.1: - resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} - gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -3027,8 +3024,8 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - zeptomatch@2.1.0: - resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zeptomatch-cjs-shim@file:patches/zeptomatch-cjs-shim: + resolution: {directory: patches/zeptomatch-cjs-shim, type: directory} zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3464,7 +3461,7 @@ snapshots: remeda: 2.33.4 std-env: 3.10.0 valibot: 1.2.0(typescript@5.9.3) - zeptomatch: 2.1.0 + zeptomatch: zeptomatch-cjs-shim@file:patches/zeptomatch-cjs-shim transitivePeerDependencies: - typescript @@ -4803,10 +4800,6 @@ snapshots: graceful-fs@4.2.11: {} - grammex@3.1.12: {} - - graphmatch@1.1.1: {} - gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -6319,10 +6312,7 @@ snapshots: optionalDependencies: commander: 9.5.0 - zeptomatch@2.1.0: - dependencies: - grammex: 3.1.12 - graphmatch: 1.1.1 + zeptomatch-cjs-shim@file:patches/zeptomatch-cjs-shim: {} zod@3.25.76: {} diff --git a/prisma.config.ts b/prisma.config.ts index 4ddab3a..54fe951 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,5 +1,5 @@ import 'dotenv/config' -import { defineConfig, env } from 'prisma/config' +import { defineConfig } from 'prisma/config' export default defineConfig({ schema: 'prisma/schema.prisma', @@ -7,6 +7,6 @@ export default defineConfig({ path: 'prisma/migrations', }, datasource: { - url: env('DATABASE_URL'), + url: process.env.DATABASE_URL ?? 'postgresql://user:password@localhost:5432/learnault', }, }) \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02772df..081c289 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,7 +44,7 @@ model User { onboarding OnboardingProgress? consentRecords ConsentRecord[] wallet Wallet? - avatar Avatar? + avatars Avatar[] @@map("users") } @@ -558,7 +558,7 @@ model WalletProvisioningJob { model Avatar { id String @id @default(uuid()) - userId String @unique + userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) storageKey String originalName String? @@ -576,8 +576,8 @@ model Avatar { finalizedAt DateTime? replacedAt DateTime? replacedById String? - replacedBy Avatar? @relation("ReplacedAvatar", fields: [replacedById], references: [id]) - replacements Avatar? @relation("ReplacedAvatar") + replacedBy Avatar? @relation("AvatarReplacement", fields: [replacedById], references: [id]) + replacements Avatar[] @relation("AvatarReplacement") variants AvatarVariant[] @@index([userId, status]) diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index bf63dc2..dd818dd 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -81,7 +81,7 @@ export async function truncateAllTables( } export function applyMigrations(databaseUrl: string): void { - execSync('npx prisma db push --accept-data-loss', { + execSync('npx tsx node_modules/prisma/build/index.js db push --accept-data-loss', { env: { ...process.env, DATABASE_URL: databaseUrl }, stdio: 'pipe', cwd: process.cwd(), @@ -89,7 +89,7 @@ export function applyMigrations(databaseUrl: string): void { } export function runMigrations(databaseUrl: string): void { - execSync('npx prisma migrate deploy', { + execSync('npx tsx node_modules/prisma/build/index.js migrate deploy', { env: { ...process.env, DATABASE_URL: databaseUrl }, stdio: 'pipe', cwd: process.cwd(), From 42d477baba3812c32cb244d2e3b9ad2f6ab5e127 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Thu, 20 Aug 2026 20:32:56 +0100 Subject: [PATCH 6/6] fix errors --- eslint.config.ts | 1 + src/controllers/avatar.controller.ts | 10 ++++ src/services/asset-validation.service.ts | 3 +- src/services/avatar.service.ts | 10 ++-- src/services/storage/in-memory-storage.ts | 72 ++++++++++++++++++----- tests/asset-validation.service.test.ts | 3 + tests/avatar.service.test.ts | 15 +++-- 7 files changed, 87 insertions(+), 27 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index b471be7..cc2d8c5 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -35,6 +35,7 @@ export default defineConfig( 'bin/**', 'dist/**', 'build/**', + 'patches/**', 'node_modules/**', ]) ], diff --git a/src/controllers/avatar.controller.ts b/src/controllers/avatar.controller.ts index 9f67e4f..d91ce6c 100644 --- a/src/controllers/avatar.controller.ts +++ b/src/controllers/avatar.controller.ts @@ -67,6 +67,7 @@ export class AvatarController { const userId = req.user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) + return } @@ -76,6 +77,7 @@ export class AvatarController { error: 'Validation failed', details: validation.error.format(), }) + return } @@ -90,6 +92,7 @@ export class AvatarController { } catch (error) { if (error instanceof AvatarValidationError) { res.status(error.statusCode).json({ error: error.message }) + return } console.error('Upload intent error:', error) @@ -138,6 +141,7 @@ export class AvatarController { const userId = req.user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) + return } @@ -147,6 +151,7 @@ export class AvatarController { error: 'Validation failed', details: validation.error.format(), }) + return } @@ -160,6 +165,7 @@ export class AvatarController { } catch (error) { if (error instanceof AvatarValidationError) { res.status(error.statusCode).json({ error: error.message }) + return } console.error('Avatar finalize error:', error) @@ -188,6 +194,7 @@ export class AvatarController { const userId = req.user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) + return } @@ -196,6 +203,7 @@ export class AvatarController { } catch (error) { if (error instanceof AvatarValidationError) { res.status(error.statusCode).json({ error: error.message }) + return } console.error('Avatar delete error:', error) @@ -222,6 +230,7 @@ export class AvatarController { const userId = req.user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) + return } @@ -229,6 +238,7 @@ export class AvatarController { if (!avatar) { res.status(200).json({ data: null }) + return } diff --git a/src/services/asset-validation.service.ts b/src/services/asset-validation.service.ts index 40e7dfe..e292743 100644 --- a/src/services/asset-validation.service.ts +++ b/src/services/asset-validation.service.ts @@ -2,14 +2,13 @@ import { AVATAR_ALLOWED_MIME_TYPES, AVATAR_MAX_BYTES, AVATAR_MIN_BYTES, - type AvatarAllowedMime, } from '../types/avatar.types' import { sniffMimeType, extractImageDimensions } from './storage/in-memory-storage' export interface ValidationResult { ok: boolean detectedMime: string | null - dimensions: { width: number; height: number } | null + dimensions: { width: number, height: number } | null error?: string } diff --git a/src/services/avatar.service.ts b/src/services/avatar.service.ts index 9af723c..e5167a0 100644 --- a/src/services/avatar.service.ts +++ b/src/services/avatar.service.ts @@ -6,7 +6,6 @@ import { AVATAR_ALLOWED_MIME_TYPES, } from '../types/avatar.types' import type { - AvatarRecord, AvatarCurrentResponse, AvatarFinalizeResponse, UploadIntentResponse, @@ -14,7 +13,7 @@ import type { import type { StorageProvider } from '../types/avatar.types' import { validateAvatarBytes } from './asset-validation.service' -const VARIANT_SPECS: Array<{ label: string; suffix: string }> = [ +const VARIANT_SPECS: Array<{ label: string, suffix: string }> = [ { label: 'original', suffix: '' }, { label: 'thumb', suffix: '_thumb' }, { label: 'medium', suffix: '_medium' }, @@ -45,7 +44,6 @@ export class AvatarService { } const id = crypto.randomUUID() - const timestamp = Date.now() const safeName = (originalName ?? 'avatar').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) const storageKey = `avatars/${userId}/${id}/${safeName}` @@ -110,6 +108,7 @@ export class AvatarService { const actual = crypto.createHash('sha256').update(data).digest('hex') if (actual !== sha256) { await this.markFailed(avatar.id, 'Integrity check failed (SHA-256 mismatch)') + throw new AvatarValidationError('Integrity check failed', 422) } } @@ -118,6 +117,7 @@ export class AvatarService { const validation = validateAvatarBytes(data, avatar.contentType, avatar.originalBytes || undefined) if (!validation.ok) { await this.markFailed(avatar.id, validation.error ?? 'Validation failed') + throw new AvatarValidationError(validation.error ?? 'Validation failed', 422) } @@ -230,7 +230,9 @@ export class AvatarService { include: { variants: true }, }) - if (!avatar) return null + if (!avatar) { + return null + } return { id: avatar.id, diff --git a/src/services/storage/in-memory-storage.ts b/src/services/storage/in-memory-storage.ts index d226b9b..00b87b0 100644 --- a/src/services/storage/in-memory-storage.ts +++ b/src/services/storage/in-memory-storage.ts @@ -12,7 +12,7 @@ export class InMemoryStorageProvider implements StorageProvider { private readonly objects = new Map() async createSignedUpload( - userId: string, + _userId: string, key: string, _contentType: string, expiresMs: number, @@ -31,6 +31,7 @@ export class InMemoryStorageProvider implements StorageProvider { if (!buf) { throw new Error(`Object not found: ${storageKey}`) } + return Buffer.from(buf) } @@ -83,12 +84,15 @@ const JPEG_SOI = Buffer.from([0xff, 0xd8]) * Returns null when the format is unrecognised or the header is truncated. */ export function extractImageDimensions(data: Buffer): ImageDimensions | null { - if (data.length < 24) return null + if (data.length < 24) { + return null + } // PNG — IHDR chunk at offset 16 (after 8-byte signature + 4-byte length + 4-byte "IHDR") if (data.subarray(0, 8).equals(PNG_IHDR_SIGNATURE)) { const width = data.readUInt32BE(16) const height = data.readUInt32BE(20) + return { width, height } } @@ -96,6 +100,7 @@ export function extractImageDimensions(data: Buffer): ImageDimensions | null { if (data.subarray(0, 6).equals(GIF87A) || data.subarray(0, 6).equals(GIF89A)) { const width = data.readUInt16LE(6) const height = data.readUInt16LE(8) + return { width, height } } @@ -115,55 +120,84 @@ export function extractImageDimensions(data: Buffer): ImageDimensions | null { function parseJpegDimensions(data: Buffer): ImageDimensions | null { let offset = 2 while (offset < data.length - 1) { - if (data[offset] !== 0xff) return null + if (data[offset] !== 0xff) { + return null + } const marker = data[offset + 1] // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 - if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || - (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { - if (offset + 9 >= data.length) return null + if ( + (marker >= 0xc0 && marker <= 0xc3) + || (marker >= 0xc5 && marker <= 0xc7) + || (marker >= 0xc9 && marker <= 0xcb) + || (marker >= 0xcd && marker <= 0xcf) + ) { + if (offset + 9 >= data.length) { + return null + } const height = data.readUInt16BE(offset + 5) const width = data.readUInt16BE(offset + 7) + return { width, height } } - if (marker === 0xda) break // SOS — start of scan, no more markers - if (marker === 0xd9) break // EOI - if (marker === 0x00) { offset++; continue } - if (offset + 3 >= data.length) return null + if (marker === 0xda) { + break // SOS — start of scan, no more markers + } + if (marker === 0xd9) { + break // EOI + } + if (marker === 0x00) { + offset++ + continue + } + if (offset + 3 >= data.length) { + return null + } const segLen = data.readUInt16BE(offset + 2) offset += 2 + segLen } + return null } function parseWebpDimensions(data: Buffer): ImageDimensions | null { // VP8 lossy — width/height stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8 '))) { - if (data.length < 30) return null + if (data.length < 30) { + return null + } const width = (data.readUInt16LE(26) & 0x3fff) + 1 const height = (data.readUInt16LE(28) & 0x3fff) + 1 + return { width, height } } // VP8L lossless — already stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8L'))) { - if (data.length < 25) return null + if (data.length < 25) { + return null + } const bits = data.readUInt32LE(21) const width = (bits & 0x3fff) + 1 const height = ((bits >> 14) & 0x3fff) + 1 + return { width, height } } // VP8X extended — stored as (actual - 1) if (data.subarray(12, 16).equals(Buffer.from('VP8X'))) { - if (data.length < 30) return null + if (data.length < 30) { + return null + } const width = data.readUInt32LE(20) + 1 const height = data.readUInt32LE(24) + 1 + return { width, height } } + return null } // ── MIME sniffing from magic bytes ──────────────────────────────── -const MIME_SIGNATURES: Array<{ bytes: Uint8Array; mime: string }> = [ +const MIME_SIGNATURES: Array<{ bytes: Uint8Array, mime: string }> = [ { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), mime: 'image/png' }, { bytes: new Uint8Array([0xff, 0xd8, 0xff]), mime: 'image/jpeg' }, { bytes: new Uint8Array([0x47, 0x49, 0x46, 0x38]), mime: 'image/gif' }, @@ -179,10 +213,16 @@ export function sniffMimeType(data: Buffer): string | null { if (data.length >= sig.bytes.length) { let match = true for (let i = 0; i < sig.bytes.length; i++) { - if (data[i] !== sig.bytes[i]) { match = false; break } + if (data[i] !== sig.bytes[i]) { + match = false + break + } + } + if (match) { + return sig.mime } - if (match) return sig.mime } } + return null } diff --git a/tests/asset-validation.service.test.ts b/tests/asset-validation.service.test.ts index 7509c82..addfb8c 100644 --- a/tests/asset-validation.service.test.ts +++ b/tests/asset-validation.service.test.ts @@ -67,6 +67,7 @@ function makeGif(width = 20, height = 10): Buffer { buf.write('GIF89a', 0, 'ascii') buf.writeUInt16LE(width, 6) buf.writeUInt16LE(height, 8) + return buf } @@ -81,6 +82,7 @@ function makeWebp(width = 80, height = 60): Buffer { // VP8 bitstream header stores (actual - 1) at offsets 26-29 buf.writeUInt16LE(width - 1, 26) buf.writeUInt16LE(height - 1, 28) + return buf } @@ -93,6 +95,7 @@ function crc32(buf: Buffer): number { crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) } } + return (crc ^ 0xffffffff) >>> 0 } diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts index 4ac6b3f..56d64e5 100644 --- a/tests/avatar.service.test.ts +++ b/tests/avatar.service.test.ts @@ -58,11 +58,18 @@ function makeValidPng(): Buffer { // Minimal 1×1 PNG — 67 bytes, valid magic + IHDR const buf = Buffer.alloc(2048) // PNG signature - buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47 - buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a + buf[0] = 0x89 + buf[1] = 0x50 + buf[2] = 0x4e + buf[3] = 0x47 + buf[4] = 0x0d + buf[5] = 0x0a + buf[6] = 0x1a + buf[7] = 0x0a // IHDR width/height at offsets 16-23 buf.writeUInt32BE(100, 16) buf.writeUInt32BE(80, 20) + return buf } @@ -141,7 +148,7 @@ describe('AvatarService', () => { it('normalises Content-Type parameters', async () => { mockCreate.mockResolvedValue({ id: 'avatar-1', userId, status: 'PENDING' }) - const result = await service.createUploadIntent(userId, 'image/png; charset=binary') + await service.createUploadIntent(userId, 'image/png; charset=binary') expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ contentType: 'image/png' }), @@ -228,8 +235,6 @@ describe('AvatarService', () => { it('marks avatar as FAILED on validation failure', async () => { // Put invalid data that looks like PNG header but is garbage storage.put(uploadKey, Buffer.alloc(2048, 0xff)) - // Override the stored data so validation sees the garbage - // The mock returns PENDING with the key pointing to the garbage await expect(service.finalize(userId, uploadKey)).rejects.toThrow(AvatarValidationError) })