Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs
Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0 Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908 Hop-Task: T_06FPAWA3279RBMJAR0BHM10 Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
@@ -33,7 +33,7 @@ const announcementSchema = z.object({
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
isNsfw: z.boolean().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db, chatConversations } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for conversation ID parameter
|
||||
@@ -62,67 +61,31 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
if (deleteFor === 'both') {
|
||||
const participant2Handle = conversation.participant2Handle;
|
||||
if (participant2Handle.includes('@')) {
|
||||
return NextResponse.json({
|
||||
error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.',
|
||||
code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED',
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// Delete the entire conversation and all messages (cascade will handle messages)
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||
|
||||
// Send deletion request to the other party
|
||||
const participant2Handle = conversation.participant2Handle;
|
||||
const isRemote = participant2Handle.includes('@');
|
||||
// Local user - find and delete their conversation too.
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: { handle: participant2Handle },
|
||||
});
|
||||
|
||||
if (isRemote) {
|
||||
// Extract domain from handle (format: handle@domain)
|
||||
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
|
||||
const handle = participant2Handle.split('@')[0];
|
||||
|
||||
try {
|
||||
if (await isNodeBlocked(domain)) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// SECURITY: Sign the deletion request
|
||||
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
|
||||
const privateKey = await getNodePrivateKey();
|
||||
|
||||
const payload = {
|
||||
senderHandle: session.user.handle,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: handle,
|
||||
conversationId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const signature = signPayload(payload, privateKey);
|
||||
|
||||
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, signature }),
|
||||
});
|
||||
|
||||
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
|
||||
} catch (error) {
|
||||
console.error('[Chat Delete] Failed to notify remote node:', error);
|
||||
// Continue anyway - local deletion succeeded
|
||||
}
|
||||
} else {
|
||||
// Local user - find and delete their conversation too
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: { handle: participant2Handle },
|
||||
if (recipientUser) {
|
||||
// Find their conversation with us
|
||||
const recipientConversation = await db.query.chatConversations.findFirst({
|
||||
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
|
||||
});
|
||||
|
||||
if (recipientUser) {
|
||||
// Find their conversation with us
|
||||
const recipientConversation = await db.query.chatConversations.findFirst({
|
||||
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
|
||||
});
|
||||
|
||||
if (recipientConversation) {
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
||||
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
||||
}
|
||||
if (recipientConversation) {
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
||||
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* GET: List all conversations for the current user
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, isNull, sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, chatMessages } from '@/db';
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ conversations: [] });
|
||||
@@ -87,12 +88,13 @@ export async function GET(request: NextRequest) {
|
||||
avatarUrl: profileData.profile.avatarUrl || null,
|
||||
did: profileData.profile.did || '',
|
||||
isBot: profileData.profile.isBot || false,
|
||||
publicKey: profileData.profile.publicKey,
|
||||
});
|
||||
|
||||
// Re-query to get the new cached user
|
||||
cachedUser = await db.query.users.findFirst({
|
||||
where: { handle: participant2Handle },
|
||||
}) as any;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
|
||||
@@ -102,18 +104,66 @@ export async function GET(request: NextRequest) {
|
||||
if (cachedUser) {
|
||||
participant2Info = {
|
||||
handle: cachedUser.handle,
|
||||
displayName: (cachedUser as any).displayName || cachedUser.handle,
|
||||
avatarUrl: (cachedUser as any).avatarUrl || null,
|
||||
did: (cachedUser as any).did || '',
|
||||
displayName: cachedUser.displayName || cachedUser.handle,
|
||||
avatarUrl: cachedUser.avatarUrl || null,
|
||||
did: cachedUser.did || '',
|
||||
};
|
||||
}
|
||||
|
||||
const latest = conv.messages[0] || null;
|
||||
let lastMessage: {
|
||||
protocolVersion: number;
|
||||
content: string | null;
|
||||
encryptedEnvelope: unknown;
|
||||
signedAction: unknown;
|
||||
senderPublicKey: string | null;
|
||||
} | null = latest ? {
|
||||
protocolVersion: 0,
|
||||
content: latest.content,
|
||||
encryptedEnvelope: null,
|
||||
signedAction: null,
|
||||
senderPublicKey: null as string | null,
|
||||
} : null;
|
||||
|
||||
if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope
|
||||
&& latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) {
|
||||
try {
|
||||
const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope));
|
||||
const senderPublicKey = latest.senderDid === session.user.did
|
||||
? session.user.publicKey
|
||||
: cachedUser?.did === latest.senderDid
|
||||
? cachedUser.publicKey
|
||||
: null;
|
||||
lastMessage = {
|
||||
protocolVersion: E2EE_PROTOCOL_VERSION,
|
||||
content: null,
|
||||
encryptedEnvelope,
|
||||
signedAction: {
|
||||
action: E2EE_CHAT_ACTION,
|
||||
data: encryptedEnvelope,
|
||||
did: latest.senderDid,
|
||||
handle: encryptedEnvelope.senderHandle,
|
||||
ts: latest.e2eeActionTs,
|
||||
nonce: latest.e2eeActionNonce,
|
||||
sig: latest.e2eeSignature,
|
||||
},
|
||||
senderPublicKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error);
|
||||
lastMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
const { messages: _rawMessages, ...conversation } = conv;
|
||||
void _rawMessages;
|
||||
return {
|
||||
...conv,
|
||||
...conversation,
|
||||
participant2: {
|
||||
...participant2Info,
|
||||
isBot: (cachedUser as any)?.isBot || false,
|
||||
isBot: cachedUser?.isBot || false,
|
||||
},
|
||||
lastMessage,
|
||||
unreadCount: Number(unreadCount[0]?.count || 0),
|
||||
};
|
||||
})
|
||||
@@ -121,7 +171,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
|
||||
});
|
||||
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
console.error('List conversations error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import { db, chatMessages, users } from '@/db';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
|
||||
|
||||
// Schema for query parameters
|
||||
const messagesQuerySchema = z.object({
|
||||
@@ -23,6 +24,8 @@ const markReadSchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
});
|
||||
|
||||
type ChatUser = typeof users.$inferSelect;
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -83,8 +86,8 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Fetch users
|
||||
const usersByDid: Record<string, any> = {};
|
||||
const usersByHandle: Record<string, any> = {};
|
||||
const usersByDid: Record<string, ChatUser> = {};
|
||||
const usersByHandle: Record<string, ChatUser> = {};
|
||||
|
||||
if (senderDids.size > 0) {
|
||||
const found = await db.query.users.findMany({
|
||||
@@ -102,7 +105,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const messagesMapped = messages.map((msg) => {
|
||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
||||
const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle;
|
||||
|
||||
// Resolve fresh user data
|
||||
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
|
||||
@@ -110,13 +113,39 @@ export async function GET(request: NextRequest) {
|
||||
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
|
||||
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
|
||||
|
||||
let encryptedEnvelope = null;
|
||||
let signedAction = null;
|
||||
if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) {
|
||||
try {
|
||||
encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope));
|
||||
if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) {
|
||||
throw new Error('Encrypted message signature metadata is incomplete');
|
||||
}
|
||||
signedAction = {
|
||||
action: E2EE_CHAT_ACTION,
|
||||
data: encryptedEnvelope,
|
||||
did: msg.senderDid,
|
||||
handle: encryptedEnvelope.senderHandle,
|
||||
ts: msg.e2eeActionTs,
|
||||
nonce: msg.e2eeActionNonce,
|
||||
sig: msg.e2eeSignature,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
senderHandle: msg.senderHandle,
|
||||
senderDisplayName: displayName,
|
||||
senderAvatarUrl: avatarUrl,
|
||||
senderDid: msg.senderDid,
|
||||
content: msg.content,
|
||||
content: msg.protocolVersion === 0 ? msg.content : null,
|
||||
protocolVersion: msg.protocolVersion,
|
||||
encryptedEnvelope,
|
||||
signedAction,
|
||||
senderPublicKey: user?.publicKey || null,
|
||||
deliveredAt: msg.deliveredAt,
|
||||
readAt: msg.readAt,
|
||||
createdAt: msg.createdAt,
|
||||
@@ -127,7 +156,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
messages: messagesMapped.reverse(), // Oldest first for display
|
||||
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
||||
});
|
||||
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
|
||||
@@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
isNsfw: z.boolean().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
|
||||
lastSeenAt: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user