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 { 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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 }] : []),
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user