feat: add public image read boundary
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 21:58:30 -04:00
parent 5452bd5589
commit b00bd388a9
15 changed files with 223 additions and 9 deletions
+5
View File
@@ -6,6 +6,7 @@ import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js'; import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js'; import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js';
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js';
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js'; import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js';
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
@@ -71,6 +72,10 @@ export function buildApp(dependencies) {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: '/api/v1',
}); });
app.register(registerPublicImageRoutes, {
...dependencies,
prefix: '/api/v1',
});
app.register(registerPublicSellerRoutes, { app.register(registerPublicSellerRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: '/api/v1',
+8 -2
View File
@@ -20,6 +20,7 @@ import { createPasswordRecoveryService } from '../modules/auth/services/password
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js'; import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js'; import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js'; import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js';
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js';
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js'; import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js'; import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js';
@@ -65,6 +66,10 @@ async function startServer() {
securityAuditRepository, securityAuditRepository,
}); });
const passwordResetNotifier = createPasswordResetNotifier(); const passwordResetNotifier = createPasswordResetNotifier();
const publicImageService = createPublicImageService({
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
inventoryImageRepository,
});
const authenticationService = createAuthenticationService({ const authenticationService = createAuthenticationService({
mfaService, mfaService,
sessionRepository, sessionRepository,
@@ -95,13 +100,14 @@ async function startServer() {
inventoryItemRepository, inventoryItemRepository,
productRepository, productRepository,
}), }),
publicInventoryService: createPublicInventoryService({ inventoryItemRepository }), publicImageService,
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
inventoryImageService: createInventoryImageService({ inventoryImageService: createInventoryImageService({
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
inventoryImageRepository, inventoryImageRepository,
}), }),
listingService: createListingService({ listingRepository, inventoryImageRepository }), listingService: createListingService({ listingRepository, inventoryImageRepository }),
publicListingService: createPublicListingService({ listingRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }), searchService: createSearchService({ listingRepository }),
@@ -1,8 +1,9 @@
import { and, eq } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { import {
inventoryImages, inventoryImages,
inventoryItems, inventoryItems,
listings,
products, products,
} from '../../../infrastructure/database/schema.js'; } from '../../../infrastructure/database/schema.js';
@@ -29,5 +30,51 @@ export function createInventoryImageRepository(db) {
return rows.length; return rows.length;
}, },
async listPublicByInventoryItemId(inventoryItemId) {
return db
.select({
id: inventoryImages.id,
mimeType: inventoryImages.mimeType,
width: inventoryImages.width,
height: inventoryImages.height,
altText: inventoryImages.altText,
sortOrder: inventoryImages.sortOrder,
isPrimary: inventoryImages.isPrimary,
processingStatus: inventoryImages.processingStatus,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.where(and(
eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
))
.orderBy(inventoryImages.sortOrder);
},
async findPublicById(imageId) {
const [image] = await db
.select({
id: inventoryImages.id,
storageKey: inventoryImages.storageKey,
mimeType: inventoryImages.mimeType,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.where(and(
eq(inventoryImages.id, imageId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
));
return image ?? null;
},
}; };
} }
@@ -0,0 +1,8 @@
export async function registerPublicImageRoutes(app, options) {
const { publicImageService } = options;
app.get('/public/images/:id', async (request, reply) => {
const image = await publicImageService.getContent(request.params.id);
return reply.type(image.mimeType).send(image.data);
});
}
@@ -0,0 +1,23 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicImageService({ inventoryImageRepository, imageStorage }) {
return {
async listForInventoryItem(inventoryItemId) {
const images = await inventoryImageRepository.listPublicByInventoryItemId(inventoryItemId);
return images.map((image) => ({
...image,
url: `/api/v1/public/images/${image.id}`,
}));
},
async getContent(imageId) {
const image = await inventoryImageRepository.findPublicById(imageId);
if (!image) {
throw createApplicationError('IMAGE_NOT_FOUND', 'Image was not found.', 404);
}
return { ...image, data: await imageStorage.get(image.storageKey) };
},
};
}
@@ -1,6 +1,8 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicInventoryService({ inventoryItemRepository }) { const noOpPublicImageService = Object.freeze({ async listForInventoryItem() { return []; } });
export function createPublicInventoryService({ inventoryItemRepository, publicImageService = noOpPublicImageService }) {
return { return {
async list(input = {}) { async list(input = {}) {
return inventoryItemRepository.searchPublic({ return inventoryItemRepository.searchPublic({
@@ -16,7 +18,10 @@ export function createPublicInventoryService({ inventoryItemRepository }) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
} }
return item; return {
...item,
images: await publicImageService.listForInventoryItem(item.id),
};
}, },
}; };
} }
@@ -1,6 +1,8 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicListingService({ listingRepository }) { const noOpPublicImageService = Object.freeze({ async listForInventoryItem() { return []; } });
export function createPublicListingService({ listingRepository, publicImageService = noOpPublicImageService }) {
return { return {
async list(input = {}) { async list(input = {}) {
return listingRepository.searchPublic({ return listingRepository.searchPublic({
@@ -17,7 +19,10 @@ export function createPublicListingService({ listingRepository }) {
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404); throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
} }
return listing; return {
...listing,
images: await publicImageService.listForInventoryItem(listing.inventoryItemId),
};
}, },
}; };
} }
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPublicImageService } from '../../src/modules/inventory/services/public-image-service.js';
test('returns public image URLs without exposing storage keys', async () => {
const service = createPublicImageService({
inventoryImageRepository: {
async listPublicByInventoryItemId(id) {
assert.equal(id, 'inventory-1');
return [{ id: 'image-1', altText: 'Front of card', sortOrder: 0 }];
},
},
imageStorage: { async get() { return Buffer.from('unused'); } },
});
assert.deepEqual(await service.listForInventoryItem('inventory-1'), [{
id: 'image-1',
altText: 'Front of card',
sortOrder: 0,
url: '/api/v1/public/images/image-1',
}]);
});
test('loads only repository-approved public image content', async () => {
const service = createPublicImageService({
inventoryImageRepository: {
async findPublicById(id) {
assert.equal(id, 'image-1');
return { id, storageKey: 'inventory/image.webp', mimeType: 'image/webp' };
},
},
imageStorage: {
async get(key) {
assert.equal(key, 'inventory/image.webp');
return Buffer.from('image-bytes');
},
},
});
const result = await service.getContent('image-1');
assert.deepEqual(result.data, Buffer.from('image-bytes'));
assert.equal(result.storageKey, 'inventory/image.webp');
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
test('serves public image bytes with the stored safe MIME type', async (testContext) => {
const app = buildApp({
publicImageService: {
async getContent(id) {
assert.equal(id, 'image-1');
return { mimeType: 'image/webp', data: Buffer.from('webp-bytes') };
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/public/images/image-1',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'image/webp');
assert.equal(response.rawPayload.toString(), 'webp-bytes');
});
test('does not expose hidden image errors as internal failures', async (testContext) => {
const app = buildApp({
publicImageService: {
async getContent() {
const error = new Error('Image was not found.');
error.code = 'IMAGE_NOT_FOUND';
error.statusCode = 404;
throw error;
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/public/images/hidden-image',
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error.code, 'IMAGE_NOT_FOUND');
});
@@ -8,4 +8,19 @@ export const ContainedMuseumStage = {
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
], ],
}, },
};
export const WithPublicImageUrls = {
args: {
images: [
{ id: 'front', src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', alt: 'Front of Charizard ex' },
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
],
},
};
export const BrokenImageFallback = {
args: {
images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }],
},
}; };
@@ -5,13 +5,18 @@ const props = defineProps({
images: { type: Array, required: true }, images: { type: Array, required: true },
}); });
const selectedIndex = ref(0); const selectedIndex = ref(0);
const failedImages = ref(new Set());
function markFailed(imageId) {
failedImages.value = new Set(failedImages.value).add(imageId);
}
</script> </script>
<template> <template>
<div class="image-gallery"> <div class="image-gallery">
<div class="image-gallery__stage"> <div class="image-gallery__stage">
<img v-if="props.images[selectedIndex]?.src" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" /> <img v-if="props.images[selectedIndex]?.src && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" @error="markFailed(props.images[selectedIndex].id)" />
<span v-else class="image-gallery__placeholder" aria-label="No collectible image available">{{ props.images[selectedIndex]?.label ?? 'IMAGE' }}</span> <span v-else class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
</div> </div>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images"> <div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" @click="selectedIndex = index"> <button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" @click="selectedIndex = index">
+1
View File
@@ -33,6 +33,7 @@ onMounted(async () => {
:price-minor="listing.askingPriceMinor" :price-minor="listing.askingPriceMinor"
:condition="listing.conditionId" :condition="listing.conditionId"
:description="listing.notes" :description="listing.notes"
:images="listing.images"
/> />
</main> </main>
</template> </template>
+1
View File
@@ -30,6 +30,7 @@ onMounted(async () => {
v-else-if="product" v-else-if="product"
:title="product.name" :title="product.name"
:description="product.description" :description="product.description"
:images="product.images"
:facts="[ :facts="[
...(product.brand ? [{ label: 'Brand', value: product.brand }] : []), ...(product.brand ? [{ label: 'Brand', value: product.brand }] : []),
...(product.setName ? [{ label: 'Set', value: product.setName }] : []), ...(product.setName ? [{ label: 'Set', value: product.setName }] : []),
+1
View File
@@ -13,6 +13,7 @@ export function toPublicInventoryItemDto(item) {
askingPriceMinor: item.askingPriceMinor, askingPriceMinor: item.askingPriceMinor,
listingStatus: item.listingStatus, listingStatus: item.listingStatus,
listingNotes: item.listingNotes ?? null, listingNotes: item.listingNotes ?? null,
images: item.images ?? [],
createdAt: toIsoString(item.createdAt), createdAt: toIsoString(item.createdAt),
updatedAt: toIsoString(item.updatedAt), updatedAt: toIsoString(item.updatedAt),
}; };
+1
View File
@@ -11,6 +11,7 @@ export function toPublicListingDto(listing) {
askingPriceMinor: listing.askingPriceMinor, askingPriceMinor: listing.askingPriceMinor,
status: listing.status, status: listing.status,
notes: listing.notes ?? null, notes: listing.notes ?? null,
images: listing.images ?? [],
createdAt: toIsoString(listing.createdAt), createdAt: toIsoString(listing.createdAt),
updatedAt: toIsoString(listing.updatedAt), updatedAt: toIsoString(listing.updatedAt),
}; };