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,3 @@
|
|||||||
|
-- Migration: Add logo_data and favicon_data columns for base64 storage
|
||||||
|
ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "logo_data" text;
|
||||||
|
ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "favicon_data" text;
|
||||||
@@ -138,7 +138,8 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const res = await fetch('/api/media/upload', {
|
formData.append('type', 'logo');
|
||||||
|
const res = await fetch('/api/admin/node/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -150,13 +151,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const nextSettings = {
|
const nextSettings = {
|
||||||
...nodeSettings,
|
...nodeSettings,
|
||||||
logoUrl: data.media?.url || data.url,
|
logoUrl: data.url,
|
||||||
};
|
};
|
||||||
setNodeSettings(nextSettings);
|
setNodeSettings(nextSettings);
|
||||||
await handleSaveSettings(nextSettings);
|
await handleSaveSettings(nextSettings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logo upload failed', error);
|
console.error('Logo upload failed', error);
|
||||||
setLogoUploadError('Upload failed. Please try again.');
|
setLogoUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingLogo(false);
|
setIsUploadingLogo(false);
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,8 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const res = await fetch('/api/media/upload', {
|
formData.append('type', 'favicon');
|
||||||
|
const res = await fetch('/api/admin/node/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -185,13 +187,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const nextSettings = {
|
const nextSettings = {
|
||||||
...nodeSettings,
|
...nodeSettings,
|
||||||
faviconUrl: data.media?.url || data.url,
|
faviconUrl: data.url,
|
||||||
};
|
};
|
||||||
setNodeSettings(nextSettings);
|
setNodeSettings(nextSettings);
|
||||||
await handleSaveSettings(nextSettings);
|
await handleSaveSettings(nextSettings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Favicon upload failed', error);
|
console.error('Favicon upload failed', error);
|
||||||
setFaviconUploadError('Upload failed. Please try again.');
|
setFaviconUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingFavicon(false);
|
setIsUploadingFavicon(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +33,15 @@ export async function PATCH(req: NextRequest) {
|
|||||||
bannerUrl: data.bannerUrl,
|
bannerUrl: data.bannerUrl,
|
||||||
logoUrl: data.logoUrl,
|
logoUrl: data.logoUrl,
|
||||||
faviconUrl: data.faviconUrl,
|
faviconUrl: data.faviconUrl,
|
||||||
|
logoData: data.logoData,
|
||||||
|
faviconData: data.faviconData,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
isNsfw: data.isNsfw ?? false,
|
isNsfw: data.isNsfw ?? false,
|
||||||
turnstileSiteKey: data.turnstileSiteKey,
|
turnstileSiteKey: data.turnstileSiteKey,
|
||||||
turnstileSecretKey: data.turnstileSecretKey,
|
turnstileSecretKey: data.turnstileSecretKey,
|
||||||
}).returning();
|
}).returning();
|
||||||
} else {
|
} else {
|
||||||
[node] = await db.update(nodes)
|
const updateData: Record<string, unknown> = {
|
||||||
.set({
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
longDescription: data.longDescription,
|
longDescription: data.longDescription,
|
||||||
@@ -55,7 +56,18 @@ export async function PATCH(req: NextRequest) {
|
|||||||
// Fix domain drift: ensure the DB matches the current environment
|
// Fix domain drift: ensure the DB matches the current environment
|
||||||
domain: domain,
|
domain: domain,
|
||||||
updatedAt: new Date(),
|
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(updateData)
|
||||||
.where(eq(nodes.id, node.id))
|
.where(eq(nodes.id, node.id))
|
||||||
.returning();
|
.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);
|
const data = createBotSchema.parse(body);
|
||||||
|
|
||||||
// Generate bot avatar using owner's S3 storage if password provided and no avatar URL
|
// 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) {
|
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) {
|
||||||
try {
|
try {
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||||
botHandle,
|
botHandle,
|
||||||
(user.storageEndpoint ?? undefined) as string | null | undefined,
|
user.storageEndpoint || undefined,
|
||||||
user.storageRegion || 'auto',
|
user.storageRegion || 'auto',
|
||||||
user.storageBucket,
|
user.storageBucket,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
handle: data.handle,
|
handle: data.handle,
|
||||||
bio: data.bio,
|
bio: data.bio,
|
||||||
avatarUrl: botAvatarUrl,
|
avatarUrl: botAvatarUrl ?? undefined,
|
||||||
headerUrl: data.headerUrl,
|
headerUrl: data.headerUrl,
|
||||||
personality: data.personality,
|
personality: data.personality,
|
||||||
llmProvider: data.llmProvider,
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-2
@@ -577,8 +577,36 @@ export default function LoginPage() {
|
|||||||
<option value="minio">MinIO / Self-hosted</option>
|
<option value="minio">MinIO / Self-hosted</option>
|
||||||
<option value="other">Other S3-compatible</option>
|
<option value="other">Other S3-compatible</option>
|
||||||
</select>
|
</select>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
</div>
|
||||||
You own your storage. We just connect to it.
|
</div>
|
||||||
|
|
||||||
|
{/* Storage Explainer Section */}
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||||
|
🗄️ Why do I need to provide storage?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '12px' }}>
|
||||||
|
Synapsis is <strong>decentralized</strong> — unlike other platforms, we don't store your photos, videos, or files on our servers.
|
||||||
|
Instead, you connect your own S3-compatible storage bucket (like Cloudflare R2 or Backblaze B2) where your media lives.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<strong style={{ color: 'var(--success)' }}>✓ You own your data</strong><br/>
|
||||||
|
Your files stay in your bucket, not ours
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<strong style={{ color: 'var(--success)' }}>✓ Portable</strong><br/>
|
||||||
|
Take your media with you if you leave
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<strong style={{ color: 'var(--success)' }}>✓ Free options</strong><br/>
|
||||||
|
R2 and B2 offer 10GB free
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
|
import { Shield, Lock, Check, AlertCircle, Mail, Trash2 } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
export default function SecuritySettingsPage() {
|
export default function SecuritySettingsPage() {
|
||||||
@@ -13,7 +13,21 @@ export default function SecuritySettingsPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const { isIdentityUnlocked, signUserAction } = useAuth();
|
const { isIdentityUnlocked, signUserAction, user } = useAuth();
|
||||||
|
|
||||||
|
// Email change states
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [emailPassword, setEmailPassword] = useState('');
|
||||||
|
const [isChangingEmail, setIsChangingEmail] = useState(false);
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Delete account states
|
||||||
|
const [deletePassword, setDeletePassword] = useState('');
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -72,6 +86,97 @@ export default function SecuritySettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEmailChange = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setEmailError(null);
|
||||||
|
setEmailSuccess(null);
|
||||||
|
|
||||||
|
if (!newEmail || !emailPassword) {
|
||||||
|
setEmailError('Please enter your new email and current password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setEmailError('Your session has expired. Please log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChangingEmail(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedPayload = await signUserAction('change_email', {
|
||||||
|
newEmail,
|
||||||
|
currentPassword: emailPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch('/api/account/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(signedPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to change email');
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailSuccess('Email updated successfully');
|
||||||
|
setNewEmail('');
|
||||||
|
setEmailPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
setEmailError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsChangingEmail(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeleteError(null);
|
||||||
|
|
||||||
|
if (deleteConfirm !== 'DELETE') {
|
||||||
|
setDeleteError('Please type DELETE to confirm account deletion');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deletePassword) {
|
||||||
|
setDeleteError('Please enter your password to confirm');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setDeleteError('Your session has expired. Please log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedPayload = await signUserAction('delete_account', {
|
||||||
|
password: deletePassword
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch('/api/account/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(signedPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to delete account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account deleted - redirect to home
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
setDeleteError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
<header style={{
|
<header style={{
|
||||||
@@ -220,6 +325,266 @@ export default function SecuritySettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Change Email Section */}
|
||||||
|
<div className="card" style={{ padding: '24px', marginTop: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--background-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Mail size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Change Email</h2>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
Update your email address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleEmailChange}>
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
|
Current Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={user?.email || ''}
|
||||||
|
disabled
|
||||||
|
style={{ opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
|
New Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
placeholder="Enter new email address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
|
Current Password (for verification)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={emailPassword}
|
||||||
|
onChange={(e) => setEmailPassword(e.target.value)}
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{emailError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailSuccess && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--success)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<Check size={16} />
|
||||||
|
{emailSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isChangingEmail || !newEmail || !emailPassword}
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}
|
||||||
|
>
|
||||||
|
{isChangingEmail ? (
|
||||||
|
'Updating...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mail size={16} /> Update Email
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Account Section */}
|
||||||
|
<div className="card" style={{ padding: '24px', marginTop: '24px', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--error)',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--error)' }}>Delete Account</h2>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
Permanently delete your account and all data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showDeleteConfirm ? (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
<strong style={{ color: 'var(--error)' }}>Warning:</strong> This action cannot be undone.
|
||||||
|
Your account, posts, and all associated data will be permanently deleted.
|
||||||
|
Your handle will be released and may be claimed by someone else.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
color: 'var(--error)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I want to delete my account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleDeleteAccount}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
<strong style={{ color: 'var(--error)' }}>This is permanent.</strong> Type <code style={{ background: 'var(--background)', padding: '2px 6px', borderRadius: '4px' }}>DELETE</code> below to confirm.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
|
Type DELETE to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={deleteConfirm}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
placeholder="DELETE"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
|
Your Password (for verification)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={deletePassword}
|
||||||
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeleteConfirm('');
|
||||||
|
setDeletePassword('');
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn"
|
||||||
|
disabled={isDeleting || deleteConfirm !== 'DELETE' || !deletePassword}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'var(--error)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete My Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { LogOut, Settings2, Unlock } from 'lucide-react';
|
import { LogOut, Settings2 } from 'lucide-react';
|
||||||
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, isAdmin, logout, isIdentityUnlocked, lockIdentity } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||||
@@ -192,55 +192,6 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Identity Status - Only show when unlocked with option to lock */}
|
|
||||||
{isIdentityUnlocked && (
|
|
||||||
<div
|
|
||||||
onClick={() => lockIdentity()}
|
|
||||||
title="Click to lock your identity"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '10px 12px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: 'rgba(34, 197, 94, 0.1)',
|
|
||||||
border: '1px solid rgba(34, 197, 94, 0.2)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.15)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Unlock size={16} style={{ color: 'rgb(34, 197, 94)', flexShrink: 0 }} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'rgb(34, 197, 94)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
|
||||||
Identity Unlocked
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--foreground-tertiary)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
|
||||||
Click to lock
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
disabled={loggingOut}
|
disabled={loggingOut}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const nodes = pgTable('nodes', {
|
|||||||
bannerUrl: text('banner_url'),
|
bannerUrl: text('banner_url'),
|
||||||
logoUrl: text('logo_url'),
|
logoUrl: text('logo_url'),
|
||||||
faviconUrl: text('favicon_url'),
|
faviconUrl: text('favicon_url'),
|
||||||
|
logoData: text('logo_data'), // Base64 encoded logo image
|
||||||
|
faviconData: text('favicon_data'), // Base64 encoded favicon image
|
||||||
accentColor: text('accent_color').default('#FFFFFF'),
|
accentColor: text('accent_color').default('#FFFFFF'),
|
||||||
publicKey: text('public_key'),
|
publicKey: text('public_key'),
|
||||||
privateKeyEncrypted: text('private_key_encrypted'), // Encrypted with AUTH_SECRET
|
privateKeyEncrypted: text('private_key_encrypted'), // Encrypted with AUTH_SECRET
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
|
|
||||||
export async function generateAndUploadAvatar(handle: string): Promise<string | null> {
|
export async function generateAndUploadAvatar(handle: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// 1. Fetch the avatar from DiceBear
|
// 1. Fetch the avatar from DiceBear (PNG format for better compatibility)
|
||||||
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
|
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/png?seed=${handle}`;
|
||||||
const response = await fetch(dicebearUrl);
|
const response = await fetch(dicebearUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -29,13 +29,13 @@ export async function generateAndUploadAvatar(handle: string): Promise<string |
|
|||||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||||
// Sanitize handle for filename just in case
|
// Sanitize handle for filename just in case
|
||||||
const safeHandle = handle.replace(/[^a-zA-Z0-9]/g, '');
|
const safeHandle = handle.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
const filename = `${uuidv4()}-${safeHandle}-avatar.svg`;
|
const filename = `${uuidv4()}-${safeHandle}-avatar.png`;
|
||||||
|
|
||||||
await s3.send(new PutObjectCommand({
|
await s3.send(new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: filename,
|
Key: filename,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ContentType: 'image/svg+xml',
|
ContentType: 'image/png',
|
||||||
ACL: 'public-read',
|
ACL: 'public-read',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export async function registerUser(
|
|||||||
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
|
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
|
||||||
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||||
fullHandle,
|
fullHandle,
|
||||||
storageEndpoint || null,
|
storageEndpoint || undefined,
|
||||||
storageRegion,
|
storageRegion,
|
||||||
storageBucket,
|
storageBucket,
|
||||||
storageAccessKey,
|
storageAccessKey,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
did?: string;
|
did?: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
|
|||||||
@@ -164,15 +164,15 @@ export async function testS3Credentials(
|
|||||||
*/
|
*/
|
||||||
export async function generateAndUploadAvatarToUserStorage(
|
export async function generateAndUploadAvatarToUserStorage(
|
||||||
handle: string,
|
handle: string,
|
||||||
endpoint: string | null | undefined,
|
endpoint: string | undefined,
|
||||||
region: string,
|
region: string,
|
||||||
bucket: string,
|
bucket: string,
|
||||||
accessKey: string,
|
accessKey: string,
|
||||||
secretKey: string
|
secretKey: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// 1. Fetch the avatar from DiceBear
|
// 1. Fetch the avatar from DiceBear (PNG format for better compatibility)
|
||||||
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
|
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/png?seed=${handle}`;
|
||||||
const response = await fetch(dicebearUrl);
|
const response = await fetch(dicebearUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -192,13 +192,13 @@ export async function generateAndUploadAvatarToUserStorage(
|
|||||||
bucket,
|
bucket,
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.svg`;
|
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`;
|
||||||
|
|
||||||
await s3.send(new PutObjectCommand({
|
await s3.send(new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ContentType: 'image/svg+xml',
|
ContentType: 'image/png',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. Return URL
|
// 3. Return URL
|
||||||
|
|||||||
Reference in New Issue
Block a user