More
This commit is contained in:
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user