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:
@@ -0,0 +1,124 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, posts, sessions, likes, follows, notifications, chatMessages, chatConversations } from '@/db';
|
||||
import { eq, or, and } from 'drizzle-orm';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { verifyPassword } from '@/lib/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Verify signature and get user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
if (signedAction.action !== 'delete_account') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { password } = signedAction.data;
|
||||
|
||||
// Verify password
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account has no password set' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is incorrect' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const userDid = user.did;
|
||||
|
||||
// Delete all user data in proper order to respect foreign keys
|
||||
|
||||
// 1. Delete chat messages sent by this user
|
||||
await db.delete(chatMessages)
|
||||
.where(eq(chatMessages.senderDid, userDid));
|
||||
|
||||
// 2. Find and delete conversations where user is a participant
|
||||
// First get conversation IDs where user is participant1 (local user)
|
||||
// For participant2, we need to check by handle since it's stored as text (can be remote)
|
||||
const conversations = await db.query.chatConversations.findMany({
|
||||
where: or(
|
||||
eq(chatConversations.participant1Id, userId),
|
||||
eq(chatConversations.participant2Handle, user.handle)
|
||||
),
|
||||
});
|
||||
|
||||
const conversationIds = conversations.map(c => c.id);
|
||||
|
||||
// Delete messages in those conversations
|
||||
if (conversationIds.length > 0) {
|
||||
for (const convId of conversationIds) {
|
||||
await db.delete(chatMessages)
|
||||
.where(eq(chatMessages.conversationId, convId));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the conversations themselves
|
||||
if (conversationIds.length > 0) {
|
||||
for (const convId of conversationIds) {
|
||||
await db.delete(chatConversations)
|
||||
.where(eq(chatConversations.id, convId));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete notifications
|
||||
await db.delete(notifications)
|
||||
.where(or(
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.actorId, userId)
|
||||
));
|
||||
|
||||
// 5. Delete likes
|
||||
await db.delete(likes)
|
||||
.where(eq(likes.userId, userId));
|
||||
|
||||
// 6. Delete follows (both directions)
|
||||
await db.delete(follows)
|
||||
.where(or(
|
||||
eq(follows.followerId, userId),
|
||||
eq(follows.followingId, userId)
|
||||
));
|
||||
|
||||
// 7. Delete posts (this will cascade delete reposts and post likes via triggers if set up)
|
||||
await db.delete(posts)
|
||||
.where(eq(posts.userId, userId));
|
||||
|
||||
// 8. Delete sessions
|
||||
await db.delete(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
// 9. Finally, delete the user
|
||||
await db.delete(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
// Clear session cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('synapsis_session');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account deleted successfully',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Account deletion error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete account' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { verifyPassword } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Verify signature and get user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
if (signedAction.action !== 'change_email') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { newEmail, currentPassword } = signedAction.data;
|
||||
|
||||
// Verify current password
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account has no password set' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(newEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, newEmail.toLowerCase()),
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is already registered to another account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update email
|
||||
await db.update(users)
|
||||
.set({ email: newEmail.toLowerCase() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Email updated successfully',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Email change error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to change email' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export async function POST(request: Request) {
|
||||
const data = createBotSchema.parse(body);
|
||||
|
||||
// Generate bot avatar using owner's S3 storage if password provided and no avatar URL
|
||||
let botAvatarUrl = data.avatarUrl;
|
||||
let botAvatarUrl: string | null | undefined = data.avatarUrl;
|
||||
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) {
|
||||
try {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
|
||||
|
||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
botHandle,
|
||||
(user.storageEndpoint ?? undefined) as string | null | undefined,
|
||||
user.storageEndpoint || undefined,
|
||||
user.storageRegion || 'auto',
|
||||
user.storageBucket,
|
||||
accessKeyId,
|
||||
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
|
||||
name: data.name,
|
||||
handle: data.handle,
|
||||
bio: data.bio,
|
||||
avatarUrl: botAvatarUrl,
|
||||
avatarUrl: botAvatarUrl ?? undefined,
|
||||
headerUrl: data.headerUrl,
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||
}
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// 1. Try exact match
|
||||
let node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// 2. 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];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have favicon data
|
||||
if (!node?.faviconData) {
|
||||
return NextResponse.json({ error: 'Favicon not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the data URL to extract MIME type and base64 data
|
||||
const dataUrl = node.faviconData;
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
|
||||
if (!match) {
|
||||
// If not a proper data URL, try to serve as-is (backward compatibility)
|
||||
return NextResponse.json({ error: 'Invalid favicon data' }, { status: 500 });
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const base64Data = match[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Return the image with proper headers
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Favicon serve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to serve favicon' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||
}
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// 1. Try exact match
|
||||
let node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// 2. 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];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have logo data
|
||||
if (!node?.logoData) {
|
||||
return NextResponse.json({ error: 'Logo not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the data URL to extract MIME type and base64 data
|
||||
const dataUrl = node.logoData;
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
|
||||
if (!match) {
|
||||
// If not a proper data URL, try to serve as-is (backward compatibility)
|
||||
return NextResponse.json({ error: 'Invalid logo data' }, { status: 500 });
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const base64Data = match[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Return the image with proper headers
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logo serve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to serve logo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user