Files
BelgianBitcoinEmbassy/frontend/app/media/[id]/route.ts
bbe 2ddf6495fb Ignore local storage; admin users NIP-05, media, events, footer updates
- Add /storage/ and /backend/storage/ to .gitignore
- Track meetup time helper, logo asset, and assorted frontend/backend fixes
2026-04-02 22:13:28 +02:00

230 lines
6.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
/** Walk up from cwd until we find backend + frontend package.json (monorepo root). */
function findMonorepoRoot(start: string): string {
let dir = path.resolve(start);
for (let i = 0; i < 8; i++) {
if (
fs.existsSync(path.join(dir, 'backend', 'package.json')) &&
fs.existsSync(path.join(dir, 'frontend', 'package.json'))
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(start, '..');
}
/**
* Resolve relative MEDIA_STORAGE_PATH against monorepo root — matches
* `backend/src/api/media.ts` (REPO_ROOT + relative path).
*/
function resolveMediaStoragePathFromEnv(envPath: string): string {
if (path.isAbsolute(envPath)) {
return envPath;
}
return path.resolve(findMonorepoRoot(process.cwd()), envPath);
}
/** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */
function getMediaStorageRoot(): string {
const envPath = process.env['MEDIA_STORAGE_PATH'];
if (envPath) {
return resolveMediaStoragePathFromEnv(envPath);
}
const root = findMonorepoRoot(process.cwd());
// Prefer repo-root `storage/media` first — matches backend default in `backend/src/api/media.ts`.
const candidates = [
path.join(root, 'storage', 'media'),
path.join(root, 'backend', 'storage', 'media'),
];
for (const dir of candidates) {
if (fs.existsSync(dir)) {
return dir;
}
}
return candidates[0];
}
const CACHE_HEADERS = {
'Cache-Control': 'public, max-age=31536000, immutable',
};
interface MediaMeta {
mimeType: string;
type: 'image' | 'video';
size: number;
}
function readMeta(root: string, id: string): MediaMeta | null {
const metaPath = path.join(root, `${id}.json`);
try {
const raw = fs.readFileSync(metaPath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
async function handleImageResize(
root: string,
filePath: string,
width: number,
meta: MediaMeta,
id: string
): Promise<NextResponse> {
const cachePath = path.join(root, 'cache');
fs.mkdirSync(cachePath, { recursive: true });
const cacheKey = `${id}_w${width}`;
const cachedFile = path.join(cachePath, cacheKey);
if (fileExists(cachedFile)) {
const cached = fs.readFileSync(cachedFile);
return new NextResponse(new Uint8Array(cached), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(cached.length),
...CACHE_HEADERS,
},
});
}
const buffer = fs.readFileSync(filePath);
const resized = await sharp(buffer)
.resize({ width, withoutEnlargement: true })
.toBuffer();
fs.writeFileSync(cachedFile, resized);
return new NextResponse(new Uint8Array(resized), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(resized.length),
...CACHE_HEADERS,
},
});
}
function handleVideoStream(
filePath: string,
meta: MediaMeta,
rangeHeader: string | null
): NextResponse {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(chunkSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
const stream = fs.createReadStream(filePath);
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 200,
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': String(fileSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const root = getMediaStorageRoot();
const filePath = path.join(root, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
}