diff --git a/.env.example b/.env.example index 10c2bcb..d874bfc 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ API_PORT=3000 +IMAGE_STORAGE_ROOT=.local/image-storage DATABASE_URL=postgresql://pokemon_inventory:change-me@localhost:5433/pokemon_inventory INITIAL_ADMIN_EMAIL= INITIAL_ADMIN_PASSWORD= diff --git a/apps/api/package.json b/apps/api/package.json index a064dd2..58cd0b3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@fastify/cookie": "^11.0.2", + "@fastify/multipart": "^9.4.0", "@pokemon-inventory/contracts": "*", "@pokemon-inventory/validation": "*", "dotenv": "^16.6.1", @@ -22,4 +23,4 @@ "pg": "^8.16.3", "redis": "^5.8.2" } -} \ No newline at end of file +} diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index d5cc495..54154bd 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import cookie from '@fastify/cookie'; +import multipart from '@fastify/multipart'; import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js'; import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js'; @@ -28,6 +29,7 @@ export function buildApp(dependencies) { app.get('/health', async () => ({ data: { status: 'ok' } })); app.register(cookie); + app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } }); app.register(registerAuthRoutes, { ...dependencies, prefix: '/api/v1/auth', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index f1ac513..0e11324 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -18,6 +18,7 @@ import { createInventoryItemRepository } from '../modules/inventory/repositories import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js'; import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js'; +import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js'; import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js'; import { createListingRepository } from '../modules/listings/repositories/listing-repository.js'; import { createListingService } from '../modules/listings/services/listing-service.js'; @@ -78,7 +79,10 @@ async function startServer() { inventoryItemRepository, productRepository, }), - inventoryImageService: createInventoryImageService({ inventoryImageRepository }), + inventoryImageService: createInventoryImageService({ + imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), + inventoryImageRepository, + }), listingService: createListingService({ listingRepository, inventoryImageRepository }), productService: createProductService({ productRepository }), }); diff --git a/apps/api/src/modules/inventory/domain/inventory-image.js b/apps/api/src/modules/inventory/domain/inventory-image.js index 6e16d41..b9eba29 100644 --- a/apps/api/src/modules/inventory/domain/inventory-image.js +++ b/apps/api/src/modules/inventory/domain/inventory-image.js @@ -4,6 +4,12 @@ const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; const DEFAULT_MAX_DIMENSION = 10_000; +const signatures = { + 'image/jpeg': (data) => data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff, + 'image/png': (data) => data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), + 'image/webp': (data) => data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP', +}; + export function validateInventoryImageMetadata( image, { maxFileSize = DEFAULT_MAX_FILE_SIZE, maxDimension = DEFAULT_MAX_DIMENSION } = {}, @@ -27,5 +33,13 @@ export function validateInventoryImageMetadata( throw createApplicationError('IMAGE_DIMENSIONS_INVALID', 'Image dimensions are invalid or too large.', 400); } + return true; +} + +export function validateImageSignature(data, mimeType) { + if (!Buffer.isBuffer(data) || !signatures[mimeType]?.(data)) { + throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image bytes do not match the declared MIME type.', 400); + } + return true; } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/admin-image-routes.js b/apps/api/src/modules/inventory/routes/admin-image-routes.js index 8064d52..76ce20d 100644 --- a/apps/api/src/modules/inventory/routes/admin-image-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-image-routes.js @@ -1,5 +1,6 @@ import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image'; import { createInventoryImageSchema } from '@pokemon-inventory/validation/image'; +import { randomUUID } from 'node:crypto'; import { createApplicationError } from '../../../shared/errors/application-error.js'; @@ -32,6 +33,39 @@ export async function registerAdminImageRoutes(app, options) { request.headers['x-csrf-token'], ); + if (request.isMultipart()) { + const part = await request.file(); + if (!part) { + throw createApplicationError('IMAGE_FILE_REQUIRED', 'An image file is required.', 400); + } + + const data = await part.toBuffer(); + const fields = part.fields; + const originalFilename = part.filename; + const result = createInventoryImageSchema.safeParse({ + storageKey: `inventory/${request.params.id}/${randomUUID()}`, + originalFilename, + mimeType: part.mimetype, + fileSize: data.length, + width: Number(fields.width?.value), + height: Number(fields.height?.value), + altText: fields.altText?.value, + sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined, + isPrimary: fields.isPrimary?.value === 'true', + }); + + if (!result.success) return validationError(reply, result); + + const image = await inventoryImageService.upload({ + ...result.data, + data, + inventoryItemId: request.params.id, + sellerId: admin.sellerId, + }); + + return reply.code(201).send({ data: toAdminInventoryImageDto(image) }); + } + const result = createInventoryImageSchema.safeParse(request.body); if (!result.success) return validationError(reply, result); diff --git a/apps/api/src/modules/inventory/services/inventory-image-service.js b/apps/api/src/modules/inventory/services/inventory-image-service.js index 5ed9c42..2bc6415 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -1,7 +1,7 @@ import { createApplicationError } from '../../../shared/errors/application-error.js'; -import { validateInventoryImageMetadata } from '../domain/inventory-image.js'; +import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js'; -export function createInventoryImageService({ inventoryImageRepository }) { +export function createInventoryImageService({ inventoryImageRepository, imageStorage }) { return { async create(input) { validateInventoryImageMetadata(input); @@ -16,5 +16,20 @@ export function createInventoryImageService({ inventoryImageRepository }) { return image; }, + + async upload(input) { + const { data, ...metadata } = input; + validateInventoryImageMetadata(metadata); + validateImageSignature(data, metadata.mimeType); + + await imageStorage.put({ key: metadata.storageKey, data }); + + try { + return await this.create(metadata); + } catch (error) { + await imageStorage.delete(metadata.storageKey); + throw error; + } + }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/inventory-image-service.test.js b/apps/api/tests/application/inventory-image-service.test.js new file mode 100644 index 0000000..5b8151f --- /dev/null +++ b/apps/api/tests/application/inventory-image-service.test.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js'; + +const input = { + inventoryItemId: 'inventory-item-id', + sellerId: 'seller-id', + storageKey: 'inventory/item/front.jpg', + originalFilename: 'front.jpg', + mimeType: 'image/jpeg', + fileSize: 4, + width: 1200, + height: 900, + data: Buffer.from([0xff, 0xd8, 0xff, 0x00]), +}; + +test('stores validated upload bytes before persisting image metadata', async () => { + const stored = []; + const service = createInventoryImageService({ + imageStorage: { + async put(value) { stored.push(value); }, + async delete() {}, + }, + inventoryImageRepository: { + async createForSeller(image) { + return { id: 'image-1', ...image }; + }, + }, + }); + + const image = await service.upload(input); + + assert.equal(stored.length, 1); + assert.deepEqual(stored[0], { key: input.storageKey, data: input.data }); + assert.equal(image.id, 'image-1'); + assert.equal(image.processingStatus, 'PENDING'); + assert.equal('data' in image, false); +}); + +test('removes stored bytes when metadata persistence fails', async () => { + const deleted = []; + const service = createInventoryImageService({ + imageStorage: { + async put() {}, + async delete(key) { deleted.push(key); }, + }, + inventoryImageRepository: { + async createForSeller() { return null; }, + }, + }); + + await assert.rejects( + () => service.upload(input), + (error) => error.code === 'INVENTORY_ITEM_NOT_FOUND', + ); + assert.deepEqual(deleted, [input.storageKey]); +}); \ No newline at end of file diff --git a/apps/api/tests/domain/inventory-image.test.js b/apps/api/tests/domain/inventory-image.test.js index b15816c..b185421 100644 --- a/apps/api/tests/domain/inventory-image.test.js +++ b/apps/api/tests/domain/inventory-image.test.js @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { validateInventoryImageMetadata } from '../../src/modules/inventory/domain/inventory-image.js'; +import { + validateImageSignature, + validateInventoryImageMetadata, +} from '../../src/modules/inventory/domain/inventory-image.js'; const validImage = { mimeType: 'image/jpeg', @@ -27,4 +30,22 @@ test('rejects unsupported MIME types, oversized files, and oversized dimensions' () => validateInventoryImageMetadata({ ...validImage, width: 10001 }), (error) => error.code === 'IMAGE_DIMENSIONS_INVALID', ); +}); + +test('accepts matching image signatures and rejects mismatched bytes', () => { + assert.equal( + validateImageSignature(Buffer.from([0xff, 0xd8, 0xff, 0x00]), 'image/jpeg'), + true, + ); + assert.equal( + validateImageSignature( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + 'image/png', + ), + true, + ); + assert.throws( + () => validateImageSignature(Buffer.from('not-an-image'), 'image/png'), + (error) => error.code === 'IMAGE_SIGNATURE_INVALID', + ); }); \ No newline at end of file diff --git a/apps/api/tests/http/listing-routes.test.js b/apps/api/tests/http/listing-routes.test.js index 9383b2e..996d514 100644 --- a/apps/api/tests/http/listing-routes.test.js +++ b/apps/api/tests/http/listing-routes.test.js @@ -71,6 +71,16 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = updatedAt: new Date('2026-09-08T00:00:00.000Z'), }; }, + async upload(input) { + return { + id: 'image-upload-1', + ...input, + data: undefined, + processingStatus: 'PENDING', + createdAt: new Date('2026-09-08T00:00:00.000Z'), + updatedAt: new Date('2026-09-08T00:00:00.000Z'), + }; + }, }, }); @@ -198,3 +208,45 @@ test('adds inventory image metadata for the authenticated seller', async (testCo assert.equal(response.json().data.inventoryItemId, 'inventory-1'); assert.equal(response.json().data.processingStatus, 'PENDING'); }); + +test('accepts a multipart image upload and forwards validated fields', async (testContext) => { + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + }); + testContext.after(() => app.close()); + + const boundary = 'pokemon-image-boundary'; + const prefix = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="width"', + '', + '1200', + `--${boundary}`, + 'Content-Disposition: form-data; name="height"', + '', + '900', + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="front.jpg"', + 'Content-Type: image/jpeg', + '', + ].join('\r\n'); + const suffix = `\r\n--${boundary}--\r\n`; + const payload = Buffer.concat([ + Buffer.from(`${prefix}\r\n`), + Buffer.from([0xff, 0xd8, 0xff, 0x00]), + Buffer.from(suffix), + ]); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/inventory/inventory-1/images', + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload, + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().data.id, 'image-upload-1'); + assert.equal(response.json().data.originalFilename, 'front.jpg'); +}); diff --git a/docs/API.md b/docs/API.md index ed9a156..6252259 100644 --- a/docs/API.md +++ b/docs/API.md @@ -98,7 +98,9 @@ available listings. `POST /api/v1/admin/inventory/:id/images` stores validated image metadata for an owned physical inventory item. File bytes are handled by the future -`ImageStorage` adapter; publication counts persisted inventory images rather +`ImageStorage` adapter; JSON metadata and multipart image uploads are accepted. +Multipart uploads require `width` and `height` fields and validate the binary +signature before storage. Publication counts persisted inventory images rather than trusting a listing request field. ## Provider matching diff --git a/docs/IMAGES.md b/docs/IMAGES.md index 2edf302..11288d6 100644 --- a/docs/IMAGES.md +++ b/docs/IMAGES.md @@ -55,8 +55,9 @@ Validate: The metadata service currently enforces JPEG, PNG, and WebP MIME types, a 10 MiB default file-size limit, and a 10,000-pixel maximum width and height. -Binary signature checks and multipart upload validation remain part of the -upload transport slice. +Multipart upload validation now checks the declared MIME type against JPEG, +PNG, and WebP signatures before storage. Image dimensions are supplied by the +upload transport until a decoder-backed dimension extractor is added. Generate: - thumbnail diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 0e9140d..41593ae 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -90,7 +90,7 @@ docker context use orbstack - Listing publication rejects physical inventory in `SOLD` or `ARCHIVED` status with a conflict response. - Listing reads are seller-scoped and return only admin listing DTO fields. - Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. -- `ImageStorage` and a traversal-safe local filesystem adapter are implemented; upload transport, signature validation, and derivative processing remain. +- `ImageStorage`, traversal-safe local storage, multipart upload transport, and binary signature validation are implemented; derivative processing remains. - Image metadata limits are enforced in the service boundary: supported MIME types, 10 MiB default file size, and 10,000-pixel maximum dimensions. ### Verified Baseline @@ -102,13 +102,13 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: `npm test` passed 64 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 68 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. ## Next Milestone Complete the inventory/listing integrity boundary before starting pricing and acquisition. -1. Add multipart upload transport, binary signature validation, and processing/derivative lifecycle for persisted image metadata. +1. Add processing/derivative lifecycle for persisted image metadata. 2. Begin acquisition and pricing foundations after the listing/image boundary is complete. Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites. diff --git a/package-lock.json b/package-lock.json index ac4620f..eeff30d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "name": "@pokemon-inventory/api", "dependencies": { "@fastify/cookie": "^11.0.2", + "@fastify/multipart": "^9.4.0", "@pokemon-inventory/contracts": "*", "@pokemon-inventory/validation": "*", "dotenv": "^16.6.1", @@ -935,6 +936,12 @@ "fast-uri": "^4.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", @@ -968,6 +975,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -1038,6 +1061,45 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/multipart/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",