328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { db, posts, likes, users, notifications } from '@/db';
|
|
import { requireAuth } from '@/lib/auth';
|
|
import { eq, and } from 'drizzle-orm';
|
|
import crypto from 'crypto';
|
|
|
|
type RouteContext = { params: Promise<{ id: string }> };
|
|
|
|
/**
|
|
* Extract domain from a swarm post ID (swarm:domain:postId)
|
|
*/
|
|
function extractSwarmDomain(apId: string | null): string | null {
|
|
if (!apId?.startsWith('swarm:')) return null;
|
|
const lastColonIndex = apId.lastIndexOf(':');
|
|
if (lastColonIndex <= 6) return null;
|
|
return apId.substring(6, lastColonIndex);
|
|
}
|
|
|
|
/**
|
|
* Check if a post is from a swarm node (has swarm: prefix in apId)
|
|
*/
|
|
function isSwarmPost(apId: string | null): boolean {
|
|
return apId?.startsWith('swarm:') ?? false;
|
|
}
|
|
|
|
/**
|
|
* Extract the original post ID from a swarm apId
|
|
*/
|
|
function extractSwarmPostId(apId: string): string | null {
|
|
if (!apId) return null;
|
|
const lastColonIndex = apId.lastIndexOf(':');
|
|
if (lastColonIndex === -1) return null;
|
|
return apId.substring(lastColonIndex + 1);
|
|
}
|
|
|
|
// Like a post
|
|
export async function POST(request: Request, context: RouteContext) {
|
|
try {
|
|
const user = await requireAuth();
|
|
const { id: rawId } = await context.params;
|
|
const postId = decodeURIComponent(rawId);
|
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
|
|
|
if (user.isSuspended || user.isSilenced) {
|
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
|
}
|
|
|
|
// Handle swarm posts (format: swarm:domain:uuid)
|
|
if (postId.startsWith('swarm:')) {
|
|
const targetDomain = extractSwarmDomain(postId);
|
|
const originalPostId = extractSwarmPostId(postId);
|
|
|
|
if (!targetDomain || !originalPostId) {
|
|
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
|
}
|
|
|
|
// Deliver like directly to the origin node
|
|
const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
|
|
|
|
const result = await deliverSwarmLike(targetDomain, {
|
|
postId: originalPostId,
|
|
like: {
|
|
actorHandle: user.handle,
|
|
actorDisplayName: user.displayName || user.handle,
|
|
actorAvatarUrl: user.avatarUrl || undefined,
|
|
actorNodeDomain: nodeDomain,
|
|
interactionId: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error(`[Swarm] Like delivery failed: ${result.error}`);
|
|
return NextResponse.json({ error: 'Failed to deliver like to remote node' }, { status: 502 });
|
|
}
|
|
|
|
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
|
|
return NextResponse.json({ success: true, liked: true });
|
|
}
|
|
|
|
// Local post - check if it exists
|
|
const post = await db.query.posts.findFirst({
|
|
where: eq(posts.id, postId),
|
|
});
|
|
|
|
if (!post) {
|
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
|
}
|
|
if (post.isRemoved) {
|
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
|
}
|
|
|
|
// Check if already liked
|
|
const existingLike = await db.query.likes.findFirst({
|
|
where: and(
|
|
eq(likes.userId, user.id),
|
|
eq(likes.postId, postId)
|
|
),
|
|
});
|
|
|
|
if (existingLike) {
|
|
return NextResponse.json({ error: 'Already liked' }, { status: 400 });
|
|
}
|
|
|
|
// Create like
|
|
await db.insert(likes).values({
|
|
userId: user.id,
|
|
postId,
|
|
});
|
|
|
|
// Update post's like count
|
|
await db.update(posts)
|
|
.set({ likesCount: post.likesCount + 1 })
|
|
.where(eq(posts.id, postId));
|
|
|
|
if (post.userId !== user.id) {
|
|
// Create notification with actor info stored directly
|
|
await db.insert(notifications).values({
|
|
userId: post.userId,
|
|
actorId: user.id,
|
|
actorHandle: user.handle,
|
|
actorDisplayName: user.displayName,
|
|
actorAvatarUrl: user.avatarUrl,
|
|
actorNodeDomain: null, // Local user
|
|
postId,
|
|
postContent: post.content?.slice(0, 200) || null,
|
|
type: 'like',
|
|
});
|
|
|
|
// Also notify bot owner if this is a bot's post
|
|
const postAuthor = await db.query.users.findFirst({
|
|
where: eq(users.id, post.userId),
|
|
});
|
|
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
|
await db.insert(notifications).values({
|
|
userId: postAuthor.botOwnerId,
|
|
actorId: user.id,
|
|
actorHandle: user.handle,
|
|
actorDisplayName: user.displayName,
|
|
actorAvatarUrl: user.avatarUrl,
|
|
actorNodeDomain: null,
|
|
postId,
|
|
postContent: post.content?.slice(0, 200) || null,
|
|
type: 'like',
|
|
});
|
|
}
|
|
}
|
|
|
|
// If this is a cached swarm post (has swarm: apId), also deliver to origin
|
|
if (isSwarmPost(post.apId)) {
|
|
const targetDomain = extractSwarmDomain(post.apId);
|
|
const originalPostId = extractSwarmPostId(post.apId!);
|
|
|
|
if (targetDomain && originalPostId) {
|
|
(async () => {
|
|
try {
|
|
const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
|
|
|
|
const result = await deliverSwarmLike(targetDomain, {
|
|
postId: originalPostId,
|
|
like: {
|
|
actorHandle: user.handle,
|
|
actorDisplayName: user.displayName || user.handle,
|
|
actorAvatarUrl: user.avatarUrl || undefined,
|
|
actorNodeDomain: nodeDomain,
|
|
interactionId: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (result.success) {
|
|
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
|
|
} else {
|
|
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Swarm] Error delivering like:', err);
|
|
}
|
|
})();
|
|
}
|
|
} else if (post.apId) {
|
|
// FALLBACK: Use ActivityPub for non-swarm posts
|
|
(async () => {
|
|
try {
|
|
const { createLikeActivity } = await import('@/lib/activitypub/activities');
|
|
const { deliverActivity } = await import('@/lib/activitypub/outbox');
|
|
|
|
// Get the post author's actor URL
|
|
const postWithAuthor = await db.query.posts.findFirst({
|
|
where: eq(posts.id, postId),
|
|
with: { author: true },
|
|
});
|
|
|
|
if (!postWithAuthor?.author) return;
|
|
|
|
const author = postWithAuthor.author as { handle: string };
|
|
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
|
|
} catch (err) {
|
|
console.error('[Federation] Error federating like:', err);
|
|
}
|
|
})();
|
|
}
|
|
|
|
return NextResponse.json({ success: true, liked: true });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Authentication required') {
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Unlike a post
|
|
export async function DELETE(request: Request, context: RouteContext) {
|
|
try {
|
|
const user = await requireAuth();
|
|
const { id: rawId } = await context.params;
|
|
const postId = decodeURIComponent(rawId);
|
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
|
|
|
if (user.isSuspended || user.isSilenced) {
|
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
|
}
|
|
|
|
// Handle swarm posts (format: swarm:domain:uuid)
|
|
if (postId.startsWith('swarm:')) {
|
|
const targetDomain = extractSwarmDomain(postId);
|
|
const originalPostId = postId.split(':')[2];
|
|
|
|
if (!targetDomain || !originalPostId) {
|
|
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
|
}
|
|
|
|
// Deliver unlike directly to the origin node
|
|
const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
|
|
|
|
const result = await deliverSwarmUnlike(targetDomain, {
|
|
postId: originalPostId,
|
|
unlike: {
|
|
actorHandle: user.handle,
|
|
actorNodeDomain: nodeDomain,
|
|
interactionId: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error(`[Swarm] Unlike delivery failed: ${result.error}`);
|
|
return NextResponse.json({ error: 'Failed to deliver unlike to remote node' }, { status: 502 });
|
|
}
|
|
|
|
console.log(`[Swarm] Unlike delivered to ${targetDomain} for post ${originalPostId}`);
|
|
return NextResponse.json({ success: true, liked: false });
|
|
}
|
|
|
|
// Local post - check if it exists
|
|
const post = await db.query.posts.findFirst({
|
|
where: eq(posts.id, postId),
|
|
});
|
|
|
|
if (!post) {
|
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
|
}
|
|
if (post.isRemoved) {
|
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
|
}
|
|
|
|
// Find the like
|
|
const existingLike = await db.query.likes.findFirst({
|
|
where: and(
|
|
eq(likes.userId, user.id),
|
|
eq(likes.postId, postId)
|
|
),
|
|
});
|
|
|
|
if (!existingLike) {
|
|
return NextResponse.json({ error: 'Not liked' }, { status: 400 });
|
|
}
|
|
|
|
// Remove like
|
|
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
|
|
|
// Update post's like count
|
|
await db.update(posts)
|
|
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
|
.where(eq(posts.id, postId));
|
|
|
|
// SWARM-FIRST: Deliver unlike to swarm node
|
|
if (isSwarmPost(post.apId)) {
|
|
const targetDomain = extractSwarmDomain(post.apId);
|
|
const originalPostId = extractSwarmPostId(post.apId!);
|
|
|
|
if (targetDomain && originalPostId) {
|
|
(async () => {
|
|
try {
|
|
const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
|
|
|
|
const result = await deliverSwarmUnlike(targetDomain, {
|
|
postId: originalPostId,
|
|
unlike: {
|
|
actorHandle: user.handle,
|
|
actorNodeDomain: nodeDomain,
|
|
interactionId: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
if (result.success) {
|
|
console.log(`[Swarm] Unlike delivered to ${targetDomain}`);
|
|
} else {
|
|
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Swarm] Error delivering unlike:', err);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true, liked: false });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Authentication required') {
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
|
|
}
|
|
}
|