feat(F-052): completed feature
This commit is contained in:
@@ -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 |
49
project/frontend/src/app/uploads/[filename]/route.ts
Normal file
49
project/frontend/src/app/uploads/[filename]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 |
45
project/storefront/src/app/uploads/[filename]/route.ts
Normal file
45
project/storefront/src/app/uploads/[filename]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user