diff --git a/scripts/clear-chat-keys.ts b/scripts/clear-chat-keys.ts deleted file mode 100644 index b838069..0000000 --- a/scripts/clear-chat-keys.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { db } from '../src/db'; -import { chatDeviceBundles } from '../src/db/schema'; - -async function clearChatKeys() { - console.log('Clearing all chat device bundles...'); - - const result = await db.delete(chatDeviceBundles); - - console.log('Cleared chat device bundles'); - process.exit(0); -} - -clearChatKeys().catch((error) => { - console.error('Failed to clear keys:', error); - process.exit(1); -}); diff --git a/src/app/.well-known/synapsis/chat/[did]/route.ts b/src/app/.well-known/synapsis/chat/[did]/route.ts deleted file mode 100644 index 16533b6..0000000 --- a/src/app/.well-known/synapsis/chat/[did]/route.ts +++ /dev/null @@ -1,48 +0,0 @@ - -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatDeviceBundles } from '@/db/schema'; -import { eq } from 'drizzle-orm'; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ did: string }> } -) { - const { did } = await params; - - // Fetch device bundle for this DID - const bundle = await db.query.chatDeviceBundles.findFirst({ - where: eq(chatDeviceBundles.did, did), - }); - - if (!bundle) { - return NextResponse.json({ error: 'No keys found' }, { status: 404 }); - } - - const signedPreKey = JSON.parse(bundle.signedPreKey); - const kyberPreKey = bundle.kyberPreKey ? JSON.parse(bundle.kyberPreKey) : null; - - // Format Response for Olm - const response = { - identityKey: bundle.identityKey, // curve25519 - signingKey: signedPreKey.signingKey, // ed25519 - oneTimeKeys: kyberPreKey?.oneTimeKeys || [], - }; - - return NextResponse.json(response, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'max-age=60' - } - }); -} - -export async function OPTIONS() { - return new NextResponse(null, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - }); -} diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts index c5c7b51..195ddf7 100644 --- a/src/app/api/account/export/route.ts +++ b/src/app/api/account/export/route.ts @@ -7,9 +7,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, verifyPassword } from '@/lib/auth'; -import { db, posts, media, follows, users, remoteFollows } from '@/db'; +import { db, posts, media, follows, users, remoteFollows, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db'; import { eq } from 'drizzle-orm'; import * as crypto from 'crypto'; +import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption'; // We'll use a simple in-memory zip approach // For production, consider using a streaming zip library @@ -51,6 +52,47 @@ interface ExportFollowing { avatarUrl?: string | null; } +interface ExportDMConversation { + id: string; + type: string; + participant2Handle: string; + lastMessageAt: string | null; + lastMessagePreview: string | null; + messages: ExportDMMessage[]; +} + +interface ExportDMMessage { + senderHandle: string; + senderDisplayName: string | null; + senderAvatarUrl: string | null; + senderNodeDomain: string | null; + senderDid: string | null; + content: string | null; + deliveredAt: string | null; + readAt: string | null; + createdAt: string; +} + +interface ExportBot { + id: string; + name: string; + handle: string; + bio: string | null; + avatarUrl: string | null; + headerUrl: string | null; + personalityConfig: any; + llmProvider: string; + llmModel: string; + llmApiKey: string; // Decrypted + botPrivateKey: string; // Decrypted + publicKey: string; + scheduleConfig: any; + autonomousMode: boolean; + isActive: boolean; + sources: any[]; + activityLogs: any[]; +} + /** * Encrypt the private key with user's password using AES-256-GCM */ @@ -136,6 +178,27 @@ export async function POST(req: NextRequest) { where: eq(remoteFollows.followerId, user.id), }); + // Fetch DMs + const userConversations = await db.query.chatConversations.findMany({ + where: eq(chatConversations.participant1Id, user.id), + with: { + messages: true + } + }); + + // Fetch Bots + const userBots = await db.query.bots.findMany({ + where: eq(bots.ownerId, user.id), + with: { + user: true, + contentSources: true, + activityLogs: { + limit: 50, + orderBy: (logs, { desc }) => [desc(logs.createdAt)] + } + } + }); + // Build export data const exportPosts: ExportPost[] = userPosts.map(post => ({ id: post.id, @@ -177,6 +240,75 @@ export async function POST(req: NextRequest) { headerUrl: user.headerUrl, }; + const exportDMs: ExportDMConversation[] = userConversations.map(conv => ({ + id: conv.id, + type: conv.type, + participant2Handle: conv.participant2Handle, + lastMessageAt: conv.lastMessageAt?.toISOString() || null, + lastMessagePreview: conv.lastMessagePreview, + messages: conv.messages.map(msg => ({ + senderHandle: msg.senderHandle, + senderDisplayName: msg.senderDisplayName, + senderAvatarUrl: msg.senderAvatarUrl, + senderNodeDomain: msg.senderNodeDomain, + senderDid: msg.senderDid, + content: msg.content, + deliveredAt: msg.deliveredAt?.toISOString() || null, + readAt: msg.readAt?.toISOString() || null, + createdAt: msg.createdAt.toISOString() + })) + })); + + const exportBots: ExportBot[] = userBots.map(bot => { + // Decrypt bot keys + let llmApiKey = ''; + let botPrivateKey = ''; + try { + if (bot.llmApiKeyEncrypted) { + llmApiKey = decryptApiKey(deserializeEncryptedData(bot.llmApiKeyEncrypted)); + } + if (bot.user?.privateKeyEncrypted) { + botPrivateKey = decryptApiKey(deserializeEncryptedData(bot.user.privateKeyEncrypted)); + } + } catch (e) { + console.error(`Failed to decrypt keys for bot ${bot.name}:`, e); + } + + return { + id: bot.id, + name: bot.name, + handle: bot.user.handle, + bio: bot.user.bio, + avatarUrl: bot.user.avatarUrl, + headerUrl: bot.user.headerUrl, + personalityConfig: JSON.parse(bot.personalityConfig), + llmProvider: bot.llmProvider, + llmModel: bot.llmModel, + llmApiKey, + botPrivateKey, + publicKey: bot.user.publicKey, + scheduleConfig: bot.scheduleConfig ? JSON.parse(bot.scheduleConfig) : null, + autonomousMode: bot.autonomousMode, + isActive: bot.isActive, + sources: bot.contentSources.map(s => ({ + type: s.type, + url: s.url, + subreddit: s.subreddit, + apiKeyEncrypted: s.apiKeyEncrypted, // These are specific to sources and might need their own decryption if they use AUTH_SECRET + sourceConfig: s.sourceConfig ? JSON.parse(s.sourceConfig) : null, + keywords: s.keywords ? JSON.parse(s.keywords) : null, + isActive: s.isActive + })), + activityLogs: bot.activityLogs.map(l => ({ + action: l.action, + details: JSON.parse(l.details), + success: l.success, + errorMessage: l.errorMessage, + createdAt: l.createdAt.toISOString() + })) + }; + }); + // Encrypt private key const privateKey = user.privateKeyEncrypted || ''; const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password); @@ -205,8 +337,8 @@ export async function POST(req: NextRequest) { profile, posts: exportPosts, following: exportFollowing, - // Media URLs are included in posts, client can download them separately - // For full ZIP export, we'd need to fetch and bundle media files + dms: exportDMs, + bots: exportBots, }; return NextResponse.json({ @@ -215,6 +347,8 @@ export async function POST(req: NextRequest) { stats: { posts: exportPosts.length, following: exportFollowing.length, + dms: exportDMs.length, + bots: exportBots.length, mediaFiles: exportPosts.reduce((sum, p) => sum + p.media.length, 0), }, }); diff --git a/src/app/api/account/import/route.ts b/src/app/api/account/import/route.ts index 3ba9d6b..c85481f 100644 --- a/src/app/api/account/import/route.ts +++ b/src/app/api/account/import/route.ts @@ -6,9 +6,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, users, posts, media, follows, nodes } from '@/db'; +import { db, users, posts, media, follows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db'; import { eq } from 'drizzle-orm'; import * as crypto from 'crypto'; +import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption'; import { v4 as uuid } from 'uuid'; import { upsertHandleEntries } from '@/lib/federation/handles'; @@ -45,11 +46,54 @@ interface ImportFollowing { handle: string; } +interface ImportDMConversation { + id: string; + type: string; + participant2Handle: string; + lastMessageAt: string | null; + lastMessagePreview: string | null; + messages: ImportDMMessage[]; +} + +interface ImportDMMessage { + senderHandle: string; + senderDisplayName: string | null; + senderAvatarUrl: string | null; + senderNodeDomain: string | null; + senderDid: string | null; + content: string | null; + deliveredAt: string | null; + readAt: string | null; + createdAt: string; +} + +interface ImportBot { + id: string; + name: string; + handle: string; + bio: string | null; + avatarUrl: string | null; + headerUrl: string | null; + personalityConfig: any; + llmProvider: string; + llmModel: string; + llmApiKey: string; + botPrivateKey: string; + publicKey: string; + scheduleConfig: any; + autonomousMode: boolean; + isActive: boolean; + sources: any[]; + activityLogs: any[]; +} + interface ImportPackage { manifest: ImportManifest; profile: ImportProfile; posts: ImportPost[]; following: ImportFollowing[]; + dms: ImportDMConversation[]; + bots: ImportBot[]; } /** @@ -123,6 +167,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 }); } + const { dms: importDMs = [], bots: importBots = [] } = exportData; + // Verify signature if (!verifyManifestSignature(manifest)) { return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 }); @@ -198,9 +244,9 @@ export async function POST(req: NextRequest) { if (node?.isNsfw) { await db.update(users) - .set({ + .set({ nsfwEnabled: true, - isNsfw: true + isNsfw: true }) .where(eq(users.id, newUser.id)); } @@ -241,6 +287,105 @@ export async function POST(req: NextRequest) { updatedAt: new Date().toISOString(), }]); + // Import DMs + for (const conv of importDMs) { + try { + const [newConv] = await db.insert(chatConversations).values({ + participant1Id: newUser.id, + participant2Handle: conv.participant2Handle, + type: conv.type, + lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null, + lastMessagePreview: conv.lastMessagePreview + }).returning(); + + for (const msg of conv.messages) { + await db.insert(chatMessages).values({ + conversationId: newConv.id, + senderHandle: msg.senderHandle, + senderDisplayName: msg.senderDisplayName, + senderAvatarUrl: msg.senderAvatarUrl, + senderNodeDomain: msg.senderNodeDomain, + senderDid: msg.senderDid, + content: msg.content, + deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null, + readAt: msg.readAt ? new Date(msg.readAt) : null, + createdAt: new Date(msg.createdAt) + }); + } + } catch (error) { + console.error('Failed to import DM conversation:', error); + } + } + + // Import Bots + for (const botData of importBots) { + try { + // 1. Create Bot User Account + const botDid = `did:web:${nodeDomain}:users:${botData.handle.toLowerCase()}`; + + // Re-encrypt bot private key + const encryptedBotPrivKey = encryptApiKey(botData.botPrivateKey); + + const [botUser] = await db.insert(users).values({ + did: botDid, + handle: botData.handle.toLowerCase(), + displayName: botData.name, + bio: botData.bio, + avatarUrl: botData.avatarUrl, + headerUrl: botData.headerUrl, + publicKey: botData.publicKey, + privateKeyEncrypted: serializeEncryptedData(encryptedBotPrivKey), + isBot: true, + botOwnerId: newUser.id + }).returning(); + + // 2. Create Bot Config + const encryptedLlmKey = encryptApiKey(botData.llmApiKey); + + const [newBot] = await db.insert(bots).values({ + userId: botUser.id, + ownerId: newUser.id, + name: botData.name, + personalityConfig: JSON.stringify(botData.personalityConfig), + llmProvider: botData.llmProvider, + llmModel: botData.llmModel, + llmApiKeyEncrypted: serializeEncryptedData(encryptedLlmKey), + scheduleConfig: botData.scheduleConfig ? JSON.stringify(botData.scheduleConfig) : null, + autonomousMode: botData.autonomousMode, + isActive: botData.isActive + }).returning(); + + // 3. Import Sources + for (const source of botData.sources) { + await db.insert(botContentSources).values({ + botId: newBot.id, + type: source.type, + url: source.url, + subreddit: source.subreddit, + apiKeyEncrypted: source.apiKeyEncrypted, + sourceConfig: source.sourceConfig ? JSON.stringify(source.sourceConfig) : null, + keywords: source.keywords ? JSON.stringify(source.keywords) : null, + isActive: source.isActive + }); + } + + // 4. Import Activity Logs + for (const log of botData.activityLogs) { + await db.insert(botActivityLogs).values({ + botId: newBot.id, + action: log.action, + details: JSON.stringify(log.details), + success: log.success, + errorMessage: log.errorMessage, + createdAt: new Date(log.createdAt) + }); + } + + } catch (error) { + console.error(`Failed to import bot ${botData.name}:`, error); + } + } + // Notify old node about the migration try { await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey); @@ -260,6 +405,8 @@ export async function POST(req: NextRequest) { stats: { postsImported: importedPosts, followingToRestore: following.length, + dmsImported: importDMs.length, + botsImported: importBots.length, }, message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.', }); diff --git a/src/app/api/admin/me/route.ts b/src/app/api/admin/me/route.ts index 803618f..fffa3a4 100644 --- a/src/app/api/admin/me/route.ts +++ b/src/app/api/admin/me/route.ts @@ -10,6 +10,7 @@ export async function GET() { } const session = await getSession(); + if (!session) { return NextResponse.json({ isAdmin: false, user: null }); } diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index 71a1894..3d62c13 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -134,7 +134,6 @@ export async function POST(request: NextRequest) { senderNodeDomain: senderNodeDomain, senderDid: did, content: content, - encryptedContent: '', deliveredAt: new Date(), }); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 9dc43a6..2aa29c1 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -119,8 +119,6 @@ export async function POST(request: NextRequest) { senderNodeDomain: null, senderDid: user.did, content: content, - // Encrypted fields are null for plain text chat - encryptedContent: '', deliveredAt: new Date(), }); @@ -133,7 +131,6 @@ export async function POST(request: NextRequest) { senderNodeDomain: null, senderDid: user.did, content: content, - encryptedContent: '', deliveredAt: new Date(), readAt: new Date() // Sender has read their own message }); @@ -217,7 +214,6 @@ export async function POST(request: NextRequest) { senderNodeDomain: null, senderDid: user.did, content: content, - encryptedContent: '', deliveredAt: new Date(), readAt: new Date() }); diff --git a/src/app/api/debug/all-chat-keys/route.ts b/src/app/api/debug/all-chat-keys/route.ts deleted file mode 100644 index 6ef8dac..0000000 --- a/src/app/api/debug/all-chat-keys/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db, users } from '@/db'; -import { getSession } from '@/lib/auth'; - -export async function GET() { - try { - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const allUsers = await db.select({ - id: users.id, - handle: users.handle, - chatPublicKey: users.chatPublicKey, - chatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted, - }).from(users); - - const results = allUsers.map(user => { - let publicKeyInfo = null; - if (user.chatPublicKey) { - try { - const decoded = Buffer.from(user.chatPublicKey, 'base64'); - publicKeyInfo = { - stringLength: user.chatPublicKey.length, - decodedBytes: decoded.byteLength, - isValidSize: decoded.byteLength === 91, - isCorrupted: decoded.byteLength !== 91, - }; - } catch (e) { - publicKeyInfo = { - error: 'Not valid base64', - stringLength: user.chatPublicKey.length, - isCorrupted: true, - }; - } - } - - return { - handle: user.handle, - hasPublicKey: !!user.chatPublicKey, - publicKeyInfo, - encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0, - }; - }); - - const corrupted = results.filter(r => r.publicKeyInfo?.isCorrupted); - - return NextResponse.json({ - total: results.length, - withKeys: results.filter(r => r.hasPublicKey).length, - corrupted: corrupted.length, - corruptedUsers: corrupted.map(r => r.handle), - allUsers: results, - }); - } catch (error) { - console.error('Debug error:', error); - return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 }); - } -} diff --git a/src/app/api/debug/chat-keys/route.ts b/src/app/api/debug/chat-keys/route.ts deleted file mode 100644 index b18f353..0000000 --- a/src/app/api/debug/chat-keys/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db, users } from '@/db'; -import { getSession } from '@/lib/auth'; - -export async function GET() { - try { - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const user = await db.query.users.findFirst({ - where: (users, { eq }) => eq(users.id, session.user.id), - }); - - if (!user) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); - } - - let publicKeyInfo = null; - if (user.chatPublicKey) { - try { - const decoded = Buffer.from(user.chatPublicKey, 'base64'); - publicKeyInfo = { - stringLength: user.chatPublicKey.length, - decodedBytes: decoded.byteLength, - isValidSize: decoded.byteLength === 91, - firstChars: user.chatPublicKey.substring(0, 20), - }; - } catch (e) { - publicKeyInfo = { - error: 'Not valid base64', - stringLength: user.chatPublicKey.length, - firstChars: user.chatPublicKey.substring(0, 20), - }; - } - } - - return NextResponse.json({ - handle: user.handle, - hasPublicKey: !!user.chatPublicKey, - hasEncryptedPrivateKey: !!user.chatPrivateKeyEncrypted, - publicKeyInfo, - encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0, - }); - } catch (error) { - console.error('Debug error:', error); - return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 }); - } -} diff --git a/src/app/api/debug/chat-messages/route.ts b/src/app/api/debug/chat-messages/route.ts deleted file mode 100644 index 0535b00..0000000 --- a/src/app/api/debug/chat-messages/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db, chatMessages } from '@/db'; -import { getSession } from '@/lib/auth'; - -export async function GET() { - try { - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const messages = await db.select({ - id: chatMessages.id, - senderHandle: chatMessages.senderHandle, - senderChatPublicKey: chatMessages.senderChatPublicKey, - createdAt: chatMessages.createdAt, - }).from(chatMessages).limit(50); - - const results = messages.map(msg => { - let keyInfo = null; - if (msg.senderChatPublicKey) { - try { - const decoded = Buffer.from(msg.senderChatPublicKey, 'base64'); - keyInfo = { - stringLength: msg.senderChatPublicKey.length, - decodedBytes: decoded.byteLength, - isValidSize: decoded.byteLength === 91, - isCorrupted: decoded.byteLength !== 91, - firstChars: msg.senderChatPublicKey.substring(0, 20), - }; - } catch (e) { - keyInfo = { - error: 'Not valid base64', - stringLength: msg.senderChatPublicKey.length, - isCorrupted: true, - firstChars: msg.senderChatPublicKey.substring(0, 20), - }; - } - } - - return { - id: msg.id, - senderHandle: msg.senderHandle, - createdAt: msg.createdAt, - hasSenderKey: !!msg.senderChatPublicKey, - keyInfo, - }; - }); - - const corrupted = results.filter(r => r.keyInfo?.isCorrupted); - - return NextResponse.json({ - total: results.length, - withKeys: results.filter(r => r.hasSenderKey).length, - corrupted: corrupted.length, - corruptedMessages: corrupted, - allMessages: results, - }); - } catch (error) { - console.error('Debug error:', error); - return NextResponse.json({ error: 'Failed to debug messages' }, { status: 500 }); - } -} diff --git a/src/app/settings/migration/page.tsx b/src/app/settings/migration/page.tsx index 0cbd963..2b7966c 100644 --- a/src/app/settings/migration/page.tsx +++ b/src/app/settings/migration/page.tsx @@ -10,6 +10,8 @@ interface ExportStats { posts: number; following: number; mediaFiles: number; + dms: number; + bots: number; } export default function MigrationPage() { @@ -137,6 +139,8 @@ export default function MigrationPage() {