Files
Synapsis/src/app/api/users/[handle]/follow/route.ts
T
Christomatt 12f515b7fb feat(notifications): Refactor notification system and consolidate interaction handlers
- Remove dedicated bot owner notification utility in favor of unified notification system
- Update all interaction endpoints (follow, like, mention, repost) to use consolidated notification flow
- Refactor notification route to handle all interaction types through single endpoint
- Update posts endpoints to trigger notifications through unified system
- Add database migration snapshot for notification schema changes
- Simplify notification logic by removing redundant bot owner notification module
- Improve notification consistency across all user interactions and post operations
2026-01-26 12:45:35 +01:00

446 lines
19 KiB
TypeScript

import { NextResponse } from 'next/server';
import crypto from 'crypto';
import { db, follows, users, notifications, remoteFollows } from '@/db';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
import { deliverActivity } from '@/lib/activitypub/outbox';
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
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;
};
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
const stripHtml = (html: string | null | undefined): string | null => {
if (!html) return null;
return html.replace(/<[^>]*>/g, '').trim() || 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 currentUser = await requireAuth();
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 });
}
// SWARM-FIRST: Check if this is a Synapsis swarm node
const isSwarm = await isSwarmNode(remote.domain);
if (isSwarm) {
// Use swarm protocol for Synapsis nodes
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) {
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
// Fall through to ActivityPub below
} else {
// Swarm follow succeeded - 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, // Will be fetched later
bio: null,
avatarUrl: null,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.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 });
}
}
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
if (!targetInbox) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
}
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
const privateKey = currentUser.privateKeyEncrypted;
if (!privateKey) {
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
}
const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId);
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
}
// Extract avatar URL from remote profile
let avatarUrl: string | null = null;
if (remoteProfile.icon) {
if (typeof remoteProfile.icon === 'string') {
avatarUrl = remoteProfile.icon;
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
avatarUrl = remoteProfile.icon.url;
}
}
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: remoteProfile.id,
inboxUrl: targetInbox,
activityId,
displayName: remoteProfile.name || null,
bio: stripHtml(remoteProfile.summary),
avatarUrl,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background
const origin = new URL(request.url).origin;
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
.catch(err => console.error('Error caching remote posts:', err));
return NextResponse.json({ success: true, following: true, remote: 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 with actor info stored directly
await db.insert(notifications).values({
userId: targetUser.id,
actorId: currentUser.id,
actorHandle: currentUser.handle,
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null, // Local user
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
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Follow activity
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 currentUser = await requireAuth();
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 });
}
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
if (isSwarmFollow) {
// 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
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.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 });
}
// FALLBACK: Use ActivityPub for non-swarm follows
const originalFollow = createFollowActivity(
currentUser,
existingRemoteFollow.targetActorUrl,
nodeDomain,
existingRemoteFollow.activityId
);
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
const privateKey = currentUser.privateKeyEncrypted;
if (!privateKey) {
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
}
const result = await deliverActivity(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
}
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
return NextResponse.json({ success: true, following: false, remote: 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
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Undo Follow activity
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 });
}
}