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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) || [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user