Fix reply consistency and source validation

This commit is contained in:
cyph3rasi
2026-03-08 18:29:42 -07:00
parent a8f104ee14
commit 5f21ea8e5d
20 changed files with 545 additions and 276 deletions
+11
View File
@@ -20,6 +20,7 @@ import {
MAX_KEYWORDS,
MAX_KEYWORD_LENGTH,
} from '@/lib/bots/contentSource';
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
type RouteContext = { params: Promise<{ id: string }> };
@@ -115,6 +116,16 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
await validateSourceReachability({
type: data.type,
url: data.url,
subreddit: data.subreddit,
apiKey: data.apiKey,
keywords: data.keywords,
braveNewsConfig: data.braveNewsConfig,
newsApiConfig: data.newsApiConfig,
});
// Add the content source
const source = await addSource(botId, {
type: data.type,
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAuth } from '@/lib/auth';
import { MAX_KEYWORDS, MAX_KEYWORD_LENGTH, SUPPORTED_SOURCE_TYPES, ContentSourceValidationError } from '@/lib/bots/contentSource';
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
const braveNewsConfigSchema = z.object({
query: z.string().min(1, 'Search query is required'),
freshness: z.enum(['pd', 'pw', 'pm', 'py']).optional(),
country: z.string().length(2, 'Country must be a 2-letter ISO code').optional(),
searchLang: z.string().optional(),
count: z.number().min(1).max(50).optional(),
}).optional();
const newsApiConfigSchema = z.object({
provider: z.enum(['newsapi', 'gnews', 'newsdata']),
query: z.string().min(1, 'Search query is required'),
category: z.string().optional(),
country: z.string().optional(),
language: z.string().optional(),
}).optional();
const validateSourceSchema = z.object({
type: z.enum(['rss', 'reddit', 'news_api', 'brave_news', 'youtube'], {
message: `Source type must be one of: ${SUPPORTED_SOURCE_TYPES.join(', ')}`,
}),
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long'),
subreddit: z.string()
.regex(/^[a-zA-Z0-9_]{3,21}$/, 'Subreddit name must be 3-21 characters, alphanumeric and underscores only')
.optional(),
apiKey: z.string().min(10, 'API key is too short').max(256, 'API key is too long').optional(),
keywords: z.array(
z.string()
.min(1, 'Keyword cannot be empty')
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
)
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
.optional(),
braveNewsConfig: braveNewsConfigSchema,
newsApiConfig: newsApiConfigSchema,
}).refine(
(data) => data.type !== 'reddit' || !!data.subreddit,
{ message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] }
).refine(
(data) => !['news_api', 'brave_news'].includes(data.type) || !!data.apiKey,
{ message: 'API key is required for news API sources', path: ['apiKey'] }
).refine(
(data) => data.type !== 'brave_news' || !!data.braveNewsConfig?.query,
{ message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] }
);
export async function POST(request: Request) {
try {
await requireAuth();
const body = await request.json();
const data = validateSourceSchema.parse(body);
await validateSourceReachability({
type: data.type,
url: data.url,
subreddit: data.subreddit,
apiKey: data.apiKey,
keywords: data.keywords,
braveNewsConfig: data.braveNewsConfig,
newsApiConfig: data.newsApiConfig,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof ContentSourceValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code, details: error.errors },
{ status: 400 }
);
}
console.error('Validate content source error:', error);
return NextResponse.json(
{ error: 'Failed to validate content source' },
{ status: 500 }
);
}
}
+7 -54
View File
@@ -103,59 +103,7 @@ export async function GET(
})) || [],
}));
const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`;
const localReplies = await db.query.posts.findMany({
where: and(
eq(posts.swarmReplyToId, localSwarmReplyId),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
if (localReplies.length > 0) {
let likedReplyIds = new Set<string>();
let repostedReplyIds = new Set<string | null>();
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const viewer = await requireAuth();
const localReplyIds = localReplies.map(reply => reply.id);
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localReplyIds)
),
});
likedReplyIds = new Set(viewerLikes.map(like => like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localReplyIds),
eq(posts.isRemoved, false)
),
});
repostedReplyIds = new Set(viewerReposts.map(reply => reply.repostOfId));
} catch {
}
const formattedLocalReplies = localReplies.map((reply: any) => ({
...reply,
isLiked: likedReplyIds.has(reply.id),
isReposted: repostedReplyIds.has(reply.id),
}));
replyPosts = [...formattedLocalReplies, ...replyPosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
mainPost.repliesCount = (mainPost.repliesCount || 0) + localReplies.length;
}
mainPost.repliesCount = replyPosts.length;
// Check if current user has liked this post
try {
@@ -214,6 +162,11 @@ export async function GET(
orderBy: [desc(posts.createdAt)],
});
mainPost = {
...post,
repliesCount: replies.length,
};
let allPostIds = [post.id, ...replies.map(r => r.id)];
try {
@@ -243,7 +196,7 @@ export async function GET(
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
mainPost = {
...post,
...mainPost,
isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id),
} as any;
+82 -86
View File
@@ -86,16 +86,92 @@ export async function POST(request: Request) {
linkPreviewImage: data.linkPreview?.image,
}).returning();
let attachedMedia: typeof media.$inferSelect[] = [];
let unattachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
await db.update(media)
.set({ postId: post.id })
.where(and(
unattachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
));
),
});
}
try {
if (data.swarmReplyTo) {
const protocol = data.swarmReplyTo.nodeDomain.includes('localhost') ? 'http' : 'https';
const targetUrl = `${protocol}://${data.swarmReplyTo.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
did: user.did,
publicKey: user.publicKey,
},
nodeDomain,
mediaUrls: unattachedMedia.map(m => m.url),
},
};
const { createSignedPayload } = await import('@/lib/swarm/signature');
const { payload, signature } = await createSignedPayload(replyPayload);
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': nodeDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(body || `Remote node rejected reply (${response.status})`);
}
}
if (data.mediaIds?.length) {
await db.update(media)
.set({ postId: post.id })
.where(and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
));
}
// Update user's post count (atomic increment to prevent race conditions)
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count (atomic increment)
if (data.replyToId) {
await db.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, data.replyToId));
}
} catch (err) {
await db.delete(posts).where(eq(posts.id, post.id));
console.error('[Swarm] Error creating synchronized reply:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to deliver reply to origin node' },
{ status: 502 }
);
}
let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
attachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
@@ -105,66 +181,6 @@ export async function POST(request: Request) {
});
}
// Update user's post count (atomic increment to prevent race conditions)
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count (atomic increment)
if (data.replyToId) {
await db.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, data.replyToId));
}
if (data.swarmReplyTo) {
(async () => {
try {
const protocol = data.swarmReplyTo!.nodeDomain.includes('localhost') ? 'http' : 'https';
const targetUrl = `${protocol}://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo!.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
did: user.did,
publicKey: user.publicKey,
},
nodeDomain,
mediaUrls: attachedMedia.map(m => m.url),
},
};
const { createSignedPayload } = await import('@/lib/swarm/signature');
const { payload, signature } = await createSignedPayload(replyPayload);
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': nodeDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
});
if (response.ok) {
console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`);
} else {
console.error(`[Swarm] Failed to deliver reply: ${response.status}`);
}
} catch (err) {
console.error('[Swarm] Error delivering reply:', err);
}
})();
}
// Handle local mentions (create notifications for users on this node)
(async () => {
try {
@@ -538,26 +554,6 @@ export async function GET(request: Request) {
includeNsfw,
});
// Transform swarm posts to match local post format
const localSwarmReplyIds = swarmResult.posts.map(sp => `swarm:${sp.nodeDomain}:${sp.id}`);
const localReplyCounts = localSwarmReplyIds.length > 0
? await db.select({
swarmReplyToId: posts.swarmReplyToId,
count: sql<number>`count(*)`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, localSwarmReplyIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId)
: [];
const localReplyCountMap = new Map(
localReplyCounts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, Number(row.count || 0)])
);
const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
@@ -565,7 +561,7 @@ export async function GET(request: Request) {
createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount,
repostsCount: sp.repostCount,
repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0),
repliesCount: sp.replyCount,
isSwarm: true,
nodeDomain: sp.nodeDomain,
author: {
-22
View File
@@ -5,10 +5,8 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
/**
@@ -20,7 +18,6 @@ import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true';
const cursor = searchParams.get('cursor') || undefined;
// Check user's NSFW preference
@@ -34,24 +31,6 @@ export async function GET(request: NextRequest) {
// Fetch swarm timeline (no caching - user preferences vary)
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
const swarmReplyIds = timeline.posts.map(post => `swarm:${post.nodeDomain}:${post.id}`);
const localReplyCounts = db && swarmReplyIds.length > 0
? await db.select({
swarmReplyToId: posts.swarmReplyToId,
count: sql<number>`count(*)::int`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, swarmReplyIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId)
: [];
const localReplyCountMap = new Map(
localReplyCounts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, row.count])
);
const session = await getSession().catch(() => null);
const viewer = session?.user;
@@ -71,7 +50,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
posts: timeline.posts.map(post => ({
...post,
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
})),
sources: timeline.sources,
+1 -1
View File
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
createdAt: post.createdAt.toISOString(),
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
repliesCount: replies.length,
author: {
handle: author.handle,
displayName: author.displayName,
+21 -15
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, notifications } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
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';
@@ -31,6 +31,20 @@ const swarmReplySchema = z.object({
}),
});
async function syncParentReplyCount(postId: string) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(posts)
.where(and(
eq(posts.replyToId, postId),
eq(posts.isRemoved, false)
));
await db.update(posts)
.set({ repliesCount: Number(count || 0) })
.where(eq(posts.id, postId));
}
/**
* POST /api/swarm/replies
*
@@ -129,9 +143,7 @@ export async function POST(request: NextRequest) {
);
}
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.postId));
await syncParentReplyCount(data.postId);
if (parentPost.userId !== remoteUser.id) {
await db.insert(notifications).values({
@@ -183,21 +195,15 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true, message: 'Reply not found or already deleted' });
}
// Decrement parent's reply count
if (existingReply.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, existingReply.replyToId),
});
if (parentPost && parentPost.repliesCount > 0) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount - 1 })
.where(eq(posts.id, existingReply.replyToId));
}
}
const parentReplyToId = existingReply.replyToId;
// Delete the reply
await db.delete(posts).where(eq(posts.id, existingReply.id));
if (parentReplyToId) {
await syncParentReplyCount(parentReplyToId);
}
console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`);
return NextResponse.json({ success: true });
+8
View File
@@ -12,6 +12,9 @@ export interface SwarmPost {
id: string;
content: string;
createdAt: string;
isReply?: boolean;
replyToId?: string | null;
swarmReplyToId?: string | null;
author: {
handle: string;
displayName: string;
@@ -84,6 +87,8 @@ export async function GET(request: NextRequest) {
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
@@ -120,6 +125,9 @@ export async function GET(request: NextRequest) {
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,
+1 -23
View File
@@ -146,28 +146,6 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: profile.avatarUrl,
};
const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`);
let localReplyCounts = new Map<string, number>();
if (swarmPostIds.length > 0) {
const counts = await db.select({
swarmReplyToId: posts.swarmReplyToId,
replyCount: sql<number>`count(*)::int`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, swarmPostIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId);
localReplyCounts = new Map(
counts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, row.replyCount])
);
}
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
originalPostId: post.id,
@@ -175,7 +153,7 @@ export async function GET(request: Request, context: RouteContext) {
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
repliesCount: post.repliesCount || 0,
author,
media: post.media || [],
linkPreviewUrl: post.linkPreviewUrl || null,