feat: add multipart image uploads

Register Fastify multipart support, validate JPEG/PNG/WebP binary signatures, store upload bytes through ImageStorage, persist metadata after storage succeeds, clean up bytes on metadata failure, and document the upload boundary. Verification: npm test passed 68 tests and integration passed 14 tests. No build script is configured.
This commit is contained in:
2026-09-08 17:38:27 -04:00
parent 5a041c8a5c
commit f7152bcff3
14 changed files with 278 additions and 11 deletions
+1
View File
@@ -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=
+1
View File
@@ -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",
+2
View File
@@ -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',
+5 -1
View File
@@ -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 }),
});
@@ -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 } = {},
@@ -29,3 +35,11 @@ export function validateInventoryImageMetadata(
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;
}
@@ -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);
@@ -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;
}
},
};
}
@@ -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]);
});
+22 -1
View File
@@ -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',
@@ -28,3 +31,21 @@ test('rejects unsupported MIME types, oversized files, and oversized dimensions'
(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',
);
});
@@ -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');
});
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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.
+62
View File
@@ -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",