Refactor chat system to remove E2EE and simplify messaging
Removed all E2EE chat endpoints, crypto logic, and related API routes, transitioning chat to plain text storage and transport. Updated chat send/receive endpoints to use signed actions and enforce DM privacy settings. Cleaned up Swarm chat inbox, key management, and related debug endpoints. Updated user profile to support DM privacy, improved search for handle queries, and refactored conversation/message logic to support the new model. Migrated bot settings and privacy settings to new locations.
This commit is contained in:
@@ -10,6 +10,7 @@ const updateProfileSchema = z.object({
|
||||
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
website: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
dmPrivacy: z.enum(['everyone', 'following', 'none']).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
@@ -33,6 +34,7 @@ export async function GET() {
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
website: session.user.website,
|
||||
dmPrivacy: session.user.dmPrivacy,
|
||||
did: session.user.did,
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
@@ -60,6 +62,7 @@ export async function PATCH(request: Request) {
|
||||
avatarUrl?: string | null;
|
||||
headerUrl?: string | null;
|
||||
website?: string | null;
|
||||
dmPrivacy?: 'everyone' | 'following' | 'none';
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
@@ -68,6 +71,7 @@ export async function PATCH(request: Request) {
|
||||
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
|
||||
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
|
||||
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
|
||||
if (data.dmPrivacy !== undefined) updateData.dmPrivacy = data.dmPrivacy;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -79,6 +83,7 @@ export async function PATCH(request: Request) {
|
||||
bio: currentUser.bio,
|
||||
headerUrl: currentUser.headerUrl,
|
||||
website: currentUser.website,
|
||||
dmPrivacy: currentUser.dmPrivacy,
|
||||
followersCount: currentUser.followersCount,
|
||||
followingCount: currentUser.followingCount,
|
||||
postsCount: currentUser.postsCount,
|
||||
@@ -103,6 +108,7 @@ export async function PATCH(request: Request) {
|
||||
bio: updatedUser.bio,
|
||||
headerUrl: updatedUser.headerUrl,
|
||||
website: updatedUser.website,
|
||||
dmPrivacy: updatedUser.dmPrivacy,
|
||||
followersCount: updatedUser.followersCount,
|
||||
followingCount: updatedUser.followingCount,
|
||||
postsCount: updatedUser.postsCount,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatInbox } from '@/db/schema';
|
||||
import { eq, and, or, isNull } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/chat/inbox
|
||||
* Poll for new encrypted envelopes for this device.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.did) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { did } = session.user;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const deviceId = searchParams.get('deviceId');
|
||||
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({ error: 'Device ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch messages for this user AND (this device OR all devices)
|
||||
const messages = await db.select().from(chatInbox).where(
|
||||
and(
|
||||
eq(chatInbox.recipientDid, did),
|
||||
or(
|
||||
eq(chatInbox.recipientDeviceId, deviceId),
|
||||
isNull(chatInbox.recipientDeviceId) // Broadcasts (if any)
|
||||
),
|
||||
eq(chatInbox.isRead, false)
|
||||
)
|
||||
);
|
||||
|
||||
// TODO: Mark them as read immediately?
|
||||
// Or client must ACK?
|
||||
// For now, we return them. Client deals with idempotency.
|
||||
// If we mark read, checking on another tab might miss them if race condition.
|
||||
// But uniqueness is (id).
|
||||
|
||||
return NextResponse.json({ messages });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Inbox poll fail:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/chat/inbox
|
||||
* Acknowledge/Delete processed messages
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
// ... Implement ACK cleaning
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const did = searchParams.get('did');
|
||||
const nodeDomain = searchParams.get('nodeDomain');
|
||||
|
||||
if (!did) {
|
||||
return NextResponse.json({ error: 'Missing DID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const handle = searchParams.get('handle');
|
||||
|
||||
// Helper to fetch and check with timeout
|
||||
const tryFetch = async (url: string) => {
|
||||
console.log(`[Proxy] Fetching keys from: ${url}`);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(id);
|
||||
|
||||
if (res.ok) return await res.json();
|
||||
const text = await res.text();
|
||||
console.warn(`[Proxy] Fetch failed for ${url} (${res.status}): ${text}`);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.warn(`[Proxy] Fetch error for ${url}: ${err.name} - ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Try Primary DID
|
||||
let primaryUrl = '';
|
||||
// If did:web, extracting domain is easy
|
||||
if (did.startsWith('did:web:')) {
|
||||
const parts = did.split(':');
|
||||
if (parts.length >= 4) {
|
||||
const domain = parts[2];
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
primaryUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`;
|
||||
}
|
||||
} else if (nodeDomain) {
|
||||
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
primaryUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`;
|
||||
}
|
||||
|
||||
if (primaryUrl) {
|
||||
const data = await tryFetch(primaryUrl);
|
||||
if (data) return NextResponse.json(data);
|
||||
}
|
||||
|
||||
// 2. Try Fallback: did:web (if handle and domain provided)
|
||||
// The remote user might be indexed by did:web even if we know them as did:synapsis
|
||||
if (nodeDomain && handle) {
|
||||
const didWeb = `did:web:${nodeDomain}:${handle}`;
|
||||
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
const fallbackUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${didWeb}`;
|
||||
|
||||
console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`);
|
||||
const data = await tryFetch(fallbackUrl);
|
||||
if (data) return NextResponse.json(data);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 });
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles, handleRegistry, remoteIdentityCache, users } from '@/db/schema';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* GET /api/chat/keys?did=<did>
|
||||
* Fetch public key for a user
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const did = searchParams.get('did');
|
||||
|
||||
if (!did) {
|
||||
return NextResponse.json({ error: 'Missing did parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('[Chat Keys GET] Looking for DID:', did);
|
||||
|
||||
const bundle = await db.query.chatDeviceBundles.findFirst({
|
||||
where: eq(chatDeviceBundles.did, did),
|
||||
orderBy: (bundles, { desc }) => [desc(bundles.lastSeenAt)],
|
||||
});
|
||||
|
||||
console.log('[Chat Keys GET] Found local bundle:', bundle ? 'YES' : 'NO');
|
||||
if (bundle) {
|
||||
console.log('[Chat Keys GET] Bundle data:', {
|
||||
userId: bundle.userId,
|
||||
did: bundle.did,
|
||||
deviceId: bundle.deviceId,
|
||||
hasIdentityKey: !!bundle.identityKey,
|
||||
});
|
||||
|
||||
// For libsodium, we just need the public key
|
||||
return NextResponse.json({
|
||||
publicKey: bundle.identityKey,
|
||||
});
|
||||
}
|
||||
|
||||
// If not found locally, check remote identity cache
|
||||
const cached = await db.query.remoteIdentityCache.findFirst({
|
||||
where: and(
|
||||
eq(remoteIdentityCache.did, did),
|
||||
gt(remoteIdentityCache.expiresAt, new Date())
|
||||
),
|
||||
});
|
||||
|
||||
// DEBUG: Force refresh for now to fix development key mismatches
|
||||
// if (cached) {
|
||||
// console.log('[Chat Keys GET] Found cached remote key');
|
||||
// return NextResponse.json({
|
||||
// publicKey: cached.publicKey,
|
||||
// });
|
||||
// }
|
||||
|
||||
// If not in cache, try to resolve DID to a node
|
||||
console.log('[Chat Keys GET] resolving DID to node...');
|
||||
const handleEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.did, did),
|
||||
});
|
||||
|
||||
if (handleEntry) {
|
||||
const { nodeDomain } = handleEntry;
|
||||
console.log('[Chat Keys GET] Resolved to node:', nodeDomain);
|
||||
|
||||
// Fetch from remote node
|
||||
const remoteUrl = `https://${nodeDomain}/api/chat/keys?did=${encodeURIComponent(did)}`;
|
||||
console.log('[Chat Keys GET] Fetching from remote:', remoteUrl);
|
||||
|
||||
try {
|
||||
const remoteRes = await fetch(remoteUrl);
|
||||
if (remoteRes.ok) {
|
||||
const data = await remoteRes.json();
|
||||
if (data.publicKey) {
|
||||
console.log('[Chat Keys GET] Successfully fetched remote key');
|
||||
|
||||
// Cache it
|
||||
await db.insert(remoteIdentityCache).values({
|
||||
did: did,
|
||||
publicKey: data.publicKey,
|
||||
fetchedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
}).onConflictDoUpdate({
|
||||
target: remoteIdentityCache.did,
|
||||
set: {
|
||||
publicKey: data.publicKey,
|
||||
fetchedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
publicKey: data.publicKey,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[Chat Keys GET] Remote fetch failed:', remoteRes.status, remoteRes.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Chat Keys GET] Remote fetch error:', err);
|
||||
}
|
||||
|
||||
// FALLBACK: Try fetching from Swarm User Profile (standard endpoint)
|
||||
if (!cached) {
|
||||
console.log('[Chat Keys GET] Trying fallback to Swarm Profile...');
|
||||
const swarmUrl = `https://${nodeDomain}/api/swarm/users/${handleEntry.handle}`;
|
||||
console.log('[Chat Keys GET] Fetching Swarm Profile:', swarmUrl);
|
||||
|
||||
try {
|
||||
const swarmRes = await fetch(swarmUrl);
|
||||
if (swarmRes.ok) {
|
||||
const data = await swarmRes.json();
|
||||
const chatKey = data.profile?.chatPublicKey;
|
||||
|
||||
if (chatKey) {
|
||||
console.log('[Chat Keys GET] Found key in Swarm Profile');
|
||||
|
||||
// Cache it
|
||||
await db.insert(remoteIdentityCache).values({
|
||||
did: did,
|
||||
publicKey: chatKey,
|
||||
fetchedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
}).onConflictDoUpdate({
|
||||
target: remoteIdentityCache.did,
|
||||
set: {
|
||||
publicKey: chatKey,
|
||||
fetchedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
publicKey: chatKey,
|
||||
});
|
||||
} else {
|
||||
console.warn('[Chat Keys GET] Swarm Profile found but no chatPublicKey');
|
||||
}
|
||||
} else {
|
||||
console.warn('[Chat Keys GET] Swarm Profile fetch failed:', swarmRes.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Chat Keys GET] Swarm Profile fetch error:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Chat Keys GET] DID not found in handle registry');
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
|
||||
} catch (error: any) {
|
||||
console.error('[Chat Keys] Failed to fetch keys:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/keys
|
||||
* Publish public key
|
||||
*
|
||||
* Body: {
|
||||
* publicKey: string (base64)
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
console.log('[Chat Keys POST] User:', user.handle, 'DID:', user.did, 'UserID:', user.id);
|
||||
|
||||
const body = await request.json();
|
||||
const { publicKey } = body;
|
||||
|
||||
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
|
||||
|
||||
if (!publicKey) {
|
||||
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if bundle exists
|
||||
const existing = await db.query.chatDeviceBundles.findFirst({
|
||||
where: and(
|
||||
eq(chatDeviceBundles.userId, user.id),
|
||||
eq(chatDeviceBundles.deviceId, '1')
|
||||
)
|
||||
});
|
||||
|
||||
console.log('[Chat Keys POST] Existing bundle found:', existing ? 'YES' : 'NO');
|
||||
|
||||
if (existing) {
|
||||
// Update existing bundle
|
||||
console.log('[Chat Keys POST] Updating existing bundle...');
|
||||
await db.update(chatDeviceBundles)
|
||||
.set({
|
||||
identityKey: publicKey,
|
||||
did: user.did, // Update DID too in case it changed
|
||||
lastSeenAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeviceBundles.userId, user.id),
|
||||
eq(chatDeviceBundles.deviceId, '1')
|
||||
)
|
||||
);
|
||||
console.log('[Chat Keys POST] Updated existing key');
|
||||
} else {
|
||||
console.log('[Chat Keys POST] Inserted new key');
|
||||
await db.insert(chatDeviceBundles).values({
|
||||
userId: user.id,
|
||||
did: user.did,
|
||||
deviceId: '1',
|
||||
identityKey: publicKey,
|
||||
signedPreKey: 'libsodium', // Placeholder
|
||||
registrationId: 1,
|
||||
signature: 'libsodium',
|
||||
});
|
||||
console.log('[Chat Keys POST] Inserted new key');
|
||||
}
|
||||
|
||||
// Sync to users table too for profile discovery
|
||||
await db.update(users)
|
||||
.set({ chatPublicKey: publicKey })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
console.log('[Chat Keys POST] Key published successfully for', user.handle);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('[Chat Keys] Failed to publish key:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,108 +3,155 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
|
||||
/**
|
||||
* POST /api/chat/receive
|
||||
* Endpoint for receiving federated chat messages from other nodes.
|
||||
*
|
||||
* Body: {
|
||||
* senderDid: string,
|
||||
* senderHandle: string, // user@domain
|
||||
* senderDisplayName: string,
|
||||
* senderAvatarUrl: string,
|
||||
* senderNodeDomain: string,
|
||||
* recipientDid: string,
|
||||
* encryptedContent: string (base64 JSON of {senderPublicKey, ciphertext, nonce, recipientDid}),
|
||||
* sentAt: string (ISO date)
|
||||
* }
|
||||
* Expects a SignedAction payload from the sender.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
senderDid,
|
||||
senderHandle,
|
||||
senderDisplayName,
|
||||
senderAvatarUrl,
|
||||
senderNodeDomain,
|
||||
recipientDid,
|
||||
encryptedContent
|
||||
} = body;
|
||||
const signedAction: SignedAction = await request.json();
|
||||
const { did, handle, data } = signedAction;
|
||||
const { recipientDid, content } = data || {};
|
||||
|
||||
// Basic validation
|
||||
if (!senderDid || !senderHandle || !recipientDid || !encryptedContent || !senderNodeDomain) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
if (!did || !handle || !recipientDid || !content) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[Chat Receive] From: ${senderHandle} (${senderNodeDomain}), To: ${recipientDid} [${encryptedContent.substring(0, 20)}...]`);
|
||||
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
|
||||
|
||||
// Ensure remote handle is fully qualified
|
||||
let finalSenderHandle = senderHandle;
|
||||
if (!finalSenderHandle.includes('@') && senderNodeDomain) {
|
||||
finalSenderHandle = `${senderHandle}@${senderNodeDomain}`;
|
||||
// 1. Resolve Sender Public Key
|
||||
let senderUser = await db.query.users.findFirst({
|
||||
where: eq(users.did, did)
|
||||
});
|
||||
|
||||
let publicKey = senderUser?.publicKey;
|
||||
let senderDisplayName = senderUser?.displayName || handle;
|
||||
let senderAvatarUrl = senderUser?.avatarUrl;
|
||||
let senderNodeDomain: string | null = null;
|
||||
|
||||
if (!senderUser) {
|
||||
// Unknown user - likely remote. We need to fetch their profile to get the public key.
|
||||
// Derive domain from handle if possible
|
||||
if (handle.includes('@')) {
|
||||
const parts = handle.split('@');
|
||||
senderNodeDomain = parts[parts.length - 1];
|
||||
} else {
|
||||
// Try handle registry (though we likely don't have it if we don't have the user)
|
||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.did, did)
|
||||
});
|
||||
if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
|
||||
}
|
||||
|
||||
if (senderNodeDomain) {
|
||||
try {
|
||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
// Fetch profile from remote node
|
||||
// Assuming /api/users/:handle convention
|
||||
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
if (remoteProfile && remoteProfile.publicKey) {
|
||||
if (remoteProfile.did !== did) {
|
||||
console.error('DID mismatch for remote user');
|
||||
} else {
|
||||
publicKey = remoteProfile.publicKey;
|
||||
senderDisplayName = remoteProfile.displayName || handle;
|
||||
senderAvatarUrl = remoteProfile.avatarUrl;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch remote profile:', res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Remote profile fetch failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Find local recipient
|
||||
if (!publicKey) {
|
||||
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Verify Signature
|
||||
const isValid = await verifyActionSignature(signedAction, publicKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 3. Find Local Recipient
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: eq(users.did, recipientDid)
|
||||
});
|
||||
|
||||
if (!recipientUser) {
|
||||
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Find or Create Conversation
|
||||
// For the RECIPIENT, the conversation is with the SENDER (Remote)
|
||||
// 4. Find or Create Conversation
|
||||
// For the RECIPIENT, the conversation is with the SENDER
|
||||
// Use full handle
|
||||
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
|
||||
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
eq(chatConversations.participant2Handle, finalSenderHandle)
|
||||
eq(chatConversations.participant2Handle, fullSenderHandle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: finalSenderHandle,
|
||||
participant2Handle: fullSenderHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted Message]'
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
conversation = newConv;
|
||||
} else {
|
||||
// Update preview
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(chatConversations.id, conversation.id));
|
||||
}
|
||||
|
||||
// 3. Store Message
|
||||
// 5. Store Message
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: finalSenderHandle,
|
||||
senderDisplayName: senderDisplayName || senderHandle,
|
||||
senderHandle: fullSenderHandle,
|
||||
senderDisplayName: senderDisplayName,
|
||||
senderAvatarUrl: senderAvatarUrl,
|
||||
senderNodeDomain: senderNodeDomain,
|
||||
senderDid: senderDid,
|
||||
encryptedContent: encryptedContent,
|
||||
senderDid: did,
|
||||
content: content,
|
||||
encryptedContent: '',
|
||||
deliveredAt: new Date(),
|
||||
});
|
||||
|
||||
// 4. Update conversation timestamp
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted Message]'
|
||||
})
|
||||
.where(eq(chatConversations.id, conversation.id));
|
||||
|
||||
// 5. Upsert sender into HandleRegistry ensures we can reply/fetch keys later
|
||||
await db.insert(handleRegistry).values({
|
||||
handle: finalSenderHandle, // user@domain
|
||||
did: senderDid,
|
||||
nodeDomain: senderNodeDomain
|
||||
}).onConflictDoUpdate({
|
||||
target: handleRegistry.handle,
|
||||
set: {
|
||||
did: senderDid,
|
||||
// 6. Update Registry (to ensure we can reply efficiently)
|
||||
if (senderNodeDomain) {
|
||||
await db.insert(handleRegistry).values({
|
||||
handle: fullSenderHandle, // user@domain
|
||||
did: did,
|
||||
nodeDomain: senderNodeDomain
|
||||
}
|
||||
});
|
||||
}).onConflictDoUpdate({
|
||||
target: handleRegistry.handle,
|
||||
set: {
|
||||
did: did,
|
||||
nodeDomain: senderNodeDomain
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
|
||||
+103
-72
@@ -1,31 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const chatSendSchema = z.object({
|
||||
recipientDid: z.string(),
|
||||
recipientHandle: z.string(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/send
|
||||
* Store encrypted message (server never decrypts)
|
||||
*
|
||||
* Body: {
|
||||
* recipientDid: string,
|
||||
* senderPublicKey: string (base64),
|
||||
* ciphertext: string (base64),
|
||||
* nonce: string (base64),
|
||||
* recipientHandle?: string
|
||||
* }
|
||||
* Send a signed chat message (verified with DID)
|
||||
* Stores plain text content.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
// Parse the signed action from the request body
|
||||
const signedAction = await request.json();
|
||||
|
||||
const body = await request.json();
|
||||
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body;
|
||||
// Strictly verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
// Extract message data
|
||||
const data = chatSendSchema.parse(signedAction.data);
|
||||
const { recipientDid, recipientHandle, content } = data;
|
||||
|
||||
// Check if recipient is local
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
@@ -33,9 +34,30 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (recipientUser) {
|
||||
// Reject if recipient is a bot
|
||||
if (recipientUser.isBot) {
|
||||
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check DM privacy settings
|
||||
if (recipientUser.dmPrivacy === 'none') {
|
||||
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
|
||||
} else if (recipientUser.dmPrivacy === 'following') {
|
||||
// Check if recipient follows the sender
|
||||
const isFollowingSender = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, recipientUser.id),
|
||||
eq(follows.followingId, user.id)
|
||||
)
|
||||
});
|
||||
if (!isFollowingSender) {
|
||||
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
// LOCAL RECIPIENT
|
||||
|
||||
// Ensure conversations exist
|
||||
// Ensure conversations exist for both sides
|
||||
// 1. Recipient's Inbox (Recipient -> User)
|
||||
let recipientConv = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
@@ -48,11 +70,21 @@ export async function POST(request: NextRequest) {
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: user.handle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted]'
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
recipientConv = newConv;
|
||||
} else {
|
||||
// Update preview
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(chatConversations.id, recipientConv.id));
|
||||
}
|
||||
|
||||
// 2. Sender's Sent Box (User -> Recipient)
|
||||
let senderConv = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, user.id),
|
||||
@@ -65,21 +97,20 @@ export async function POST(request: NextRequest) {
|
||||
participant1Id: user.id,
|
||||
participant2Handle: recipientUser.handle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted]'
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
senderConv = newConv;
|
||||
} else {
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(chatConversations.id, senderConv.id));
|
||||
}
|
||||
|
||||
// Store encrypted message (libsodium format)
|
||||
// Include recipientDid so sender can decrypt their own sent messages
|
||||
const messageData = {
|
||||
senderPublicKey,
|
||||
recipientDid, // Add this so sender knows who to decrypt with
|
||||
ciphertext,
|
||||
nonce,
|
||||
};
|
||||
|
||||
// Create message for recipient
|
||||
// Create message for recipient (Inbox)
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: recipientConv.id,
|
||||
senderHandle: user.handle,
|
||||
@@ -87,11 +118,13 @@ export async function POST(request: NextRequest) {
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null,
|
||||
senderDid: user.did,
|
||||
encryptedContent: JSON.stringify(messageData),
|
||||
content: content,
|
||||
// Encrypted fields are null for plain text chat
|
||||
encryptedContent: '',
|
||||
deliveredAt: new Date(),
|
||||
});
|
||||
|
||||
// Create message for sender
|
||||
// Create message for sender (Sent)
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: senderConv.id,
|
||||
senderHandle: user.handle,
|
||||
@@ -99,61 +132,44 @@ export async function POST(request: NextRequest) {
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null,
|
||||
senderDid: user.did,
|
||||
encryptedContent: JSON.stringify(messageData),
|
||||
content: content,
|
||||
encryptedContent: '',
|
||||
deliveredAt: new Date(),
|
||||
readAt: new Date() // Sender has read their own message
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
// REMOTE RECIPIENT
|
||||
const { handleRegistry } = await import('@/db/schema'); // dynamic import or add to top
|
||||
|
||||
// 1. Resolve recipient node
|
||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.did, recipientDid)
|
||||
});
|
||||
|
||||
if (!registryEntry) {
|
||||
console.error('Recipient DID not found in registry:', recipientDid);
|
||||
return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 });
|
||||
// If not in registry, try to parse from handle if it has domain
|
||||
let targetDomain: string | null = registryEntry?.nodeDomain || null;
|
||||
let targetHandle = recipientHandle;
|
||||
|
||||
if (!targetDomain && recipientHandle.includes('@')) {
|
||||
const parts = recipientHandle.split('@');
|
||||
targetDomain = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
const targetDomain = registryEntry.nodeDomain;
|
||||
// Ensure handle is fully qualified for remote users
|
||||
let targetHandle = registryEntry.handle;
|
||||
if (!targetHandle.includes('@') && targetDomain) {
|
||||
targetHandle = `${targetHandle}@${targetDomain}`;
|
||||
if (!targetDomain) {
|
||||
console.error('Recipient node domain not found for:', recipientHandle);
|
||||
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
|
||||
|
||||
// 2. Prepare Payload
|
||||
const messageData = {
|
||||
senderPublicKey,
|
||||
recipientDid,
|
||||
ciphertext,
|
||||
nonce,
|
||||
};
|
||||
const encryptedContent = JSON.stringify(messageData);
|
||||
|
||||
const payload = {
|
||||
senderDid: user.did,
|
||||
senderHandle: user.handle,
|
||||
senderDisplayName: user.displayName,
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'dev.syn.quest', // Current node domain
|
||||
recipientDid,
|
||||
encryptedContent,
|
||||
sentAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 3. Send to Remote Node
|
||||
// 2. Send to Remote Node (Forward the Signed Action)
|
||||
try {
|
||||
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
|
||||
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(signedAction) // Forward the user's signed intent
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -166,23 +182,31 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
|
||||
}
|
||||
|
||||
// 4. Store "Sent" copy locally
|
||||
// 3. Store "Sent" copy locally
|
||||
// Ensure conversation exists locally
|
||||
let senderConv = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, user.id),
|
||||
eq(chatConversations.participant2Handle, targetHandle)
|
||||
eq(chatConversations.participant2Handle, recipientHandle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!senderConv) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: user.id,
|
||||
participant2Handle: targetHandle,
|
||||
participant2Handle: recipientHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted]'
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
senderConv = newConv;
|
||||
} else {
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(chatConversations.id, senderConv.id));
|
||||
}
|
||||
|
||||
await db.insert(chatMessages).values({
|
||||
@@ -190,17 +214,24 @@ export async function POST(request: NextRequest) {
|
||||
senderHandle: user.handle,
|
||||
senderDisplayName: user.displayName,
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null, // It's ME, so null
|
||||
senderNodeDomain: null,
|
||||
senderDid: user.did,
|
||||
encryptedContent: encryptedContent,
|
||||
content: content,
|
||||
encryptedContent: '',
|
||||
deliveredAt: new Date(),
|
||||
readAt: new Date()
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Send failed:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
console.error('Send chat failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, chatDeviceBundles } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handle = searchParams.get('handle');
|
||||
|
||||
if (!handle) {
|
||||
return NextResponse.json({ error: 'Missing handle parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check for device bundles
|
||||
const bundles = await db.query.chatDeviceBundles.findMany({
|
||||
where: eq(chatDeviceBundles.userId, user.id),
|
||||
with: {
|
||||
oneTimeKeys: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
did: user.did,
|
||||
hasChatPublicKey: !!user.chatPublicKey,
|
||||
},
|
||||
bundles: bundles.map(b => ({
|
||||
deviceId: b.deviceId,
|
||||
identityKey: b.identityKey?.substring(0, 20) + '...',
|
||||
hasSignedPreKey: !!b.signedPreKey,
|
||||
oneTimeKeysCount: b.oneTimeKeys.length,
|
||||
})),
|
||||
bundleCount: bundles.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Check chat keys error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check keys' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const bundles = await db.select({
|
||||
did: chatDeviceBundles.did,
|
||||
userId: chatDeviceBundles.userId,
|
||||
deviceId: chatDeviceBundles.deviceId,
|
||||
identityKey: chatDeviceBundles.identityKey,
|
||||
createdAt: chatDeviceBundles.createdAt,
|
||||
}).from(chatDeviceBundles).orderBy(chatDeviceBundles.createdAt);
|
||||
|
||||
return NextResponse.json({ bundles, count: bundles.length });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles } from '@/db/schema';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
console.log('[Debug] Clearing all chat device bundles...');
|
||||
|
||||
await db.delete(chatDeviceBundles);
|
||||
|
||||
console.log('[Debug] Cleared all chat device bundles');
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Cleared all chat keys' });
|
||||
} catch (error: any) {
|
||||
console.error('[Debug] Failed to clear keys:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+49
-23
@@ -65,35 +65,61 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const isHandleSearch = query.trim().startsWith('@');
|
||||
const searchPattern = `%${localSearchQuery}%`;
|
||||
let searchUsers: SearchUser[] = [];
|
||||
let searchPosts: typeof posts.$inferSelect[] = [];
|
||||
|
||||
// Search users
|
||||
if (type === 'all' || type === 'users') {
|
||||
const userConditions = and(
|
||||
or(
|
||||
ilike(users.handle, searchPattern),
|
||||
ilike(users.displayName, searchPattern),
|
||||
ilike(users.bio, searchPattern)
|
||||
),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false)
|
||||
);
|
||||
const localUsers = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(userConditions)
|
||||
.limit(limit);
|
||||
|
||||
// Filter out remote placeholder users (those with @ in handle)
|
||||
searchUsers = localUsers.filter(u => !u.handle.includes('@'));
|
||||
if (isHandleSearch) {
|
||||
// Try exact match first
|
||||
const exactMatch = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(and(
|
||||
eq(users.handle, localSearchQuery),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (exactMatch.length > 0) {
|
||||
searchUsers = exactMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchUsers.length === 0) {
|
||||
const userConditions = and(
|
||||
or(
|
||||
ilike(users.handle, searchPattern),
|
||||
ilike(users.displayName, searchPattern),
|
||||
ilike(users.bio, searchPattern)
|
||||
),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false)
|
||||
);
|
||||
const localUsers = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(userConditions)
|
||||
.limit(limit);
|
||||
|
||||
// Filter out remote placeholder users (those with @ in handle)
|
||||
searchUsers = localUsers.filter(u => !u.handle.includes('@'));
|
||||
}
|
||||
}
|
||||
|
||||
// Swarm user lookup (exact handle@domain queries)
|
||||
|
||||
@@ -54,7 +54,6 @@ export async function GET(request: NextRequest) {
|
||||
handle: participant2Handle,
|
||||
displayName: participant2Handle,
|
||||
avatarUrl: null as string | null,
|
||||
chatPublicKey: null as string | null,
|
||||
};
|
||||
|
||||
// Try to get cached user info
|
||||
@@ -67,20 +66,22 @@ export async function GET(request: NextRequest) {
|
||||
handle: cachedUser.handle,
|
||||
displayName: cachedUser.displayName || cachedUser.handle,
|
||||
avatarUrl: cachedUser.avatarUrl,
|
||||
chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...conv,
|
||||
participant2: participant2Info,
|
||||
participant2: {
|
||||
...participant2Info,
|
||||
isBot: cachedUser?.isBot || false,
|
||||
},
|
||||
unreadCount: Number(unreadCount[0]?.count || 0),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
conversations: conversationsWithUnread,
|
||||
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List conversations error:', error);
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Swarm Chat Inbox
|
||||
*
|
||||
* POST: Receives chat messages from other swarm nodes
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, chatConversations, chatMessages } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
|
||||
|
||||
const chatMessageSchema = z.object({
|
||||
messageId: z.string(),
|
||||
senderHandle: z.string(),
|
||||
senderDisplayName: z.string().optional(),
|
||||
senderAvatarUrl: z.string().optional(),
|
||||
senderNodeDomain: z.string(),
|
||||
recipientHandle: z.string(),
|
||||
encryptedContent: z.string(),
|
||||
timestamp: z.string(),
|
||||
signature: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
console.log('[Swarm Inbox] Received body keys:', Object.keys(body), 'action:', body.action);
|
||||
|
||||
// Check if this is a V2 encrypted envelope (has 'action' and 'data' fields)
|
||||
// MUST check BEFORE Zod validation to avoid validation errors
|
||||
if (body.action === 'chat.deliver' && body.data) {
|
||||
// V2 E2EE Message - store in chatInbox
|
||||
const { recipientDid, recipientDeviceId, ciphertext } = body.data;
|
||||
|
||||
if (!recipientDid || !ciphertext) {
|
||||
return NextResponse.json({ error: 'Invalid V2 payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find recipient by DID
|
||||
const recipient = await db.query.users.findFirst({
|
||||
where: eq(users.did, recipientDid)
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Import schemas
|
||||
const { chatInbox, chatConversations, chatMessages } = await import('@/db/schema');
|
||||
|
||||
// Store in V2 inbox
|
||||
await db.insert(chatInbox).values({
|
||||
senderDid: body.did, // From signed envelope
|
||||
recipientDid,
|
||||
recipientDeviceId: recipientDeviceId || null,
|
||||
envelope: JSON.stringify(body),
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||
});
|
||||
|
||||
// Also create conversation and message for recipient UI
|
||||
// Extract sender info from envelope
|
||||
const senderHandle = body.handle || 'unknown';
|
||||
const senderNodeDomain = body.data?.senderNodeDomain || 'unknown';
|
||||
const senderFullHandle = senderNodeDomain !== 'unknown'
|
||||
? `${senderHandle}@${senderNodeDomain}`
|
||||
: senderHandle;
|
||||
|
||||
console.log('[Swarm Chat V2] Sender info:', { senderHandle, senderNodeDomain, senderFullHandle });
|
||||
|
||||
// Get or create conversation for recipient
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipient.id),
|
||||
eq(chatConversations.participant2Handle, senderFullHandle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipient.id,
|
||||
participant2Handle: senderFullHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted message]'
|
||||
}).returning();
|
||||
conversation = newConv;
|
||||
console.log(`[Swarm Chat V2] Created conversation for recipient:`, conversation.id);
|
||||
} else {
|
||||
// Update last message time
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted message]'
|
||||
})
|
||||
.where(eq(chatConversations.id, conversation.id));
|
||||
}
|
||||
|
||||
// Store message reference so it appears in UI
|
||||
// Store full envelope data as JSON so we can decrypt later
|
||||
const envelopeData = {
|
||||
did: body.did,
|
||||
handle: body.handle,
|
||||
ciphertext: body.data.ciphertext
|
||||
};
|
||||
|
||||
const messageId = crypto.randomUUID();
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: senderFullHandle,
|
||||
senderDisplayName: null, // Unknown until decrypted
|
||||
senderAvatarUrl: null,
|
||||
senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null,
|
||||
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
|
||||
senderChatPublicKey: null,
|
||||
swarmMessageId: `swarm:v2:${messageId}`,
|
||||
deliveredAt: new Date(),
|
||||
readAt: null,
|
||||
});
|
||||
|
||||
console.log(`[Swarm Chat V2] Received encrypted message for ${recipientDid}, conversation:`, conversation.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'V2 message received',
|
||||
version: 2
|
||||
});
|
||||
}
|
||||
|
||||
// V1 Legacy Message Format - validate with Zod
|
||||
const data = chatMessageSchema.parse(body) as SwarmChatMessagePayload;
|
||||
|
||||
// Find the recipient (local user)
|
||||
const recipient = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.recipientHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (recipient.isSuspended) {
|
||||
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if message already exists (prevent duplicates)
|
||||
const swarmMessageId = `swarm:${data.senderNodeDomain}:${data.messageId}`;
|
||||
const existingMessage = await db.query.chatMessages.findFirst({
|
||||
where: eq(chatMessages.swarmMessageId, swarmMessageId),
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Message already received',
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipient.id),
|
||||
eq(chatConversations.participant2Handle, senderFullHandle)
|
||||
),
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConversation] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipient.id,
|
||||
participant2Handle: senderFullHandle,
|
||||
lastMessageAt: new Date(data.timestamp),
|
||||
lastMessagePreview: '[Encrypted message]',
|
||||
}).returning();
|
||||
conversation = newConversation;
|
||||
}
|
||||
|
||||
// Store the message
|
||||
const [newMessage] = await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: senderFullHandle,
|
||||
senderDisplayName: data.senderDisplayName,
|
||||
senderAvatarUrl: data.senderAvatarUrl,
|
||||
senderNodeDomain: data.senderNodeDomain,
|
||||
encryptedContent: data.encryptedContent,
|
||||
swarmMessageId,
|
||||
deliveredAt: new Date(),
|
||||
createdAt: new Date(data.timestamp),
|
||||
}).returning();
|
||||
|
||||
// Update conversation last message
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(data.timestamp),
|
||||
lastMessagePreview: '[Encrypted message]',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatConversations.id, conversation.id));
|
||||
|
||||
console.log(`[Swarm Chat] Received message from ${senderFullHandle} to ${data.recipientHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Message received',
|
||||
messageId: newMessage.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Swarm chat inbox error:', error);
|
||||
return NextResponse.json({ error: 'Failed to receive message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt, isNull } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { decryptMessage } from '@/lib/swarm/chat-crypto';
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -43,15 +43,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get user's private key for decryption
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
});
|
||||
|
||||
if (!user?.privateKeyEncrypted) {
|
||||
return NextResponse.json({ error: 'Cannot decrypt messages' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Build query with cursor-based pagination
|
||||
const baseCondition = eq(chatMessages.conversationId, conversationId);
|
||||
const whereCondition = cursor
|
||||
@@ -65,90 +56,16 @@ export async function GET(request: NextRequest) {
|
||||
limit,
|
||||
});
|
||||
|
||||
// Get recipient info for sent messages
|
||||
const recipientHandle = conversation.participant2Handle;
|
||||
let recipientPublicKey: string | null = null;
|
||||
|
||||
console.log('[Messages API] Fetching recipient key for:', recipientHandle);
|
||||
|
||||
// Check if this is a remote user (has @domain)
|
||||
const isRemote = recipientHandle.includes('@');
|
||||
|
||||
if (isRemote) {
|
||||
// Remote user - fetch from their node
|
||||
const [handle, domain] = recipientHandle.split('@');
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
recipientPublicKey = data.user?.chatPublicKey || null;
|
||||
console.log('[Messages API] Fetched remote recipient key:', !!recipientPublicKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Messages API] Failed to fetch remote recipient key:', error);
|
||||
}
|
||||
} else {
|
||||
// Local user
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, recipientHandle),
|
||||
});
|
||||
recipientPublicKey = recipientUser?.chatPublicKey || null;
|
||||
}
|
||||
|
||||
console.log('[Messages API] Recipient public key found:', !!recipientPublicKey);
|
||||
|
||||
// Get sender DID for received messages
|
||||
const senderDids = new Map<string, string>();
|
||||
for (const msg of messages) {
|
||||
const messagesMapped = messages.map((msg) => {
|
||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
||||
if (!isSentByMe && !senderDids.has(msg.senderHandle)) {
|
||||
// Try to get DID for this sender
|
||||
try {
|
||||
const isRemote = msg.senderHandle.includes('@');
|
||||
if (isRemote) {
|
||||
const [handle, domain] = msg.senderHandle.split('@');
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.user?.did) {
|
||||
senderDids.set(msg.senderHandle, data.user.did);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const senderUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, msg.senderHandle),
|
||||
});
|
||||
if (senderUser?.did) {
|
||||
senderDids.set(msg.senderHandle, senderUser.did);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Messages API] Failed to resolve sender DID:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messagesWithDecryption = messages.map((msg) => {
|
||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
||||
|
||||
const senderPubKey = isSentByMe ? recipientPublicKey : msg.senderChatPublicKey;
|
||||
|
||||
console.log('[Messages API] Message:', msg.id, 'isSentByMe:', isSentByMe, 'senderPubKey:', !!senderPubKey, 'msgSenderChatPubKey:', !!msg.senderChatPublicKey);
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
senderHandle: msg.senderHandle,
|
||||
senderDisplayName: msg.senderDisplayName,
|
||||
senderAvatarUrl: msg.senderAvatarUrl,
|
||||
senderDid: isSentByMe ? undefined : senderDids.get(msg.senderHandle), // Add DID for received messages
|
||||
// For decryption:
|
||||
// - Sent messages: need recipient's public key
|
||||
// - Received messages: need sender's public key
|
||||
senderPublicKey: senderPubKey,
|
||||
isE2E: !!msg.senderChatPublicKey || (isSentByMe && !!recipientPublicKey),
|
||||
encryptedContent: msg.encryptedContent, // This is now the full envelope JSON
|
||||
senderDid: msg.senderDid,
|
||||
content: msg.content,
|
||||
deliveredAt: msg.deliveredAt,
|
||||
readAt: msg.readAt,
|
||||
createdAt: msg.createdAt,
|
||||
@@ -157,7 +74,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
messages: messagesWithDecryption.reverse(), // Oldest first for display
|
||||
messages: messagesMapped.reverse(), // Oldest first for display
|
||||
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* Swarm Chat Send
|
||||
*
|
||||
* POST: Send a chat message to another user (local or remote)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, chatConversations, chatMessages } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { encryptMessage } from '@/lib/swarm/chat-crypto';
|
||||
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
|
||||
|
||||
const sendMessageSchema = z.object({
|
||||
recipientHandle: z.string(),
|
||||
// For E2E encryption: client sends pre-encrypted content
|
||||
encryptedContent: z.string().optional(),
|
||||
senderPublicKey: z.string().optional(), // ECDH public key for decryption
|
||||
// Legacy: server-side encryption (will be removed)
|
||||
content: z.string().min(1).max(5000).optional(),
|
||||
}).refine(data => data.encryptedContent || data.content, {
|
||||
message: 'Either encryptedContent or content is required',
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
console.log('[Chat Send] Starting request processing');
|
||||
try {
|
||||
if (!db) {
|
||||
console.error('[Chat Send] Database connection missing');
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
console.warn('[Chat Send] Unauthorized attempt');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.log('[Chat Send] User authenticated:', session.user.id);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (e) {
|
||||
console.error('[Chat Send] Failed to parse JSON body');
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parseResult = sendMessageSchema.safeParse(body);
|
||||
if (!parseResult.success) {
|
||||
console.error('[Chat Send] Schema validation failed:', parseResult.error);
|
||||
return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey);
|
||||
|
||||
// Get sender info
|
||||
const sender = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
});
|
||||
|
||||
if (!sender) {
|
||||
console.error('[Chat Send] Sender not found in DB:', session.user.id);
|
||||
return NextResponse.json({ error: 'Sender not found' }, { status: 404 });
|
||||
}
|
||||
console.log('[Chat Send] Sender retrieved:', sender.handle);
|
||||
|
||||
// Parse recipient handle (could be local or remote)
|
||||
// Strip leading @ if present, then normalize
|
||||
const recipientHandle = data.recipientHandle.toLowerCase().replace(/^@/, '');
|
||||
const isRemote = recipientHandle.includes('@');
|
||||
|
||||
let recipientUser: typeof users.$inferSelect | undefined;
|
||||
let recipientPublicKey: string;
|
||||
let recipientNodeDomain: string | null = null;
|
||||
|
||||
if (isRemote) {
|
||||
// Remote user - need to fetch their public key
|
||||
// Format: handle@domain (e.g., matterbator@batorbros.bond)
|
||||
const atIndex = recipientHandle.indexOf('@');
|
||||
const handle = recipientHandle.substring(0, atIndex);
|
||||
const domain = recipientHandle.substring(atIndex + 1);
|
||||
recipientNodeDomain = domain;
|
||||
console.log('[Chat Send] Processing remote recipient:', handle, '@', domain);
|
||||
|
||||
// Try to find cached remote user
|
||||
recipientUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, recipientHandle),
|
||||
});
|
||||
|
||||
// Check if we have a valid public key (not a placeholder)
|
||||
const hasValidPublicKey = recipientUser?.publicKey?.startsWith('-----BEGIN');
|
||||
|
||||
if (!recipientUser || !hasValidPublicKey) {
|
||||
// Fetch from remote node to get the real public key
|
||||
try {
|
||||
console.log('[Chat Send] Fetching remote user from node:', domain);
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Chat Send] Remote user fetch failed. Status:', response.status);
|
||||
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const remoteUserData = await response.json();
|
||||
const userData = remoteUserData.user || remoteUserData;
|
||||
recipientPublicKey = userData.publicKey;
|
||||
|
||||
if (!recipientPublicKey || !recipientPublicKey.startsWith('-----BEGIN')) {
|
||||
console.error('[Chat Send] Remote user has no valid public key');
|
||||
return NextResponse.json({ error: 'Recipient does not support encrypted chat' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (recipientUser) {
|
||||
// Update existing cached user with real public key
|
||||
await db.update(users)
|
||||
.set({ publicKey: recipientPublicKey })
|
||||
.where(eq(users.id, recipientUser.id));
|
||||
console.log('[Chat Send] Updated cached user with real public key');
|
||||
} else {
|
||||
// Cache the remote user
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: userData.did || `did:swarm:${domain}:${handle}`,
|
||||
handle: recipientHandle,
|
||||
displayName: userData.displayName,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
publicKey: recipientPublicKey,
|
||||
}).returning();
|
||||
recipientUser = newUser;
|
||||
console.log('[Chat Send] Remote user cached');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Chat Send] Failed to fetch remote user:', error);
|
||||
return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 });
|
||||
}
|
||||
} else {
|
||||
recipientPublicKey = recipientUser.publicKey;
|
||||
console.log('[Chat Send] Remote user found in cache with valid key');
|
||||
}
|
||||
} else {
|
||||
// Local user
|
||||
console.log('[Chat Send] Processing local recipient');
|
||||
recipientUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, recipientHandle),
|
||||
});
|
||||
|
||||
if (!recipientUser) {
|
||||
console.warn('[Chat Send] Local recipient not found:', recipientHandle);
|
||||
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (recipientUser.isSuspended) {
|
||||
console.warn('[Chat Send] Local recipient suspended:', recipientHandle);
|
||||
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
|
||||
}
|
||||
|
||||
recipientPublicKey = recipientUser.publicKey;
|
||||
}
|
||||
|
||||
// Handle encryption - either client-side (E2E) or server-side (legacy)
|
||||
let encryptedContent: string;
|
||||
let senderEncryptedContent: string | null = null;
|
||||
|
||||
if (data.encryptedContent) {
|
||||
// E2E mode: client already encrypted the message
|
||||
// Server cannot read the content - this is true E2E encryption
|
||||
console.log('[Chat Send] Using client-side E2E encryption');
|
||||
encryptedContent = data.encryptedContent;
|
||||
// Store sender's public key so recipient can decrypt
|
||||
// Note: senderEncryptedContent not needed in E2E mode - client stores locally
|
||||
} else if (data.content) {
|
||||
// Legacy mode: server-side encryption (for backwards compatibility)
|
||||
console.log('[Chat Send] Using server-side encryption (legacy)');
|
||||
try {
|
||||
encryptedContent = encryptMessage(data.content, recipientPublicKey);
|
||||
senderEncryptedContent = encryptMessage(data.content, sender.publicKey);
|
||||
} catch (encError) {
|
||||
console.error('[Chat Send] Encryption failed:', encError);
|
||||
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: 'No message content provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, sender.id),
|
||||
eq(chatConversations.participant2Handle, recipientHandle)
|
||||
),
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
console.log('[Chat Send] Creating new conversation');
|
||||
const [newConversation] = await db.insert(chatConversations).values({
|
||||
participant1Id: sender.id,
|
||||
participant2Handle: recipientHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: 'New message',
|
||||
}).returning();
|
||||
conversation = newConversation;
|
||||
}
|
||||
|
||||
// Store the message locally
|
||||
const messageId = crypto.randomUUID();
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const swarmMessageId = `swarm:${nodeDomain}:${messageId}`;
|
||||
|
||||
console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey);
|
||||
const [newMessage] = await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: sender.handle,
|
||||
senderDisplayName: sender.displayName,
|
||||
senderAvatarUrl: sender.avatarUrl,
|
||||
senderNodeDomain: null, // Local sender
|
||||
encryptedContent,
|
||||
senderEncryptedContent,
|
||||
// Use client-provided key first, fall back to database
|
||||
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
|
||||
swarmMessageId,
|
||||
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
|
||||
readAt: null,
|
||||
}).returning();
|
||||
|
||||
// Update conversation
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: 'New message',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatConversations.id, conversation.id));
|
||||
|
||||
// For LOCAL recipients, create/update their conversation too
|
||||
if (!isRemote && recipientUser) {
|
||||
try {
|
||||
console.log('[Chat Send] Creating reciprocal conversation for local recipient:', recipientUser.handle);
|
||||
|
||||
// Check if recipient has a conversation with sender
|
||||
let recipientConversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
eq(chatConversations.participant2Handle, sender.handle)
|
||||
),
|
||||
});
|
||||
|
||||
if (!recipientConversation) {
|
||||
// Create conversation for recipient
|
||||
const [newRecipientConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: sender.handle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: 'New message',
|
||||
}).returning();
|
||||
recipientConversation = newRecipientConv;
|
||||
console.log('[Chat Send] Created new conversation for recipient');
|
||||
} else {
|
||||
// Update existing conversation
|
||||
await db.update(chatConversations)
|
||||
.set({
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: 'New message',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatConversations.id, recipientConversation.id));
|
||||
console.log('[Chat Send] Updated existing conversation for recipient');
|
||||
}
|
||||
|
||||
// Insert message into recipient's conversation
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: recipientConversation.id,
|
||||
senderHandle: sender.handle,
|
||||
senderDisplayName: sender.displayName,
|
||||
senderAvatarUrl: sender.avatarUrl,
|
||||
senderNodeDomain: null,
|
||||
encryptedContent,
|
||||
senderEncryptedContent,
|
||||
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
|
||||
swarmMessageId: `${swarmMessageId}-recipient`, // Make it unique for recipient's copy
|
||||
deliveredAt: new Date(),
|
||||
readAt: null,
|
||||
});
|
||||
|
||||
console.log('[Chat Send] Reciprocal message created for local recipient');
|
||||
} catch (recipError) {
|
||||
console.error('[Chat Send] Failed to create reciprocal conversation:', recipError);
|
||||
// Don't fail the whole request - sender's message was still saved
|
||||
}
|
||||
}
|
||||
|
||||
// If remote, send to their node
|
||||
if (isRemote && recipientNodeDomain) {
|
||||
// ... (remote logic remains similar but add logs)
|
||||
console.log('[Chat Send] Dispatching to remote node:', recipientNodeDomain);
|
||||
// ... existing remote send logic ...
|
||||
// For brevity in this tool call, I'm keeping the original logic mostly intact but wrapped/logged.
|
||||
// Re-implementing the block:
|
||||
try {
|
||||
const payload: SwarmChatMessagePayload = {
|
||||
messageId,
|
||||
senderHandle: sender.handle,
|
||||
senderDisplayName: sender.displayName || undefined,
|
||||
senderAvatarUrl: sender.avatarUrl || undefined,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: recipientHandle.split('@')[0],
|
||||
encryptedContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Mark as delivered
|
||||
await db.update(chatMessages)
|
||||
.set({ deliveredAt: new Date() })
|
||||
.where(eq(chatMessages.id, newMessage.id));
|
||||
console.log('[Chat Send] Remote delivery confirmed');
|
||||
} else {
|
||||
console.warn('[Chat Send] Remote delivery failed. Status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Chat Send] Failed to send message to remote node:', error);
|
||||
// Message is still stored locally, will show as undelivered
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Chat Send] Success');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: newMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
// Should be caught by safeParse above, but just in case
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Chat Send] Unhandled error:', error);
|
||||
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export interface SwarmUserProfile {
|
||||
isBot?: boolean;
|
||||
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
||||
nodeDomain: string;
|
||||
chatPublicKey?: string;
|
||||
publicKey?: string; // Signing key for verifying actions
|
||||
did?: string;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
isBot: user.isBot || undefined,
|
||||
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
|
||||
nodeDomain,
|
||||
chatPublicKey: user.chatPublicKey || undefined,
|
||||
publicKey: user.publicKey, // Expose signing key
|
||||
did: user.did || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, users, follows } from '@/db';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
@@ -62,7 +63,6 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
chatPublicKey: profile.chatPublicKey,
|
||||
did: profile.did,
|
||||
}
|
||||
});
|
||||
@@ -96,11 +96,38 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
website: user.website,
|
||||
movedTo: user.movedTo,
|
||||
isBot: user.isBot,
|
||||
publicKey: user.publicKey, // RSA key for signing
|
||||
chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat
|
||||
publicKey: user.publicKey, // Signing key
|
||||
did: user.did, // V2 Identity
|
||||
dmPrivacy: user.dmPrivacy,
|
||||
};
|
||||
|
||||
// Check if viewer can DM this user
|
||||
let canReceiveDms = true;
|
||||
if (user.isBot) {
|
||||
canReceiveDms = false;
|
||||
} else if (user.dmPrivacy === 'none') {
|
||||
canReceiveDms = false;
|
||||
} else if (user.dmPrivacy === 'following') {
|
||||
canReceiveDms = false; // Default to false for 'following'
|
||||
const session = await getSession();
|
||||
if (session?.user) {
|
||||
if (session.user.id === user.id) {
|
||||
canReceiveDms = true; // Can DM yourself
|
||||
} else {
|
||||
const isFollowingViewer = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, user.id),
|
||||
eq(follows.followingId, session.user.id)
|
||||
)
|
||||
});
|
||||
if (isFollowingViewer) {
|
||||
canReceiveDms = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
userResponse.canReceiveDms = canReceiveDms;
|
||||
|
||||
// If this is a bot, include owner info
|
||||
if (user.isBot && user.botOwnerId) {
|
||||
const owner = await db.query.users.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user