feat(F-052): completed feature

This commit is contained in:
chattie
2026-08-19 10:47:17 +02:00
parent 30c131a809
commit adf1d20c4c
19 changed files with 424 additions and 135 deletions

View File

@@ -1,8 +1,28 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { NextRequest, NextResponse } from 'next/server';
/**
* Peer upload directories (frontend and storefront) that must mirror the
* admin's own `public/uploads/`. After every successful upload, the file
* is copied to each peer so that product pages render images regardless
* of which app the URL is fetched from.
*/
const PEER_UPLOAD_DIRS = [
path.join(process.cwd(), '..', '..', 'frontend', 'public', 'uploads'),
path.join(process.cwd(), '..', '..', 'storefront', 'public', 'uploads'),
];
async function mirrorToPeers(filePath: string): Promise<void> {
await Promise.allSettled(
PEER_UPLOAD_DIRS.map(async (dir) => {
await mkdir(dir, { recursive: true });
await copyFile(filePath, path.join(dir, path.basename(filePath)));
}),
);
}
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
const MAX_SIZE = 10 * 1024 * 1024;
const EXTENSION_BY_TYPE: Readonly<Record<string, string>> = {
@@ -49,6 +69,7 @@ export async function POST(request: NextRequest) {
await mkdir(uploadDir, { recursive: true });
await writeFile(filePath, buffer, { flag: 'wx' });
await mirrorToPeers(filePath);
return NextResponse.json({
url: `/uploads/${filename}`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,49 @@
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 });
}
}

View File

@@ -203,6 +203,29 @@ build_prod() {
(cd "$PROJECT_DIR/frontend" && NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build)
echo '[INFO] Building SEO storefront...'
(cd "$PROJECT_DIR/storefront" && API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build)
sync_uploads
}
sync_uploads() {
# Mirror admin's public/uploads to frontend and storefront so that product
# images are reachable from any of the three apps.
local src="$PROJECT_DIR/apps/admin/public/uploads"
local peer1="$PROJECT_DIR/frontend/public/uploads"
local peer2="$PROJECT_DIR/storefront/public/uploads"
if [[ ! -d "$src" ]]; then
return 0
fi
for peer in "$peer1" "$peer2"; 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
fi
done < <(find "$src" -maxdepth 1 -type f -print0 2>/dev/null)
done
}
spawn_service() {
@@ -262,6 +285,7 @@ start_all() {
install_dependencies
migrate
[[ "$MODE" == "prod" ]] && build_prod
sync_uploads
for service in "${SERVICES[@]}"; do spawn_service "$service"; done
echo
print_urls

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,45 @@
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 });
}
}