feat: store node logo/favicon in postgres instead of S3

- Add logoData and faviconData columns to nodes table
- Create /api/admin/node/upload endpoint for direct DB storage
- Create /api/node/logo and /api/node/favicon endpoints to serve images
- Update admin page to use new upload endpoint
- Remove dependency on admin's personal S3 storage for node assets
This commit is contained in:
Christomatt
2026-02-01 16:29:39 +01:00
parent 0cea2e9e1f
commit 90dfd62434
17 changed files with 913 additions and 90 deletions
+28 -16
View File
@@ -33,29 +33,41 @@ export async function PATCH(req: NextRequest) {
bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
logoData: data.logoData,
faviconData: data.faviconData,
accentColor: data.accentColor,
isNsfw: data.isNsfw ?? false,
turnstileSiteKey: data.turnstileSiteKey,
turnstileSecretKey: data.turnstileSecretKey,
}).returning();
} else {
const updateData: Record<string, unknown> = {
name: data.name,
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor,
isNsfw: data.isNsfw ?? node.isNsfw,
turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey,
turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey,
// Fix domain drift: ensure the DB matches the current environment
domain: domain,
updatedAt: new Date(),
};
// Only update logoData/faviconData if explicitly provided
if (data.logoData !== undefined) {
updateData.logoData = data.logoData;
}
if (data.faviconData !== undefined) {
updateData.faviconData = data.faviconData;
}
[node] = await db.update(nodes)
.set({
name: data.name,
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor,
isNsfw: data.isNsfw ?? node.isNsfw,
turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey,
turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey,
// Fix domain drift: ensure the DB matches the current environment
domain: domain,
updatedAt: new Date(),
})
.set(updateData)
.where(eq(nodes.id, node.id))
.returning();
}
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, nodes } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '@/lib/auth/admin';
// Logo constraints
const MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED_LOGO_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/svg+xml',
];
// Favicon constraints
const MAX_FAVICON_SIZE = 500 * 1024; // 500KB
const ALLOWED_FAVICON_TYPES = [
'image/x-icon',
'image/vnd.microsoft.icon',
'image/png',
'image/svg+xml',
];
// Map file extensions to MIME types for validation
const MIME_TYPE_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
export async function POST(req: NextRequest) {
try {
await requireAdmin();
const formData = await req.formData();
const file = formData.get('file') as File | null;
const type = formData.get('type') as 'logo' | 'favicon' | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!type || (type !== 'logo' && type !== 'favicon')) {
return NextResponse.json({ error: 'Invalid type. Must be "logo" or "favicon"' }, { status: 400 });
}
// Determine constraints based on type
const isLogo = type === 'logo';
const maxSize = isLogo ? MAX_LOGO_SIZE : MAX_FAVICON_SIZE;
const allowedTypes = isLogo ? ALLOWED_LOGO_TYPES : ALLOWED_FAVICON_TYPES;
const typeName = isLogo ? 'Logo' : 'Favicon';
// Validate file size
if (file.size > maxSize) {
return NextResponse.json(
{ error: `${typeName} too large. Maximum size: ${isLogo ? '2MB' : '500KB'}` },
{ status: 400 }
);
}
// Validate MIME type
let mimeType = file.type;
// Handle cases where browser might not set correct MIME type
if (!mimeType || mimeType === 'application/octet-stream') {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
mimeType = MIME_TYPE_MAP[ext] || '';
}
// Special handling for .ico files
if (file.name.toLowerCase().endsWith('.ico')) {
mimeType = 'image/x-icon';
}
if (!allowedTypes.includes(mimeType)) {
const allowedList = isLogo
? 'PNG, JPG, GIF, WebP, SVG'
: 'ICO, PNG, SVG';
return NextResponse.json(
{ error: `Invalid file type for ${typeName.toLowerCase()}. Allowed: ${allowedList}` },
{ status: 400 }
);
}
// Convert file to base64
const buffer = Buffer.from(await file.arrayBuffer());
const base64Data = buffer.toString('base64');
const dataUrl = `data:${mimeType};base64,${base64Data}`;
// Get current node
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
// Fallback: If not found, check if there is exactly ONE node in the system
if (!node) {
const allNodes = await db.query.nodes.findMany({ limit: 2 });
if (allNodes.length === 1) {
node = allNodes[0];
}
}
if (!node) {
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
}
// Update the appropriate field
const updateData = isLogo
? { logoData: dataUrl, logoUrl: `/api/node/logo`, updatedAt: new Date() }
: { faviconData: dataUrl, faviconUrl: `/api/node/favicon`, updatedAt: new Date() };
await db.update(nodes)
.set(updateData)
.where(eq(nodes.id, node.id));
return NextResponse.json({
success: true,
url: isLogo ? '/api/node/logo' : '/api/node/favicon',
type,
size: file.size,
});
} catch (error) {
if (error instanceof Error && error.message === 'Admin authentication required') {
return NextResponse.json({ error: 'Admin authentication required' }, { status: 401 });
}
console.error('Node upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}