12f515b7fb
- 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
446 lines
19 KiB
TypeScript
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 });
|
|
}
|
|
}
|