Initial commit
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Swarm Announce Endpoint
|
||||
*
|
||||
* POST: Receive announcements from other nodes joining the swarm
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { upsertSwarmNode } from '@/lib/swarm/registry';
|
||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||
import type { SwarmNodeInfo } from '@/lib/swarm/types';
|
||||
|
||||
const announcementSchema = z.object({
|
||||
domain: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
logoUrl: z.string().url().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
softwareVersion: z.string().optional(),
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
signature: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/announce
|
||||
*
|
||||
* Receives an announcement from another node and responds with our info.
|
||||
* This is how nodes introduce themselves to the swarm.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const announcement = announcementSchema.parse(body);
|
||||
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||
|
||||
// Don't process announcements from ourselves
|
||||
if (announcement.domain === ourDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot announce to self' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Add/update the announcing node in our registry
|
||||
const nodeInfo: SwarmNodeInfo = {
|
||||
domain: announcement.domain,
|
||||
name: announcement.name,
|
||||
description: announcement.description,
|
||||
logoUrl: announcement.logoUrl,
|
||||
publicKey: announcement.publicKey,
|
||||
softwareVersion: announcement.softwareVersion,
|
||||
userCount: announcement.userCount,
|
||||
postCount: announcement.postCount,
|
||||
capabilities: announcement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement');
|
||||
|
||||
console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${announcement.domain}`);
|
||||
|
||||
// Respond with our own info
|
||||
const ourAnnouncement = await buildAnnouncement();
|
||||
|
||||
return NextResponse.json({
|
||||
domain: ourAnnouncement.domain,
|
||||
name: ourAnnouncement.name,
|
||||
description: ourAnnouncement.description,
|
||||
logoUrl: ourAnnouncement.logoUrl,
|
||||
publicKey: ourAnnouncement.publicKey,
|
||||
softwareVersion: ourAnnouncement.softwareVersion,
|
||||
userCount: ourAnnouncement.userCount,
|
||||
postCount: ourAnnouncement.postCount,
|
||||
capabilities: ourAnnouncement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid announcement payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Swarm announce error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process announcement' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Swarm Gossip Endpoint
|
||||
*
|
||||
* POST: Exchange node and handle information with other nodes
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { processGossip } from '@/lib/swarm/gossip';
|
||||
import { markNodeSuccess } from '@/lib/swarm/registry';
|
||||
import type { SwarmGossipPayload } from '@/lib/swarm/types';
|
||||
|
||||
const handleSchema = z.object({
|
||||
handle: z.string(),
|
||||
did: z.string(),
|
||||
nodeDomain: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const nodeInfoSchema = z.object({
|
||||
domain: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
softwareVersion: z.string().optional(),
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
lastSeenAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const gossipPayloadSchema = z.object({
|
||||
sender: z.string().min(1),
|
||||
nodes: z.array(nodeInfoSchema),
|
||||
handles: z.array(handleSchema).optional(),
|
||||
timestamp: z.string(),
|
||||
since: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/gossip
|
||||
*
|
||||
* Receives gossip from another node and responds with our own data.
|
||||
* This is the core of the epidemic protocol - nodes exchange what they know.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const payload = gossipPayloadSchema.parse(body) as SwarmGossipPayload;
|
||||
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||
|
||||
// Don't process gossip from ourselves
|
||||
if (payload.sender === ourDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot gossip with self' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Gossip from ${payload.sender}: ${payload.nodes.length} nodes, ${payload.handles?.length || 0} handles`);
|
||||
|
||||
// Process the incoming gossip and build our response
|
||||
const response = await processGossip(payload);
|
||||
|
||||
// Mark the sender as successfully contacted
|
||||
await markNodeSuccess(payload.sender);
|
||||
|
||||
console.log(`[Swarm] Gossip response to ${payload.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid gossip payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Swarm gossip error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process gossip' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Swarm Inbox Endpoint
|
||||
*
|
||||
* POST: Receive posts from users on other swarm nodes that local users follow
|
||||
*
|
||||
* This is the swarm equivalent of ActivityPub inbox - when a user on another
|
||||
* Synapsis node creates a post, it gets pushed here for their followers.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmPostSchema = z.object({
|
||||
post: z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
createdAt: z.string(),
|
||||
isNsfw: z.boolean(),
|
||||
replyToId: z.string().optional(),
|
||||
repostOfId: z.string().optional(),
|
||||
media: z.array(z.object({
|
||||
url: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
altText: z.string().optional(),
|
||||
})).optional(),
|
||||
linkPreviewUrl: z.string().optional(),
|
||||
linkPreviewTitle: z.string().optional(),
|
||||
linkPreviewDescription: z.string().optional(),
|
||||
linkPreviewImage: z.string().optional(),
|
||||
}),
|
||||
author: z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
isNsfw: z.boolean(),
|
||||
}),
|
||||
nodeDomain: z.string(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/inbox
|
||||
*
|
||||
* Receives a post from another swarm node.
|
||||
* Stores it for local users who follow the author.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmPostSchema.parse(body);
|
||||
|
||||
// Construct the swarm post ID
|
||||
const swarmPostId = `swarm:${data.nodeDomain}:${data.post.id}`;
|
||||
|
||||
// Check if we already have this post
|
||||
const existingPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, swarmPostId),
|
||||
});
|
||||
|
||||
if (existingPost) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Post already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if anyone on this node follows the author
|
||||
const authorActorUrl = `swarm://${data.nodeDomain}/${data.author.handle}`;
|
||||
const hasFollowers = await db.query.remoteFollowers.findFirst({
|
||||
where: eq(remoteFollowers.actorUrl, authorActorUrl),
|
||||
});
|
||||
|
||||
// Even if no one follows, we might want to cache for timeline
|
||||
// For now, only store if someone follows
|
||||
if (!hasFollowers) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No local followers',
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create placeholder user for the remote author
|
||||
const remoteHandle = `${data.author.handle}@${data.nodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.nodeDomain}:${data.author.handle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.author.displayName,
|
||||
avatarUrl: data.author.avatarUrl || null,
|
||||
isNsfw: data.author.isNsfw,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
} else {
|
||||
// Update profile info if changed
|
||||
await db.update(users)
|
||||
.set({
|
||||
displayName: data.author.displayName,
|
||||
avatarUrl: data.author.avatarUrl || remoteUser.avatarUrl,
|
||||
isNsfw: data.author.isNsfw,
|
||||
})
|
||||
.where(eq(users.id, remoteUser.id));
|
||||
}
|
||||
|
||||
// Create the post
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: remoteUser.id,
|
||||
content: data.post.content,
|
||||
isNsfw: data.post.isNsfw || data.author.isNsfw,
|
||||
apId: swarmPostId,
|
||||
apUrl: `https://${data.nodeDomain}/${data.author.handle}/posts/${data.post.id}`,
|
||||
createdAt: new Date(data.post.createdAt),
|
||||
linkPreviewUrl: data.post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: data.post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: data.post.linkPreviewDescription || null,
|
||||
linkPreviewImage: data.post.linkPreviewImage || null,
|
||||
}).returning();
|
||||
|
||||
// Store media attachments
|
||||
if (data.post.media && data.post.media.length > 0) {
|
||||
for (const m of data.post.media) {
|
||||
await db.insert(media).values({
|
||||
userId: remoteUser.id,
|
||||
postId: newPost.id,
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || null,
|
||||
altText: m.altText || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received post from ${remoteHandle}: ${newPost.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Post received',
|
||||
postId: newPost.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Inbox error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Swarm Info Endpoint
|
||||
*
|
||||
* GET: Returns this node's public swarm information
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||
|
||||
/**
|
||||
* GET /api/swarm/info
|
||||
*
|
||||
* Returns this node's public information for the swarm.
|
||||
* Used by other nodes during discovery.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const announcement = await buildAnnouncement();
|
||||
|
||||
return NextResponse.json({
|
||||
domain: announcement.domain,
|
||||
name: announcement.name,
|
||||
description: announcement.description,
|
||||
logoUrl: announcement.logoUrl,
|
||||
publicKey: announcement.publicKey,
|
||||
softwareVersion: announcement.softwareVersion,
|
||||
userCount: announcement.userCount,
|
||||
postCount: announcement.postCount,
|
||||
capabilities: announcement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm info error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get node info' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Swarm Follow Endpoint
|
||||
*
|
||||
* POST: Receive a follow from another swarm node
|
||||
*
|
||||
* This enables swarm-native follows between Synapsis nodes,
|
||||
* bypassing ActivityPub for faster, more direct connections.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
follow: z.object({
|
||||
followerHandle: z.string(),
|
||||
followerDisplayName: z.string(),
|
||||
followerAvatarUrl: z.string().optional(),
|
||||
followerBio: z.string().optional(),
|
||||
followerNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/follow
|
||||
*
|
||||
* Receives a follow from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmFollowSchema.parse(body);
|
||||
|
||||
// Find the target user (local user being followed)
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Construct the remote follower's actor URL (swarm-style)
|
||||
const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
|
||||
const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
|
||||
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
|
||||
|
||||
// Check if this follow already exists
|
||||
const existingFollow = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, actorUrl)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollow) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Already following',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the remote follower record
|
||||
await db.insert(remoteFollowers).values({
|
||||
userId: targetUser.id,
|
||||
actorUrl,
|
||||
inboxUrl,
|
||||
handle: remoteHandle,
|
||||
activityId: data.follow.interactionId,
|
||||
});
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Get or create placeholder user for the remote follower (for notifications)
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.follow.followerNodeDomain}:${data.follow.followerHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.follow.followerDisplayName,
|
||||
avatarUrl: data.follow.followerAvatarUrl || null,
|
||||
bio: data.follow.followerBio || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: remoteUser.id,
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Follow received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Follow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Swarm Like Endpoint
|
||||
*
|
||||
* POST: Receive a like from another swarm node
|
||||
*
|
||||
* This is the swarm-first approach - direct node-to-node communication
|
||||
* for likes, bypassing ActivityPub for Synapsis nodes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmLikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
like: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/like
|
||||
*
|
||||
* Receives a like from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmLikeSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Increment like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Create a notification for the post author
|
||||
// First, get or create a placeholder user for the remote liker
|
||||
const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
// Create a placeholder user for the remote actor
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.like.actorDisplayName,
|
||||
avatarUrl: data.like.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
postId: data.postId,
|
||||
type: 'like',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Like received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Like error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process like' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Swarm Mention Endpoint
|
||||
*
|
||||
* POST: Receive a mention notification from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmMentionSchema = z.object({
|
||||
mentionedHandle: z.string(),
|
||||
mention: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
postId: z.string(),
|
||||
postContent: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/mention
|
||||
*
|
||||
* Receives a mention notification from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmMentionSchema.parse(body);
|
||||
|
||||
// Find the mentioned user (local user)
|
||||
const mentionedUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!mentionedUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (mentionedUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get or create placeholder user for the remote actor
|
||||
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.mention.actorDisplayName,
|
||||
avatarUrl: data.mention.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Check if we already have this post cached (from swarm timeline)
|
||||
// If not, create a placeholder post for the notification
|
||||
const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
|
||||
let post = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, swarmPostId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
// Create a placeholder post for the mention
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: remoteUser.id,
|
||||
content: data.mention.postContent,
|
||||
apId: swarmPostId,
|
||||
apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
|
||||
createdAt: new Date(data.mention.timestamp),
|
||||
}).returning();
|
||||
post = newPost;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorId: remoteUser.id,
|
||||
postId: post.id,
|
||||
type: 'mention',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Mention received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Mention error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Swarm Repost Endpoint
|
||||
*
|
||||
* POST: Receive a repost from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmRepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
repost: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
repostId: z.string(), // The ID of the repost on the actor's node
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/repost
|
||||
*
|
||||
* Receives a repost notification from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmRepostSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Get or create placeholder user for the remote reposter
|
||||
const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.repost.actorNodeDomain}:${data.repost.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.repost.actorDisplayName,
|
||||
avatarUrl: data.repost.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
postId: data.postId,
|
||||
type: 'repost',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Repost received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Repost error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Swarm Unfollow Endpoint
|
||||
*
|
||||
* POST: Receive an unfollow from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmUnfollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
unfollow: z.object({
|
||||
followerHandle: z.string(),
|
||||
followerNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/unfollow
|
||||
*
|
||||
* Receives an unfollow from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmUnfollowSchema.parse(body);
|
||||
|
||||
// Find the target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find and remove the remote follower record
|
||||
const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
|
||||
|
||||
const existingFollow = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, actorUrl)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingFollow) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Not following',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the follow
|
||||
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id));
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Unfollow received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Unfollow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Swarm Unlike Endpoint
|
||||
*
|
||||
* POST: Receive an unlike from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmUnlikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
unlike: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/unlike
|
||||
*
|
||||
* Receives an unlike from another swarm node.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmUnlikeSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Decrement like count (don't go below 0)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Unlike received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Unlike error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process unlike' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Swarm Nodes Endpoint
|
||||
*
|
||||
* GET: List all known nodes in the swarm
|
||||
* POST: Trigger discovery/sync operations (admin only)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import {
|
||||
getActiveSwarmNodes,
|
||||
getSwarmStats,
|
||||
addSeedNode,
|
||||
} from '@/lib/swarm/registry';
|
||||
import {
|
||||
announceToSeeds,
|
||||
announceToNode,
|
||||
discoverNode,
|
||||
} from '@/lib/swarm/discovery';
|
||||
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
|
||||
|
||||
/**
|
||||
* GET /api/swarm/nodes
|
||||
*
|
||||
* Returns list of known swarm nodes and stats.
|
||||
* Public endpoint - anyone can see the swarm.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
|
||||
const includeStats = searchParams.get('stats') === 'true';
|
||||
|
||||
const nodes = await getActiveSwarmNodes(limit);
|
||||
|
||||
const response: {
|
||||
nodes: typeof nodes;
|
||||
stats?: Awaited<ReturnType<typeof getSwarmStats>>;
|
||||
} = { nodes };
|
||||
|
||||
if (includeStats) {
|
||||
response.stats = await getSwarmStats();
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Swarm nodes error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch swarm nodes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(['announce', 'discover', 'gossip', 'addSeed']),
|
||||
domain: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/nodes
|
||||
*
|
||||
* Admin-only endpoint to trigger swarm operations:
|
||||
* - announce: Announce to seeds or a specific node
|
||||
* - discover: Discover a specific node
|
||||
* - gossip: Run a gossip round or gossip with specific node
|
||||
* - addSeed: Add a new seed node
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const { action, domain, priority } = actionSchema.parse(body);
|
||||
|
||||
switch (action) {
|
||||
case 'announce': {
|
||||
if (domain) {
|
||||
const result = await announceToNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
} else {
|
||||
const result = await announceToSeeds();
|
||||
return NextResponse.json({ action, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
case 'discover': {
|
||||
if (!domain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Domain required for discover action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = await discoverNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
}
|
||||
|
||||
case 'gossip': {
|
||||
if (domain) {
|
||||
const result = await gossipToNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
} else {
|
||||
const result = await runGossipRound();
|
||||
return NextResponse.json({ action, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
case 'addSeed': {
|
||||
if (!domain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Domain required for addSeed action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await addSeedNode(domain, priority);
|
||||
return NextResponse.json({ action, domain, success: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Swarm nodes POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to execute swarm action' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Swarm Post Endpoint
|
||||
*
|
||||
* GET: Returns a single post for swarm requests
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]
|
||||
*
|
||||
* Returns a single post with author info.
|
||||
* Used by other nodes to fetch post details.
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { id: postId } = await context.params;
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Find the post with author
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get media for the post
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, postId));
|
||||
|
||||
const author = post.author as {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
isNsfw: boolean;
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: author.handle,
|
||||
displayName: author.displayName || author.handle,
|
||||
avatarUrl: author.avatarUrl || undefined,
|
||||
isNsfw: author.isNsfw,
|
||||
},
|
||||
nodeDomain,
|
||||
isNsfw: post.isNsfw || author.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
replyToId: post.replyToId || undefined,
|
||||
repostOfId: post.repostOfId || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm post error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Swarm Replies Endpoint
|
||||
*
|
||||
* POST: Receive a reply from another node
|
||||
* GET: Fetch replies to a post on this node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for incoming swarm reply
|
||||
const swarmReplySchema = z.object({
|
||||
postId: z.string().uuid(), // The local post being replied to
|
||||
reply: z.object({
|
||||
id: z.string(), // Original reply ID on the sender's node
|
||||
content: z.string(),
|
||||
createdAt: z.string(),
|
||||
author: z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
}),
|
||||
nodeDomain: z.string(),
|
||||
mediaUrls: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/replies
|
||||
*
|
||||
* Receives a reply from another node in the swarm.
|
||||
* The reply is stored as a remote reply linked to the local post.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = swarmReplySchema.parse(body);
|
||||
|
||||
// Verify the target post exists on this node
|
||||
const targetPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
});
|
||||
|
||||
if (!targetPost) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if we already have this reply (by swarm ID)
|
||||
const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`;
|
||||
const existingReply = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, swarmReplyId),
|
||||
});
|
||||
|
||||
if (existingReply) {
|
||||
return NextResponse.json({ success: true, message: 'Reply already exists' });
|
||||
}
|
||||
|
||||
// We need a system user to attribute swarm replies to
|
||||
// For now, we'll store them with metadata in the apId/apUrl fields
|
||||
// and create a virtual representation
|
||||
|
||||
// Get or create a placeholder user for this remote author
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
// Create a placeholder user for the remote author
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`,
|
||||
handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`,
|
||||
displayName: data.reply.author.displayName,
|
||||
avatarUrl: data.reply.author.avatarUrl || null,
|
||||
publicKey: 'swarm-remote-user', // Placeholder
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create the reply post
|
||||
const [replyPost] = await db.insert(posts).values({
|
||||
userId: remoteUser.id,
|
||||
content: data.reply.content,
|
||||
replyToId: data.postId,
|
||||
apId: swarmReplyId,
|
||||
apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`,
|
||||
createdAt: new Date(data.reply.createdAt),
|
||||
}).returning();
|
||||
|
||||
// Update the parent post's reply count
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: targetPost.repliesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
replyId: replyPost.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Reply error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/swarm/replies?postId=xxx
|
||||
*
|
||||
* Returns replies to a specific post on this node.
|
||||
* Used by other nodes to fetch reply threads.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ replies: [] });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const postId = searchParams.get('postId');
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: 'postId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Get replies to this post
|
||||
const replies = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(posts.replyToId, postId),
|
||||
eq(posts.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(50);
|
||||
|
||||
// Format replies for swarm consumption
|
||||
const formattedReplies = replies.map(reply => ({
|
||||
id: reply.id,
|
||||
content: reply.content,
|
||||
createdAt: reply.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: reply.authorHandle.includes('@')
|
||||
? reply.authorHandle.split('@')[0]
|
||||
: reply.authorHandle,
|
||||
displayName: reply.authorDisplayName || reply.authorHandle,
|
||||
avatarUrl: reply.authorAvatarUrl || undefined,
|
||||
},
|
||||
nodeDomain: reply.authorHandle.includes('@')
|
||||
? reply.authorHandle.split('@')[1]
|
||||
: nodeDomain,
|
||||
likeCount: reply.likesCount,
|
||||
repostCount: reply.repostsCount,
|
||||
replyCount: reply.repliesCount,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
postId,
|
||||
replies: formattedReplies,
|
||||
nodeDomain,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Swarm] Fetch replies error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch replies' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Swarm Timeline Endpoint
|
||||
*
|
||||
* GET: Returns recent public posts from this node for the swarm timeline
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isNsfw: boolean;
|
||||
};
|
||||
nodeDomain: string;
|
||||
nodeIsNsfw: boolean;
|
||||
isNsfw: boolean;
|
||||
likeCount: number;
|
||||
repostCount: number;
|
||||
replyCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
// Link preview
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/swarm/timeline
|
||||
*
|
||||
* Returns recent public posts from this node.
|
||||
* Used by other nodes to build the swarm-wide timeline.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Get node NSFW status
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, nodeDomain),
|
||||
});
|
||||
const nodeIsNsfw = node?.isNsfw ?? false;
|
||||
|
||||
// Get recent public posts (not replies, local users only, not removed)
|
||||
const recentPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
authorIsNsfw: users.isNsfw,
|
||||
authorNodeId: users.nodeId,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
eq(posts.isRemoved, false) // Not removed
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmPost[] = [];
|
||||
|
||||
for (const post of recentPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: post.authorHandle,
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
avatarUrl: post.authorAvatarUrl || undefined,
|
||||
isNsfw: post.authorIsNsfw,
|
||||
},
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
|
||||
likeCount: post.likesCount,
|
||||
repostCount: post.repostsCount,
|
||||
replyCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm timeline error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch timeline' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Swarm User Profile Endpoint
|
||||
*
|
||||
* GET: Returns a user's profile and posts for swarm requests
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmUserProfile {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
headerUrl?: string;
|
||||
website?: string;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
createdAt: string;
|
||||
isBot?: boolean;
|
||||
nodeDomain: string;
|
||||
}
|
||||
|
||||
export interface SwarmUserPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isNsfw: boolean;
|
||||
likesCount: number;
|
||||
repostsCount: number;
|
||||
repliesCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/swarm/users/[handle]
|
||||
*
|
||||
* Returns a user's profile and recent posts.
|
||||
* Used by other nodes to display remote user profiles.
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build profile response
|
||||
const profile: SwarmUserProfile = {
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
bio: user.bio || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
headerUrl: user.headerUrl || undefined,
|
||||
website: user.website || undefined,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
isBot: user.isBot || undefined,
|
||||
nodeDomain,
|
||||
};
|
||||
|
||||
// Get user's recent posts
|
||||
const userPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
})
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmUserPost[] = [];
|
||||
|
||||
for (const post of userPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isNsfw: post.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
profile,
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm user profile error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch user profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user