feat: Remove chat key management and debug endpoints, simplify chat message handling, and enhance migration page with DM and bot export capabilities.

This commit is contained in:
Christomatt
2026-01-29 00:04:22 +01:00
parent 9cd23081ed
commit c47b0141f5
11 changed files with 293 additions and 249 deletions
@@ -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',
},
});
}
+137 -3
View File
@@ -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),
},
});
+150 -3
View File
@@ -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.',
});
+1
View File
@@ -10,6 +10,7 @@ export async function GET() {
}
const session = await getSession();
if (!session) {
return NextResponse.json({ isAdmin: false, user: null });
}
-1
View File
@@ -134,7 +134,6 @@ export async function POST(request: NextRequest) {
senderNodeDomain: senderNodeDomain,
senderDid: did,
content: content,
encryptedContent: '',
deliveredAt: new Date(),
});
-4
View File
@@ -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()
});
-60
View File
@@ -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 });
}
}
-50
View File
@@ -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 });
}
}
-63
View File
@@ -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 });
}
}
+5 -1
View File
@@ -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() {
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
<li>All DMs and conversation history</li>
<li>Your automated bots and their configuration</li>
</ul>
</div>
@@ -167,7 +171,7 @@ export default function MigrationPage() {
borderRadius: '8px',
marginBottom: '20px',
}}>
Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references.
Export successful! Downloaded {exportStats.posts} posts, {exportStats.dms} DM threads, and {exportStats.bots} bots.
</div>
)}