Files
immich-folio/app/api/admin/favicon/route.ts
Ralksta a3a6319c29 release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
2026-09-22 14:12:13 +02:00

92 lines
3.0 KiB
TypeScript

import { NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { promises as fs } from 'fs';
import path from 'path';
import { atomicWrite } from '@/lib/atomicWrite';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const DEST = 'favicon.svg';
const MAX_SIZE = 512 * 1024; // 512 kB
/**
* Write via temp file + rename, the same pattern yaml-service uses: GET
* /api/favicon reads this path on every request, so a plain writeFile would
* let a concurrent read see a half-written icon.
*/
async function writeFavicon(content: Buffer | string): Promise<void> {
await fs.mkdir(CONTENT_DIR, { recursive: true });
const dest = path.join(CONTENT_DIR, DEST);
await atomicWrite(dest, content);
}
export const PUT = withAdmin(async (request: Request) => {
let body: FormData;
try {
body = await request.formData();
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
// A plain text field named "file" also satisfies `body.get('file')`, and the
// .size/.arrayBuffer() calls below would then throw a 500 on what is really
// a malformed request.
const file = body.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File must be under 512 kB' }, { status: 400 });
}
const buf = Buffer.from(await file.arrayBuffer());
// SVG passes through directly — CSP on the GET route protects the origin.
if (file.type === 'image/svg+xml') {
const text = buf.toString('utf-8');
if (!text.includes('<svg')) {
return NextResponse.json({ error: 'Invalid SVG file' }, { status: 400 });
}
await writeFavicon(buf);
return NextResponse.json({ success: true, message: 'Favicon updated.' });
}
// Raster formats (PNG, ICO, JPEG) — wrap in an SVG container so the favicon
// reference and CSP apply uniformly regardless of the uploaded format.
const mime = file.type || 'image/png';
if (
mime !== 'image/png' &&
mime !== 'image/x-icon' &&
mime !== 'image/vnd.microsoft.icon' &&
mime !== 'image/jpeg'
) {
return NextResponse.json(
{ error: `Unsupported file type: ${mime}. Use SVG, PNG, ICO, or JPEG.` },
{ status: 400 },
);
}
const b64 = buf.toString('base64');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<image href="data:${mime};base64,${b64}" width="32" height="32"/>
</svg>
`;
await writeFavicon(svg);
return NextResponse.json({ success: true, message: 'Favicon updated.' });
});
/** Clean up stale favicon files left by a previous upload of a different format. */
export const DELETE = withAdmin(async () => {
try {
await fs.unlink(path.join(CONTENT_DIR, DEST));
return NextResponse.json({
success: true,
message: 'Custom favicon removed. Default restored.',
});
} catch {
return NextResponse.json({ error: 'No custom favicon to remove' }, { status: 404 });
}
});