This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
const selectedIndex = ref(0);
|
||||
const failedImages = ref(new Set());
|
||||
|
||||
function markFailed(imageId) {
|
||||
failedImages.value = new Set(failedImages.value).add(imageId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-gallery">
|
||||
<div class="image-gallery__stage">
|
||||
<img v-if="props.images[selectedIndex]?.src" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" />
|
||||
<span v-else class="image-gallery__placeholder" aria-label="No collectible image available">{{ props.images[selectedIndex]?.label ?? 'IMAGE' }}</span>
|
||||
<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" 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 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">
|
||||
|
||||
@@ -33,6 +33,7 @@ onMounted(async () => {
|
||||
:price-minor="listing.askingPriceMinor"
|
||||
:condition="listing.conditionId"
|
||||
:description="listing.notes"
|
||||
:images="listing.images"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -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 }] : []),
|
||||
|
||||
Reference in New Issue
Block a user