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
+53 -17
View File
@@ -1,9 +1,12 @@
import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -13,6 +16,25 @@ const postIdSchema = z.union([
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
]);
function isSignedActionPayload(payload: unknown): payload is SignedAction {
if (!payload || typeof payload !== 'object') return false;
const value = payload as Record<string, unknown>;
return typeof value.action === 'string'
&& typeof value.did === 'string'
&& typeof value.handle === 'string'
&& typeof value.ts === 'number'
&& typeof value.nonce === 'string'
&& typeof value.sig === 'string'
&& typeof value.data === 'object'
&& value.data !== null;
}
async function readOptionalJson(request: Request) {
const rawBody = await request.text();
if (!rawBody.trim()) return null;
return JSON.parse(rawBody);
}
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
@@ -71,6 +93,9 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
@@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
// Parse the signed action from the request body
const signedAction: SignedAction = await request.json();
const body = await readOptionalJson(request);
const { id: paramId } = await context.params;
const decodedParamId = decodeURIComponent(paramId);
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
const user = isSignedActionPayload(body)
? await requireSignedAction(body)
: await requireAuth();
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const decodedId = decodeURIComponent(rawId);
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
}
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
@@ -183,6 +212,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(posts.id, postId));
if (post.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: post.userId,
@@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: post.content?.slice(0, 200) || null,
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'like',
});
// Also notify bot owner if this is a bot's post
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(postAuthor),
type: 'like',
});
}
@@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) {
// Unlike a post
export async function DELETE(request: Request, context: RouteContext) {
try {
// Parse the signed action from the request body
const signedAction: SignedAction = await request.json();
const body = await readOptionalJson(request);
const { id: paramId } = await context.params;
const decodedParamId = decodeURIComponent(paramId);
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
const user = isSignedActionPayload(body)
? await requireSignedAction(body)
: await requireAuth();
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const decodedId = decodeURIComponent(rawId);
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
}
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+105 -4
View File
@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { db, posts, users, notifications, userSwarmReposts } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null {
return apId.substring(lastColonIndex + 1);
}
async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return null;
}
const data = await res.json();
const post = data.post;
if (!post) {
return null;
}
return {
authorHandle: post.author?.handle || 'unknown',
authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown',
authorAvatarUrl: post.author?.avatarUrl || null,
content: post.content || '',
postCreatedAt: new Date(post.createdAt || new Date().toISOString()),
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
linkPreviewUrl: post.linkPreviewUrl || null,
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
return null;
}
}
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
});
if (existingRepost) {
return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
}
// Deliver repost directly to the origin node
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
@@ -83,6 +138,27 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 });
}
const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId);
if (snapshot) {
await db.insert(userSwarmReposts).values({
userId: user.id,
nodeDomain: targetDomain,
originalPostId,
...snapshot,
repostedAt: new Date(),
}).onConflictDoUpdate({
target: [userSwarmReposts.userId, userSwarmReposts.nodeDomain, userSwarmReposts.originalPostId],
set: {
...snapshot,
repostedAt: new Date(),
},
});
}
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: true });
}
@@ -133,6 +209,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(users.id, user.id));
if (originalPost.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, originalPost.userId),
});
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: originalPost.userId,
@@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: originalPost.content?.slice(0, 200) || null,
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'repost',
});
// Also notify bot owner if this is a bot's post
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, originalPost.userId),
});
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: originalPost.content?.slice(0, 200) || null,
...buildNotificationTarget(postAuthor),
type: 'repost',
});
}
@@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
});
if (!existingRepost) {
return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
}
// Deliver unrepost directly to the origin node
const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions');
@@ -254,6 +345,16 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 });
}
await db.delete(userSwarmReposts).where(and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
));
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.where(eq(users.id, user.id));
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: false });
}
+87 -62
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
// Schema for local post ID (UUID)
const localPostIdSchema = z.string().uuid('Invalid post ID format');
@@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex(
// Combined schema that accepts either format
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const postDetailRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
function mapSwarmDetailPost(post: any, fallbackDomain: string): any {
const effectiveDomain = post.nodeDomain || fallbackDomain;
const rawId = post.originalPostId || post.id;
return {
id: post.id?.startsWith('swarm:') ? post.id : `swarm:${effectiveDomain}:${rawId}`,
originalPostId: rawId,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
isSwarm: true,
nodeDomain: effectiveDomain,
repostOfId: post.repostOf
? (post.repostOf.id?.startsWith('swarm:')
? post.repostOf.id
: `swarm:${post.repostOf.nodeDomain || effectiveDomain}:${post.repostOf.originalPostId || post.repostOf.id}`)
: (post.repostOfId ? `swarm:${effectiveDomain}:${post.repostOfId}` : null),
repostOf: post.repostOf ? mapSwarmDetailPost(post.repostOf, post.repostOf.nodeDomain || effectiveDomain) : null,
author: post.author ? {
id: `swarm:${effectiveDomain}:${post.author.handle}`,
handle: post.author.handle.includes('@') ? post.author.handle : `${post.author.handle}@${effectiveDomain}`,
displayName: post.author.displayName,
avatarUrl: post.author.avatarUrl,
isSwarm: true,
nodeDomain: effectiveDomain,
} : null,
media: post.media?.map((m: any, idx: number) => ({
id: m.id || `swarm:${effectiveDomain}:${rawId}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
linkPreviewUrl: post.linkPreviewUrl,
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMedia: post.linkPreviewMedia || null,
};
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -47,67 +111,25 @@ export async function GET(
if (res.ok) {
const data = await res.json();
// Transform to match expected format
mainPost = {
id: id,
originalPostId: originalPostId,
content: data.post.content,
createdAt: data.post.createdAt,
likesCount: data.post.likesCount || 0,
repostsCount: data.post.repostsCount || 0,
repliesCount: data.post.repliesCount || 0,
isSwarm: true,
mainPost = mapSwarmDetailPost({
...data.post,
id,
originalPostId,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${data.post.author.handle}`,
handle: data.post.author.handle,
displayName: data.post.author.displayName,
avatarUrl: data.post.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: data.post.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${originalPostId}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
linkPreviewUrl: data.post.linkPreviewUrl,
linkPreviewTitle: data.post.linkPreviewTitle,
linkPreviewDescription: data.post.linkPreviewDescription,
linkPreviewImage: data.post.linkPreviewImage,
};
}, originDomain);
// Transform replies from the origin node
replyPosts = (data.replies || []).map((r: any) => ({
id: `swarm:${originDomain}:${r.id}`,
originalPostId: r.id,
content: r.content,
createdAt: r.createdAt,
likesCount: r.likesCount || 0,
repostsCount: r.repostsCount || 0,
repliesCount: r.repliesCount || 0,
isSwarm: true,
replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({
...reply,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${r.author.handle}`,
handle: r.author.handle,
displayName: r.author.displayName,
avatarUrl: r.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: r.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${r.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
}));
}, originDomain));
mainPost.repliesCount = replyPosts.length;
// Check if current user has liked this post
try {
const { requireAuth } = await import('@/lib/auth');
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const viewer = await requireAuth();
const likeCheckRes = await fetch(
@@ -119,6 +141,15 @@ export async function GET(
const likeData = await likeCheckRes.json();
mainPost.isLiked = likeData.isLiked;
}
const repostedIds = await getViewerSwarmRepostedPostIds([
{
id,
nodeDomain: originDomain,
originalPostId,
},
], viewer.id);
mainPost.isReposted = repostedIds.has(id);
} catch {
// Not logged in or timeout
}
@@ -138,13 +169,7 @@ export async function GET(
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
author: true,
media: true,
replyTo: {
with: { author: true },
},
},
with: postDetailRelations,
});
if (post) {
@@ -155,10 +180,7 @@ export async function GET(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
with: postDetailRelations,
orderBy: [desc(posts.createdAt)],
});
@@ -235,6 +257,9 @@ export async function GET(
linkPreviewTitle: cached.linkPreviewTitle,
linkPreviewDescription: cached.linkPreviewDescription,
linkPreviewImage: cached.linkPreviewImage,
linkPreviewType: cached.linkPreviewType,
linkPreviewVideoUrl: cached.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null,
isLiked: false,
isReposted: false,
};