feat(swarm): Add federated followers and following endpoints
- Create new swarm endpoints for fetching user followers list at /api/swarm/users/[handle]/followers - Create new swarm endpoints for fetching user following list at /api/swarm/users/[handle]/following - Add SwarmFollowerUser and SwarmFollowingUser interfaces for federated user data - Implement local and remote follower/following aggregation in swarm endpoints - Update local followers endpoint to fetch data from remote swarm nodes - Update local following endpoint to fetch data from remote swarm nodes - Add error handling and database availability checks for all endpoints - Include node domain and timestamp metadata in swarm responses - Support pagination with configurable limit parameter (max 100) - Enable cross-node follower/following visibility for federated social graph
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Swarm User Followers Endpoint
|
||||||
|
*
|
||||||
|
* GET: Returns a user's followers list for swarm requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, follows, users, remoteFollowers } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface SwarmFollowerUser {
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bio?: string;
|
||||||
|
isBot?: boolean;
|
||||||
|
isRemote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/swarm/users/[handle]/followers
|
||||||
|
*
|
||||||
|
* Returns a user's followers list.
|
||||||
|
* Used by other nodes to display who follows a remote user.
|
||||||
|
*/
|
||||||
|
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') || '50'), 100);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get local followers
|
||||||
|
const userFollowers = await db
|
||||||
|
.select({
|
||||||
|
id: follows.id,
|
||||||
|
follower: users,
|
||||||
|
})
|
||||||
|
.from(follows)
|
||||||
|
.innerJoin(users, eq(follows.followerId, users.id))
|
||||||
|
.where(eq(follows.followingId, user.id))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const localFollowers: SwarmFollowerUser[] = userFollowers.map(f => ({
|
||||||
|
handle: f.follower.handle, // Local handle without domain
|
||||||
|
displayName: f.follower.displayName || f.follower.handle,
|
||||||
|
avatarUrl: f.follower.avatarUrl || undefined,
|
||||||
|
bio: f.follower.bio || undefined,
|
||||||
|
isBot: f.follower.isBot || undefined,
|
||||||
|
isRemote: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get remote followers
|
||||||
|
const userRemoteFollowers = await db.query.remoteFollowers.findMany({
|
||||||
|
where: eq(remoteFollowers.userId, user.id),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteFollowersList: SwarmFollowerUser[] = userRemoteFollowers.map(f => ({
|
||||||
|
handle: f.handle || 'unknown', // Remote handle with @domain
|
||||||
|
displayName: f.handle?.split('@')[0] || 'Unknown',
|
||||||
|
avatarUrl: undefined,
|
||||||
|
bio: undefined,
|
||||||
|
isRemote: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge all followers
|
||||||
|
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
followers: allFollowers,
|
||||||
|
nodeDomain,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm user followers error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch followers list' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Swarm User Following Endpoint
|
||||||
|
*
|
||||||
|
* GET: Returns a user's following list for swarm requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, follows, users, remoteFollows } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface SwarmFollowingUser {
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bio?: string;
|
||||||
|
isBot?: boolean;
|
||||||
|
isRemote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/swarm/users/[handle]/following
|
||||||
|
*
|
||||||
|
* Returns a user's following list.
|
||||||
|
* Used by other nodes to display who a remote user follows.
|
||||||
|
*/
|
||||||
|
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') || '50'), 100);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get local following
|
||||||
|
const userFollowing = await db
|
||||||
|
.select({
|
||||||
|
id: follows.id,
|
||||||
|
following: users,
|
||||||
|
})
|
||||||
|
.from(follows)
|
||||||
|
.innerJoin(users, eq(follows.followingId, users.id))
|
||||||
|
.where(eq(follows.followerId, user.id))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const localFollowing: SwarmFollowingUser[] = userFollowing.map(f => ({
|
||||||
|
handle: f.following.handle, // Local handle without domain
|
||||||
|
displayName: f.following.displayName || f.following.handle,
|
||||||
|
avatarUrl: f.following.avatarUrl || undefined,
|
||||||
|
bio: f.following.bio || undefined,
|
||||||
|
isBot: f.following.isBot || undefined,
|
||||||
|
isRemote: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get remote following
|
||||||
|
const userRemoteFollowing = await db.query.remoteFollows.findMany({
|
||||||
|
where: eq(remoteFollows.followerId, user.id),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteFollowing: SwarmFollowingUser[] = userRemoteFollowing.map(f => ({
|
||||||
|
handle: f.targetHandle, // Already includes @domain
|
||||||
|
displayName: f.displayName || f.targetHandle.split('@')[0],
|
||||||
|
avatarUrl: f.avatarUrl || undefined,
|
||||||
|
bio: f.bio || undefined,
|
||||||
|
isRemote: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge all following
|
||||||
|
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
following: allFollowing,
|
||||||
|
nodeDomain,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm user following error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch following list' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,24 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch followers list from a remote swarm node
|
||||||
|
*/
|
||||||
|
const fetchSwarmFollowers = async (handle: string, domain: string, limit: number) => {
|
||||||
|
try {
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const url = `${protocol}://${domain}/api/swarm/users/${handle}/followers?limit=${limit}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
@@ -11,6 +29,29 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
// Check if this is a remote user
|
||||||
|
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||||
|
|
||||||
|
if (remoteDomain) {
|
||||||
|
// Fetch from remote swarm node
|
||||||
|
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
|
||||||
|
if (swarmData?.followers) {
|
||||||
|
// Transform to include full handles for local users on that node
|
||||||
|
const followers = swarmData.followers.map((f: any) => ({
|
||||||
|
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||||
|
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||||
|
displayName: f.displayName,
|
||||||
|
avatarUrl: f.avatarUrl,
|
||||||
|
bio: f.bio,
|
||||||
|
isRemote: true,
|
||||||
|
isBot: f.isBot,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ followers, nextCursor: null });
|
||||||
|
}
|
||||||
|
// If swarm fetch fails, return empty
|
||||||
|
return NextResponse.json({ followers: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
// Return empty if no database
|
// Return empty if no database
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ followers: [], nextCursor: null });
|
return NextResponse.json({ followers: [], nextCursor: null });
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch following list from a remote swarm node
|
||||||
|
*/
|
||||||
|
const fetchSwarmFollowing = async (handle: string, domain: string, limit: number) => {
|
||||||
|
try {
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const url = `${protocol}://${domain}/api/swarm/users/${handle}/following?limit=${limit}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
@@ -11,6 +29,29 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
// Check if this is a remote user
|
||||||
|
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||||
|
|
||||||
|
if (remoteDomain) {
|
||||||
|
// Fetch from remote swarm node
|
||||||
|
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
|
||||||
|
if (swarmData?.following) {
|
||||||
|
// Transform to include full handles for local users on that node
|
||||||
|
const following = swarmData.following.map((f: any) => ({
|
||||||
|
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||||
|
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||||
|
displayName: f.displayName,
|
||||||
|
avatarUrl: f.avatarUrl,
|
||||||
|
bio: f.bio,
|
||||||
|
isRemote: true,
|
||||||
|
isBot: f.isBot,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ following, nextCursor: null });
|
||||||
|
}
|
||||||
|
// If swarm fetch fails, return empty (could add ActivityPub fallback later)
|
||||||
|
return NextResponse.json({ following: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
// Return empty if no database
|
// Return empty if no database
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ following: [], nextCursor: null });
|
return NextResponse.json({ following: [], nextCursor: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user