This commit is contained in:
AskIt
2026-01-26 06:07:38 +01:00
parent 2372f5c054
commit 599225a17d
3 changed files with 327 additions and 0 deletions
+21
View File
@@ -161,6 +161,27 @@ export async function POST(request: Request) {
// Federate the post to remote followers (non-blocking) // Federate the post to remote followers (non-blocking)
(async () => { (async () => {
try { try {
// SWARM-FIRST: Deliver to swarm followers directly
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
const swarmResult = await deliverPostToSwarmFollowers(
user.id,
post,
{
handle: user.handle,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isNsfw: user.isNsfw,
},
attachedMedia,
nodeDomain
);
if (swarmResult.delivered > 0) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
}
// FALLBACK: Deliver to ActivityPub followers
const { createCreateActivity } = await import('@/lib/activitypub/activities'); const { createCreateActivity } = await import('@/lib/activitypub/activities');
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox'); const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
+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 });
}
}
+150
View File
@@ -472,3 +472,153 @@ export async function deliverSwarmMentions(
return { delivered, failed }; return { delivered, failed };
} }
// ============================================
// POST DELIVERY TO SWARM FOLLOWERS
// ============================================
export interface SwarmPostDeliveryPayload {
post: {
id: string;
content: string;
createdAt: string;
isNsfw: boolean;
replyToId?: string;
repostOfId?: string;
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
};
author: {
handle: string;
displayName: string;
avatarUrl?: string;
isNsfw: boolean;
};
nodeDomain: string;
timestamp: string;
}
/**
* Deliver a new post to a swarm node's inbox
* This is used to push posts to followers on other swarm nodes
*/
export async function deliverSwarmPost(
targetDomain: string,
payload: SwarmPostDeliveryPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/inbox', payload);
}
/**
* Get swarm follower domains from remote followers
* Returns domains of swarm nodes that have followers for this user
*/
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
try {
const { db, remoteFollowers } = await import('@/db');
const { eq } = await import('drizzle-orm');
if (!db) return [];
const followers = await db.query.remoteFollowers.findMany({
where: eq(remoteFollowers.userId, userId),
});
// Filter for swarm followers (actorUrl starts with swarm://)
const swarmFollowers = followers.filter(f => f.actorUrl.startsWith('swarm://'));
// Extract unique domains
const domains = swarmFollowers.map(f => {
const match = f.actorUrl.match(/^swarm:\/\/([^\/]+)/);
return match ? match[1] : null;
}).filter((d): d is string => d !== null);
return [...new Set(domains)];
} catch (error) {
console.error('[Swarm] Error getting swarm follower domains:', error);
return [];
}
}
/**
* Deliver a post to all swarm followers
*/
export async function deliverPostToSwarmFollowers(
userId: string,
post: {
id: string;
content: string;
createdAt: Date;
isNsfw: boolean;
replyToId?: string | null;
repostOfId?: string | null;
linkPreviewUrl?: string | null;
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
},
author: {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isNsfw: boolean;
},
media: { url: string; mimeType: string | null; altText: string | null }[],
nodeDomain: string
): Promise<{ delivered: number; failed: number }> {
const swarmDomains = await getSwarmFollowerDomains(userId);
if (swarmDomains.length === 0) {
return { delivered: 0, failed: 0 };
}
const payload: SwarmPostDeliveryPayload = {
post: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isNsfw: post.isNsfw,
replyToId: post.replyToId || undefined,
repostOfId: post.repostOfId || undefined,
media: media.length > 0 ? media.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,
},
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isNsfw: author.isNsfw,
},
nodeDomain,
timestamp: new Date().toISOString(),
};
let delivered = 0;
let failed = 0;
// Deliver to all swarm domains in parallel
const results = await Promise.allSettled(
swarmDomains.map(domain => deliverSwarmPost(domain, payload))
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) {
delivered++;
} else {
failed++;
}
}
return { delivered, failed };
}