feat(F-055): completed feature
7
project/apps/admin/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -524,7 +525,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2933,7 +2933,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5885,7 +5884,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -5935,7 +5933,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function ProductsPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
{p.imageUrl ? (
|
||||
<img
|
||||
src={p.imageUrl}
|
||||
src={p.imageUrl.replace('/uploads/', '/uploads/40/')}
|
||||
alt={p.name}
|
||||
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
|
||||
/>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves uploaded files dynamically from disk on every request.
|
||||
*
|
||||
* Next.js production mode caches the public/ directory listing at build/start
|
||||
* time, so files uploaded after startup return 404 when served as static
|
||||
* assets. This route handler reads the file fresh from disk each time,
|
||||
* making newly uploaded images immediately available without a restart.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
|
||||
// Prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const filePath = path.join(process.cwd(), 'public', 'uploads', safe);
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -173,8 +173,9 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{images.map((img, idx) => (
|
||||
<div key={img.id} className="relative group">
|
||||
{/* Use 200px thumbnail for editor preview to reduce bandwidth */}
|
||||
<img
|
||||
src={img.url}
|
||||
src={img.url.replace('/uploads/', '/uploads/200/')}
|
||||
alt={img.altText ?? img.url}
|
||||
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
|
||||
/>
|
||||
|
||||
7
project/frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -524,7 +525,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3035,7 +3035,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5999,7 +5998,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -6049,7 +6047,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -100,7 +100,7 @@ export default async function ProductPage({ params }: Props) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Image */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
<div className="aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves uploaded product images dynamically from disk on every request.
|
||||
*
|
||||
* Next.js production mode caches the public/ directory listing at build/start
|
||||
* time, so files added after startup return 404 when served as static
|
||||
* assets. This route reads the file fresh from disk each time, mirroring
|
||||
* the behaviour of the admin app so newly uploaded images are immediately
|
||||
* available without a rebuild.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const filePath = path.join(process.cwd(), 'public', 'uploads', safe);
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
82
project/scripts/generate-thumbnails.mjs
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// Generate cached thumbnails (40px and 200px) for every uploaded product image.
|
||||
// Pre-generates thumbnails in each app's public/uploads/thumbs/<size>/ directory
|
||||
// so Next.js serves them as static files.
|
||||
|
||||
import { readdir, stat, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { dirname, extname, join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const sharp = require(process.env.SHARP_PATH || '/Users/chattie/git/mercadodevida/project/apps/admin/node_modules/sharp');
|
||||
|
||||
const PROJECT_DIR = join(import.meta.dirname, '..');
|
||||
const SIZES = [40, 200];
|
||||
const APP_DIRS = [
|
||||
join(PROJECT_DIR, 'apps', 'admin'),
|
||||
join(PROJECT_DIR, 'frontend'),
|
||||
join(PROJECT_DIR, 'storefront'),
|
||||
];
|
||||
const SRC_DIR = join(PROJECT_DIR, 'apps', 'admin', 'public', 'uploads');
|
||||
|
||||
const VALID_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif']);
|
||||
|
||||
async function main() {
|
||||
let files;
|
||||
try {
|
||||
files = await readdir(SRC_DIR);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.log('[INFO] No uploads dir yet, nothing to do');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const images = files.filter((f) => VALID_EXTS.has(extname(f).toLowerCase()));
|
||||
console.log(`[INFO] Found ${images.length} source images`);
|
||||
|
||||
let generated = 0;
|
||||
for (const filename of images) {
|
||||
const src = join(SRC_DIR, filename);
|
||||
const srcStat = await stat(src);
|
||||
if (!srcStat.isFile()) continue;
|
||||
|
||||
for (const appDir of APP_DIRS) {
|
||||
// Ensure peer upload exists so the thumbnail is reachable
|
||||
const peerPath = join(appDir, 'public', 'uploads', filename);
|
||||
try {
|
||||
await stat(peerPath);
|
||||
} catch {
|
||||
// Copy from source if missing (mirror admin's uploads)
|
||||
await mkdir(dirname(peerPath), { recursive: true });
|
||||
await writeFile(peerPath, await import('node:fs/promises').then((fs) => fs.readFile(src)));
|
||||
}
|
||||
|
||||
for (const size of SIZES) {
|
||||
// Store as <app>/public/uploads/<size>/<filename> so Next.js serves
|
||||
// them as plain static files via URLs like /uploads/40/foo.jpg
|
||||
const thumbDir = join(appDir, 'public', 'uploads', String(size));
|
||||
const thumbPath = join(thumbDir, filename);
|
||||
|
||||
// Skip if already cached
|
||||
try {
|
||||
const s = await stat(thumbPath);
|
||||
if (s.isFile() && s.size > 0) continue;
|
||||
} catch {}
|
||||
|
||||
await mkdir(thumbDir, { recursive: true });
|
||||
await sharp(src)
|
||||
.resize({ width: size, withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbPath);
|
||||
generated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[OK] Processed ${images.length} images, generated ${generated} new thumbnails`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[FAIL]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
31
project/scripts/generate-thumbnails.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate cached thumbnails (40px and 200px) for every uploaded product image.
|
||||
#
|
||||
# Pre-generates thumbnails in each app's public/uploads/thumbs/<size>/ directory
|
||||
# so Next.js serves them as static files with Cache-Control: public, max-age=31536000.
|
||||
#
|
||||
# Run after every upload, as part of sync_uploads in monolith.sh, or on demand.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/generate-thumbnails.sh
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Find a node_modules that contains sharp
|
||||
SHARP_DIR=""
|
||||
for dir in "$PROJECT_DIR/apps/admin" "$PROJECT_DIR/frontend" "$PROJECT_DIR/storefront"; do
|
||||
if [[ -d "$dir/node_modules/sharp" ]]; then
|
||||
SHARP_DIR="$dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$SHARP_DIR" ]]; then
|
||||
echo "[FAIL] sharp not found; install with: cd project/apps/admin && npm install sharp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SHARP_DIR"
|
||||
exec node "$SCRIPT_DIR/generate-thumbnails.mjs"
|
||||
@@ -210,24 +210,30 @@ build_prod() {
|
||||
|
||||
sync_uploads() {
|
||||
# Mirror admin's public/uploads to frontend and storefront so that product
|
||||
# images are reachable from any of the three apps.
|
||||
# images are reachable from any of the three apps. Also mirrors every
|
||||
# generated thumbnail subdir (40/, 200/, ...) so the static cached files
|
||||
# reach all three apps in lockstep.
|
||||
local src="$PROJECT_DIR/apps/admin/public/uploads"
|
||||
local peer1="$PROJECT_DIR/frontend/public/uploads"
|
||||
local peer2="$PROJECT_DIR/storefront/public/uploads"
|
||||
local peers=(
|
||||
"$PROJECT_DIR/frontend/public/uploads"
|
||||
"$PROJECT_DIR/storefront/public/uploads"
|
||||
)
|
||||
if [[ ! -d "$src" ]]; then
|
||||
return 0
|
||||
fi
|
||||
for peer in "$peer1" "$peer2"; do
|
||||
for peer in "${peers[@]}"; do
|
||||
mkdir -p "$peer"
|
||||
# copy every file present in source but missing in peer
|
||||
while IFS= read -r -d '' f; do
|
||||
local name
|
||||
name="$(basename "$f")"
|
||||
if [[ ! -f "$peer/$name" ]]; then
|
||||
cp "$f" "$peer/$name" 2>/dev/null || true
|
||||
find "$src" -maxdepth 2 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
|
||||
local rel
|
||||
rel="${f#$src/}"
|
||||
if [[ ! -f "$peer/$rel" ]]; then
|
||||
mkdir -p "$(dirname "$peer/$rel")"
|
||||
cp "$f" "$peer/$rel" 2>/dev/null || true
|
||||
fi
|
||||
done < <(find "$src" -maxdepth 1 -type f -print0 2>/dev/null)
|
||||
done
|
||||
done
|
||||
# Ensure thumbnails are up to date for the current image set.
|
||||
"$PROJECT_DIR/scripts/generate-thumbnails.sh" --quiet
|
||||
}
|
||||
|
||||
spawn_service() {
|
||||
|
||||
4
project/storefront/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,7 +54,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1544,7 +1544,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -1563,7 +1562,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -66,7 +66,11 @@ export default async function ProductPage({ params }: PageProps) {
|
||||
<div className="overflow-hidden rounded-[2rem] border border-emerald-900/10 bg-emerald-50">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image.url} alt={image.altText} className="aspect-[4/3] w-full object-cover" />
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText}
|
||||
className="aspect-[4/3] w-full max-h-[600px] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center text-emerald-900">
|
||||
Imagen próximamente
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves uploaded product images dynamically from disk on every request.
|
||||
* Mirrors the behaviour of the admin app so newly uploaded images are
|
||||
* immediately available without a rebuild.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const filePath = path.join(process.cwd(), 'public', 'uploads', safe);
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||