diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 4843f1b..ff5de63 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -6,6 +6,7 @@ import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js'; import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js'; import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-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 { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js'; import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; @@ -71,6 +72,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1', }); + app.register(registerPublicImageRoutes, { + ...dependencies, + prefix: '/api/v1', + }); app.register(registerPublicSellerRoutes, { ...dependencies, prefix: '/api/v1', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 8c1f70d..149d018 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -20,6 +20,7 @@ import { createPasswordRecoveryService } from '../modules/auth/services/password import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js'; import { createInventoryItemService } from '../modules/inventory/services/inventory-item-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 { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js'; import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js'; @@ -65,6 +66,10 @@ async function startServer() { securityAuditRepository, }); const passwordResetNotifier = createPasswordResetNotifier(); + const publicImageService = createPublicImageService({ + imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), + inventoryImageRepository, + }); const authenticationService = createAuthenticationService({ mfaService, sessionRepository, @@ -95,13 +100,14 @@ async function startServer() { inventoryItemRepository, productRepository, }), - publicInventoryService: createPublicInventoryService({ inventoryItemRepository }), + publicImageService, + publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }), inventoryImageService: createInventoryImageService({ imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), inventoryImageRepository, }), listingService: createListingService({ listingRepository, inventoryImageRepository }), - publicListingService: createPublicListingService({ listingRepository }), + publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), searchService: createSearchService({ listingRepository }), diff --git a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js index 67092e0..dd962b2 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -1,8 +1,9 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { inventoryImages, inventoryItems, + listings, products, } from '../../../infrastructure/database/schema.js'; @@ -29,5 +30,51 @@ export function createInventoryImageRepository(db) { 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; + }, }; } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/public-image-routes.js b/apps/api/src/modules/inventory/routes/public-image-routes.js new file mode 100644 index 0000000..64c102d --- /dev/null +++ b/apps/api/src/modules/inventory/routes/public-image-routes.js @@ -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); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/public-image-service.js b/apps/api/src/modules/inventory/services/public-image-service.js new file mode 100644 index 0000000..7d510fa --- /dev/null +++ b/apps/api/src/modules/inventory/services/public-image-service.js @@ -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) }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/public-inventory-service.js b/apps/api/src/modules/inventory/services/public-inventory-service.js index 2112a5f..35837f3 100644 --- a/apps/api/src/modules/inventory/services/public-inventory-service.js +++ b/apps/api/src/modules/inventory/services/public-inventory-service.js @@ -1,6 +1,8 @@ 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 { async list(input = {}) { return inventoryItemRepository.searchPublic({ @@ -16,7 +18,10 @@ export function createPublicInventoryService({ inventoryItemRepository }) { throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); } - return item; + return { + ...item, + images: await publicImageService.listForInventoryItem(item.id), + }; }, }; } \ No newline at end of file diff --git a/apps/api/src/modules/listings/services/public-listing-service.js b/apps/api/src/modules/listings/services/public-listing-service.js index a0ac07a..326e3d8 100644 --- a/apps/api/src/modules/listings/services/public-listing-service.js +++ b/apps/api/src/modules/listings/services/public-listing-service.js @@ -1,6 +1,8 @@ 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 { async list(input = {}) { return listingRepository.searchPublic({ @@ -17,7 +19,10 @@ export function createPublicListingService({ listingRepository }) { throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404); } - return listing; + return { + ...listing, + images: await publicImageService.listForInventoryItem(listing.inventoryItemId), + }; }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/public-image-service.test.js b/apps/api/tests/application/public-image-service.test.js new file mode 100644 index 0000000..645bba7 --- /dev/null +++ b/apps/api/tests/application/public-image-service.test.js @@ -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'); +}); diff --git a/apps/api/tests/http/public-image-routes.test.js b/apps/api/tests/http/public-image-routes.test.js new file mode 100644 index 0000000..3e1842b --- /dev/null +++ b/apps/api/tests/http/public-image-routes.test.js @@ -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'); +}); diff --git a/apps/web/src/components/molecules/ImageGallery.stories.js b/apps/web/src/components/molecules/ImageGallery.stories.js index 0289f10..72e9060 100644 --- a/apps/web/src/components/molecules/ImageGallery.stories.js +++ b/apps/web/src/components/molecules/ImageGallery.stories.js @@ -8,4 +8,19 @@ export const ContainedMuseumStage = { { 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' }], + }, }; \ No newline at end of file diff --git a/apps/web/src/components/molecules/ImageGallery.vue b/apps/web/src/components/molecules/ImageGallery.vue index 319577e..08e7db1 100644 --- a/apps/web/src/components/molecules/ImageGallery.vue +++ b/apps/web/src/components/molecules/ImageGallery.vue @@ -5,13 +5,18 @@ const props = defineProps({ images: { type: Array, required: true }, }); const selectedIndex = ref(0); +const failedImages = ref(new Set()); + +function markFailed(imageId) { + failedImages.value = new Set(failedImages.value).add(imageId); +} \ No newline at end of file diff --git a/apps/web/src/pages/ProductDetailPage.vue b/apps/web/src/pages/ProductDetailPage.vue index 965c05c..4a2df74 100644 --- a/apps/web/src/pages/ProductDetailPage.vue +++ b/apps/web/src/pages/ProductDetailPage.vue @@ -30,6 +30,7 @@ onMounted(async () => { v-else-if="product" :title="product.name" :description="product.description" + :images="product.images" :facts="[ ...(product.brand ? [{ label: 'Brand', value: product.brand }] : []), ...(product.setName ? [{ label: 'Set', value: product.setName }] : []), diff --git a/packages/contracts/public-inventory.js b/packages/contracts/public-inventory.js index 4eb7feb..e5413ba 100644 --- a/packages/contracts/public-inventory.js +++ b/packages/contracts/public-inventory.js @@ -13,6 +13,7 @@ export function toPublicInventoryItemDto(item) { askingPriceMinor: item.askingPriceMinor, listingStatus: item.listingStatus, listingNotes: item.listingNotes ?? null, + images: item.images ?? [], createdAt: toIsoString(item.createdAt), updatedAt: toIsoString(item.updatedAt), }; diff --git a/packages/contracts/public-listing.js b/packages/contracts/public-listing.js index ac239d0..95cf22f 100644 --- a/packages/contracts/public-listing.js +++ b/packages/contracts/public-listing.js @@ -11,6 +11,7 @@ export function toPublicListingDto(listing) { askingPriceMinor: listing.askingPriceMinor, status: listing.status, notes: listing.notes ?? null, + images: listing.images ?? [], createdAt: toIsoString(listing.createdAt), updatedAt: toIsoString(listing.updatedAt), };