362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import crypto from 'crypto';
|
|
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
|
import { eq, and, sql } from 'drizzle-orm';
|
|
import { requireAuth } from '@/lib/auth';
|
|
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
|
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
|
import { discoverNode } from '@/lib/swarm/discovery';
|
|
|
|
type RouteContext = { params: Promise<{ handle: string }> };
|
|
|
|
const parseRemoteHandle = (handle: string) => {
|
|
const clean = handle.toLowerCase().replace(/^@/, '');
|
|
const parts = clean.split('@').filter(Boolean);
|
|
if (parts.length === 2) {
|
|
return { handle: parts[0], domain: parts[1] };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Check follow status
|
|
export async function GET(request: Request, context: RouteContext) {
|
|
try {
|
|
const currentUser = await requireAuth();
|
|
const { handle } = await context.params;
|
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
|
const remote = parseRemoteHandle(handle);
|
|
|
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
|
}
|
|
|
|
if (remote) {
|
|
if (!db) {
|
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
|
}
|
|
const targetHandle = `${remote.handle}@${remote.domain}`;
|
|
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
|
where: and(
|
|
eq(remoteFollows.followerId, currentUser.id),
|
|
eq(remoteFollows.targetHandle, targetHandle)
|
|
),
|
|
});
|
|
return NextResponse.json({ following: !!existingRemoteFollow, remote: true });
|
|
}
|
|
|
|
if (!db) {
|
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
|
}
|
|
|
|
const targetUser = await db.query.users.findFirst({
|
|
where: eq(users.handle, cleanHandle),
|
|
});
|
|
|
|
if (!targetUser) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
if (targetUser.isSuspended) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
|
|
if (targetUser.id === currentUser.id) {
|
|
return NextResponse.json({ following: false, self: true });
|
|
}
|
|
|
|
const existingFollow = await db.query.follows.findFirst({
|
|
where: and(
|
|
eq(follows.followerId, currentUser.id),
|
|
eq(follows.followingId, targetUser.id)
|
|
),
|
|
});
|
|
|
|
return NextResponse.json({ following: !!existingFollow });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Authentication required') {
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
console.error('Follow status error:', error);
|
|
return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Follow a user
|
|
export async function POST(request: Request, context: RouteContext) {
|
|
try {
|
|
const signedAction = await request.json();
|
|
const currentUser = await requireSignedAction(signedAction);
|
|
|
|
// Extract handle from URL params (still needed for routing)
|
|
// But we should also validate it matches the signed action intent if possible,
|
|
// or just trust the signed interaction timestamp/nonce.
|
|
// For follow, the data usually contains target info.
|
|
// Let's assume the client sends targetHandle in 'data' of signed action to be secure.
|
|
|
|
const { handle } = await context.params;
|
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
|
const remote = parseRemoteHandle(handle);
|
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
|
|
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
|
}
|
|
|
|
if (remote) {
|
|
const targetHandle = `${remote.handle}@${remote.domain}`;
|
|
|
|
// Check if already following
|
|
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
|
where: and(
|
|
eq(remoteFollows.followerId, currentUser.id),
|
|
eq(remoteFollows.targetHandle, targetHandle)
|
|
),
|
|
});
|
|
if (existingRemoteFollow) {
|
|
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
|
}
|
|
|
|
// Only allow following swarm nodes
|
|
let isSwarm = await isSwarmNode(remote.domain);
|
|
if (!isSwarm) {
|
|
const discovery = await discoverNode(remote.domain, nodeDomain);
|
|
isSwarm = discovery.success;
|
|
}
|
|
|
|
if (!isSwarm) {
|
|
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 });
|
|
}
|
|
|
|
// Use swarm protocol
|
|
const activityId = crypto.randomUUID();
|
|
|
|
const result = await deliverSwarmFollow(remote.domain, {
|
|
targetHandle: remote.handle,
|
|
follow: {
|
|
followerHandle: currentUser.handle,
|
|
followerDisplayName: currentUser.displayName || currentUser.handle,
|
|
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
|
followerBio: currentUser.bio || undefined,
|
|
followerNodeDomain: nodeDomain,
|
|
interactionId: activityId,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json({ error: result.error || 'Failed to follow user' }, { status: 502 });
|
|
}
|
|
|
|
// Store the follow locally
|
|
await db.insert(remoteFollows).values({
|
|
followerId: currentUser.id,
|
|
targetHandle,
|
|
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
|
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
|
activityId,
|
|
displayName: null,
|
|
bio: null,
|
|
avatarUrl: null,
|
|
});
|
|
|
|
// Update the user's following count (atomic increment)
|
|
await db.update(users)
|
|
.set({ followingCount: sql`${users.followingCount} + 1` })
|
|
.where(eq(users.id, currentUser.id));
|
|
|
|
// Cache the remote user's recent posts in the background
|
|
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
|
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
|
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
|
|
|
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
|
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
|
}
|
|
|
|
if (!db) {
|
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
|
}
|
|
|
|
// Find target user
|
|
const targetUser = await db.query.users.findFirst({
|
|
where: eq(users.handle, cleanHandle),
|
|
});
|
|
|
|
if (!targetUser) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
if (targetUser.isSuspended) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
|
|
// Can't follow yourself
|
|
if (targetUser.id === currentUser.id) {
|
|
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
|
|
}
|
|
|
|
// Check if already following
|
|
const existingFollow = await db.query.follows.findFirst({
|
|
where: and(
|
|
eq(follows.followerId, currentUser.id),
|
|
eq(follows.followingId, targetUser.id)
|
|
),
|
|
});
|
|
|
|
if (existingFollow) {
|
|
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
|
}
|
|
|
|
// Create follow
|
|
await db.insert(follows).values({
|
|
followerId: currentUser.id,
|
|
followingId: targetUser.id,
|
|
});
|
|
|
|
if (currentUser.id !== targetUser.id) {
|
|
// Create notification
|
|
await db.insert(notifications).values({
|
|
userId: targetUser.id,
|
|
actorId: currentUser.id,
|
|
actorHandle: currentUser.handle,
|
|
actorDisplayName: currentUser.displayName,
|
|
actorAvatarUrl: currentUser.avatarUrl,
|
|
actorNodeDomain: null,
|
|
type: 'follow',
|
|
});
|
|
|
|
// Also notify bot owner if this is a bot being followed
|
|
if (targetUser.isBot && targetUser.botOwnerId) {
|
|
await db.insert(notifications).values({
|
|
userId: targetUser.botOwnerId,
|
|
actorId: currentUser.id,
|
|
actorHandle: currentUser.handle,
|
|
actorDisplayName: currentUser.displayName,
|
|
actorAvatarUrl: currentUser.avatarUrl,
|
|
actorNodeDomain: null,
|
|
type: 'follow',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update counts (atomic increments)
|
|
await db.update(users)
|
|
.set({ followingCount: sql`${users.followingCount} + 1` })
|
|
.where(eq(users.id, currentUser.id));
|
|
|
|
await db.update(users)
|
|
.set({ followersCount: sql`${users.followersCount} + 1` })
|
|
.where(eq(users.id, targetUser.id));
|
|
|
|
return NextResponse.json({ success: true, following: true });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Authentication required') {
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
console.error('Follow error:', error);
|
|
return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Unfollow a user
|
|
export async function DELETE(request: Request, context: RouteContext) {
|
|
try {
|
|
const signedAction = await request.json();
|
|
const currentUser = await requireSignedAction(signedAction);
|
|
|
|
const { handle } = await context.params;
|
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
|
const remote = parseRemoteHandle(handle);
|
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
|
|
|
if (remote) {
|
|
if (!db) {
|
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
|
}
|
|
const targetHandle = `${remote.handle}@${remote.domain}`;
|
|
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
|
where: and(
|
|
eq(remoteFollows.followerId, currentUser.id),
|
|
eq(remoteFollows.targetHandle, targetHandle)
|
|
),
|
|
});
|
|
if (!existingRemoteFollow) {
|
|
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
|
}
|
|
|
|
// Use swarm protocol for unfollow
|
|
const result = await deliverSwarmUnfollow(remote.domain, {
|
|
targetHandle: remote.handle,
|
|
unfollow: {
|
|
followerHandle: currentUser.handle,
|
|
followerNodeDomain: nodeDomain,
|
|
interactionId: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
|
// Continue anyway - remove local record
|
|
}
|
|
|
|
// Remove the follow record
|
|
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
|
|
|
// Update the user's following count (atomic decrement, clamped to 0)
|
|
await db.update(users)
|
|
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
|
.where(eq(users.id, currentUser.id));
|
|
|
|
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
|
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
|
}
|
|
|
|
if (!db) {
|
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
|
}
|
|
|
|
// Find target user
|
|
const targetUser = await db.query.users.findFirst({
|
|
where: eq(users.handle, cleanHandle),
|
|
});
|
|
|
|
if (!targetUser) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
if (targetUser.isSuspended) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
|
|
// Find existing follow
|
|
const existingFollow = await db.query.follows.findFirst({
|
|
where: and(
|
|
eq(follows.followerId, currentUser.id),
|
|
eq(follows.followingId, targetUser.id)
|
|
),
|
|
});
|
|
|
|
if (!existingFollow) {
|
|
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
|
}
|
|
|
|
// Remove follow
|
|
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
|
|
|
// Update counts (atomic decrements, clamped to 0)
|
|
await db.update(users)
|
|
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
|
.where(eq(users.id, currentUser.id));
|
|
|
|
await db.update(users)
|
|
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
|
|
.where(eq(users.id, targetUser.id));
|
|
|
|
return NextResponse.json({ success: true, following: false });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Authentication required') {
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
console.error('Unfollow error:', error);
|
|
return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
|
|
}
|
|
}
|