Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
+94
View File
@@ -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 }
);
}
}
+86
View File
@@ -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 }
);
}
}
+156
View File
@@ -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 });
}
}
+39
View File
@@ -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 });
}
}
+143
View File
@@ -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 }
);
}
}
+90
View File
@@ -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 }
);
}
}
+189
View File
@@ -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 });
}
}
+139
View File
@@ -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 }
);
}
}
+158
View File
@@ -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 }
);
}
}