Add docker publish flow, node blocklist & API updates

Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
cyph3rasi
2026-03-10 12:40:05 -07:00
parent 044196cfa7
commit b4a9d82d05
79 changed files with 4714 additions and 1114 deletions
@@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -73,10 +74,14 @@ export async function DELETE(
if (isRemote) {
// Extract domain from handle (format: handle@domain)
const domain = participant2Handle.split('@')[1];
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
if (await isNodeBlocked(domain)) {
return NextResponse.json({ success: true });
}
const protocol = domain.includes('localhost') ? 'http' : 'https';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
+8 -2
View File
@@ -12,6 +12,7 @@ import { db, users, chatConversations } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const deletionSchema = z.object({
senderHandle: z.string(),
@@ -30,6 +31,11 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const data = deletionSchema.parse(body);
const senderNodeDomain = normalizeNodeDomain(data.senderNodeDomain);
if (await isNodeBlocked(senderNodeDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
// SECURITY: Verify the signature
const { signature, ...payload } = data;
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
payload,
signature,
data.senderHandle,
data.senderNodeDomain
senderNodeDomain
);
if (!isValid) {
@@ -55,7 +61,7 @@ export async function POST(request: NextRequest) {
}
// Find the conversation with the sender
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
+8
View File
@@ -29,6 +29,14 @@ const swarmPostSchema = z.object({
linkPreviewTitle: z.string().optional(),
linkPreviewDescription: z.string().optional(),
linkPreviewImage: z.string().optional(),
linkPreviewType: z.enum(['card', 'image', 'gallery', 'video']).optional(),
linkPreviewVideoUrl: z.string().optional(),
linkPreviewMedia: z.array(z.object({
url: z.string(),
width: z.number().nullable().optional(),
height: z.number().nullable().optional(),
mimeType: z.string().nullable().optional(),
})).optional(),
}),
author: z.object({
handle: z.string(),
@@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmFollowSchema = z.object({
targetHandle: localHandleSchema,
@@ -107,6 +108,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
type: 'follow',
});
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
@@ -125,6 +127,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
...buildNotificationTarget(targetUser),
type: 'follow',
});
} catch (err) {
+5 -1
View File
@@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
@@ -88,6 +89,8 @@ export async function POST(request: NextRequest) {
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, data.postId));
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'like',
});
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
@@ -108,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -119,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(author as any),
type: 'like',
});
} catch (err) {
@@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmMentionSchema = z.object({
mentionedHandle: localHandleSchema,
@@ -73,6 +74,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
@@ -90,6 +92,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
...buildNotificationTarget(mentionedUser),
type: 'mention',
});
} catch (err) {
+28 -3
View File
@@ -7,11 +7,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { db, posts, users, notifications, remoteReposts } from '@/db';
import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
@@ -64,11 +65,34 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.repost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain),
),
});
if (existingRepost) {
return NextResponse.json({
success: true,
message: 'Repost already recorded',
});
}
// Increment repost count
await db.update(posts)
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, data.postId));
await db.insert(remoteReposts).values({
postId: data.postId,
actorHandle: data.repost.actorHandle,
actorNodeDomain: data.repost.actorNodeDomain,
});
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -79,6 +103,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'repost',
});
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
@@ -87,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -98,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(author as any),
type: 'repost',
});
} catch (err) {
@@ -7,8 +7,8 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { db, posts, remoteReposts } from '@/db';
import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
@@ -60,11 +60,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.unrepost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain),
),
});
if (!existingRepost) {
return NextResponse.json({
success: true,
message: 'Repost already removed',
});
}
// Decrement repost count
await db.update(posts)
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, data.postId));
await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id));
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
return NextResponse.json({
+68 -2
View File
@@ -5,9 +5,10 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { db, posts, userSwarmReposts, users } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) {
});
if (!post || post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
const remoteRepost = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, postId),
});
if (!remoteRepost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const repostAuthor = await db.query.users.findFirst({
where: eq(users.id, remoteRepost.userId),
});
return NextResponse.json({
post: {
id: remoteRepost.id,
apId: null,
content: '',
createdAt: remoteRepost.repostedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: repostAuthor ? {
handle: repostAuthor.handle,
displayName: repostAuthor.displayName,
avatarUrl: repostAuthor.avatarUrl,
} : null,
media: [],
repostOfId: remoteRepost.originalPostId,
repostOf: {
id: remoteRepost.originalPostId,
originalPostId: remoteRepost.originalPostId,
content: remoteRepost.content,
createdAt: remoteRepost.postCreatedAt.toISOString(),
likesCount: remoteRepost.likesCount,
repostsCount: remoteRepost.repostsCount,
repliesCount: remoteRepost.repliesCount,
nodeDomain: remoteRepost.nodeDomain,
author: {
handle: remoteRepost.authorHandle.includes('@')
? remoteRepost.authorHandle
: `${remoteRepost.authorHandle}@${remoteRepost.nodeDomain}`,
displayName: remoteRepost.authorDisplayName,
avatarUrl: remoteRepost.authorAvatarUrl,
},
media: remoteRepost.mediaJson ? JSON.parse(remoteRepost.mediaJson) : [],
linkPreviewUrl: remoteRepost.linkPreviewUrl,
linkPreviewTitle: remoteRepost.linkPreviewTitle,
linkPreviewDescription: remoteRepost.linkPreviewDescription,
linkPreviewImage: remoteRepost.linkPreviewImage,
linkPreviewType: remoteRepost.linkPreviewType,
linkPreviewVideoUrl: remoteRepost.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(remoteRepost.linkPreviewMediaJson) || [],
},
},
replies: [],
});
}
// Get replies
@@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
linkPreviewType: post.linkPreviewType,
linkPreviewVideoUrl: post.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [],
},
replies: replies.map(r => {
const replyAuthor = r.author as any;
@@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) {
url: m.url,
altText: m.altText,
})) || [],
linkPreviewUrl: r.linkPreviewUrl,
linkPreviewTitle: r.linkPreviewTitle,
linkPreviewDescription: r.linkPreviewDescription,
linkPreviewImage: r.linkPreviewImage,
linkPreviewType: r.linkPreviewType,
linkPreviewVideoUrl: r.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(r.linkPreviewMediaJson) || [],
};
}),
});
+18
View File
@@ -11,6 +11,7 @@ import { eq, desc, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
import { buildNotificationTarget } from '@/lib/notifications';
// Schema for incoming swarm reply
const swarmReplySchema = z.object({
@@ -145,6 +146,8 @@ export async function POST(request: NextRequest) {
await syncParentReplyCount(data.postId);
const parentAuthor = parentPost.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
if (parentPost.userId !== remoteUser.id) {
await db.insert(notifications).values({
userId: parentPost.userId,
@@ -154,8 +157,23 @@ export async function POST(request: NextRequest) {
actorNodeDomain: sourceDomain,
postId: data.postId,
postContent: data.reply.content.slice(0, 200),
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}),
type: 'reply',
});
if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: parentAuthor.botOwnerId,
actorHandle: data.reply.author.handle,
actorDisplayName: data.reply.author.displayName || data.reply.author.handle,
actorAvatarUrl: data.reply.author.avatarUrl || null,
actorNodeDomain: sourceDomain,
postId: data.postId,
postContent: data.reply.content.slice(0, 200),
...buildNotificationTarget(parentAuthor as any),
type: 'reply',
});
}
}
return NextResponse.json({ success: true, message: 'Reply received' });
+149 -37
View File
@@ -6,7 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and, isNull, lt, sql } from 'drizzle-orm';
import { eq, desc, and, isNull, lt, sql, inArray } from 'drizzle-orm';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
export interface SwarmPost {
id: string;
@@ -15,6 +16,8 @@ export interface SwarmPost {
isReply?: boolean;
replyToId?: string | null;
swarmReplyToId?: string | null;
repostOfId?: string | null;
repostOf?: SwarmPost | null;
author: {
handle: string;
displayName: string;
@@ -34,6 +37,74 @@ export interface SwarmPost {
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
linkPreviewVideoUrl?: string;
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
}
interface TimelinePostRow {
id: string;
content: string;
createdAt: Date;
replyToId: string | null;
swarmReplyToId: string | null;
repostOfId: string | null;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
linkPreviewUrl: string | null;
linkPreviewTitle: string | null;
linkPreviewDescription: string | null;
linkPreviewImage: string | null;
linkPreviewType: string | null;
linkPreviewVideoUrl: string | null;
linkPreviewMediaJson: string | null;
authorHandle: string;
authorDisplayName: string | null;
authorAvatarUrl: string | null;
authorIsNsfw: boolean;
authorIsBot: boolean | null;
}
function buildSwarmPost(
post: TimelinePostRow,
mediaByPostId: Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>,
repostById: Map<string, SwarmPost>,
nodeDomain: string,
nodeIsNsfw: boolean
): SwarmPost {
return {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isReply: Boolean(post.replyToId || post.swarmReplyToId),
replyToId: post.replyToId,
swarmReplyToId: post.swarmReplyToId,
repostOfId: post.repostOfId,
repostOf: post.repostOfId ? repostById.get(post.repostOfId) || null : null,
author: {
handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot || undefined,
},
nodeDomain,
nodeIsNsfw,
isNsfw: post.isNsfw || post.authorIsNsfw,
likeCount: post.likesCount,
repostCount: post.repostsCount,
replyCount: post.repliesCount,
media: mediaByPostId.get(post.id),
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
linkPreviewType: (post.linkPreviewType as SwarmPost['linkPreviewType']) || undefined,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
};
}
/**
@@ -89,6 +160,7 @@ export async function GET(request: NextRequest) {
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
repostOfId: posts.repostOfId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
@@ -97,6 +169,9 @@ export async function GET(request: NextRequest) {
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
linkPreviewType: posts.linkPreviewType,
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
linkPreviewMediaJson: posts.linkPreviewMediaJson,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
@@ -112,47 +187,84 @@ export async function GET(request: NextRequest) {
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
// Fetch media for each post
const swarmPosts: SwarmPost[] = [];
const repostIds = Array.from(new Set(
recentPosts
.map(post => post.repostOfId)
.filter((id): id is string => Boolean(id))
));
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));
const repostTargets = repostIds.length > 0
? await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
repostOfId: posts.repostOfId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
linkPreviewUrl: posts.linkPreviewUrl,
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
linkPreviewType: posts.linkPreviewType,
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
linkPreviewMediaJson: posts.linkPreviewMediaJson,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
authorIsNsfw: users.isNsfw,
authorIsBot: users.isBot,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(and(
inArray(posts.id, repostIds),
eq(posts.isRemoved, false),
))
: [];
swarmPosts.push({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isReply: Boolean(post.replyToId || post.swarmReplyToId),
replyToId: post.replyToId,
swarmReplyToId: post.swarmReplyToId,
author: {
handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot,
},
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,
const mediaPostIds = Array.from(new Set([
...recentPosts.map(post => post.id),
...repostTargets.map(post => post.id),
]));
const mediaRows = mediaPostIds.length > 0
? await db
.select({
postId: media.postId,
url: media.url,
mimeType: media.mimeType,
altText: media.altText,
})
.from(media)
.where(inArray(media.postId, mediaPostIds))
: [];
const mediaByPostId = new Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>();
for (const item of mediaRows) {
if (!item.postId) continue;
const bucket = mediaByPostId.get(item.postId) || [];
bucket.push({
url: item.url,
mimeType: item.mimeType || undefined,
altText: item.altText || undefined,
});
mediaByPostId.set(item.postId, bucket);
}
const repostById = new Map<string, SwarmPost>();
for (const post of repostTargets) {
repostById.set(post.id, buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw));
}
const swarmPosts = recentPosts.map(post =>
buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw)
);
return NextResponse.json({
posts: swarmPosts,
nodeDomain,
+147 -54
View File
@@ -5,8 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { db, posts, users, userSwarmReposts } from '@/db';
import { eq, desc, and, isNull } from 'drizzle-orm';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
export interface SwarmUserProfile {
handle: string;
@@ -28,21 +29,144 @@ export interface SwarmUserProfile {
export interface SwarmUserPost {
id: string;
originalPostId?: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
nodeDomain?: string;
author?: {
handle: string;
displayName?: string;
avatarUrl?: string;
isBot?: boolean;
nodeDomain?: string;
};
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
linkPreviewVideoUrl?: string;
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
repostOfId?: string;
repostOf?: SwarmUserPost | null;
}
type RouteContext = { params: Promise<{ handle: string }> };
const profilePostRelations = {
author: true,
media: true,
repostOf: {
with: {
author: true,
media: true,
},
},
} as const;
function parseMediaJson(mediaJson: string | null) {
if (!mediaJson) {
return [];
}
try {
return JSON.parse(mediaJson);
} catch {
return [];
}
}
function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
return {
id: post.id,
originalPostId: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isNsfw: post.isNsfw,
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
nodeDomain,
author: post.author ? {
handle: post.author.handle,
displayName: post.author.displayName || post.author.handle,
avatarUrl: post.author.avatarUrl || undefined,
isBot: post.author.isBot || undefined,
nodeDomain,
} : undefined,
media: (post.media || []).map((item: any) => ({
url: item.url,
mimeType: item.mimeType || undefined,
altText: item.altText || undefined,
})),
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
linkPreviewType: post.linkPreviewType || undefined,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
repostOfId: post.repostOfId || undefined,
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
};
}
function mapUserSwarmRepostToSwarmPost(
row: typeof userSwarmReposts.$inferSelect,
author: typeof users.$inferSelect,
nodeDomain: string
): SwarmUserPost {
return {
id: row.id,
originalPostId: row.id,
content: '',
createdAt: row.repostedAt.toISOString(),
isNsfw: false,
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
nodeDomain,
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isBot: author.isBot || undefined,
nodeDomain,
},
repostOfId: row.originalPostId,
repostOf: {
id: row.originalPostId,
originalPostId: row.originalPostId,
content: row.content,
createdAt: row.postCreatedAt.toISOString(),
isNsfw: false,
likesCount: row.likesCount,
repostsCount: row.repostsCount,
repliesCount: row.repliesCount,
nodeDomain: row.nodeDomain,
author: {
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
displayName: row.authorDisplayName || row.authorHandle,
avatarUrl: row.authorAvatarUrl || undefined,
nodeDomain: row.nodeDomain,
},
media: parseMediaJson(row.mediaJson),
linkPreviewUrl: row.linkPreviewUrl || undefined,
linkPreviewTitle: row.linkPreviewTitle || undefined,
linkPreviewDescription: row.linkPreviewDescription || undefined,
linkPreviewImage: row.linkPreviewImage || undefined,
linkPreviewType: (row.linkPreviewType as SwarmUserPost['linkPreviewType']) || undefined,
linkPreviewVideoUrl: row.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson),
},
};
}
/**
* GET /api/swarm/users/[handle]
*
@@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) {
did: user.did || undefined,
};
// 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),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
)
)
.orderBy(desc(posts.createdAt))
.limit(limit);
const localPosts = await db.query.posts.findMany({
where: and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
),
with: profilePostRelations,
orderBy: [desc(posts.createdAt)],
limit: limit * 2,
});
// Fetch media for each post
const swarmPosts: SwarmUserPost[] = [];
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
where: eq(userSwarmReposts.userId, user.id),
orderBy: [desc(userSwarmReposts.repostedAt)],
limit: limit * 2,
});
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,
});
}
const swarmPosts: SwarmUserPost[] = [
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
return NextResponse.json({
profile,