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:
@@ -14,6 +14,8 @@
|
||||
|
||||
import { getActiveSwarmNodes } from './registry';
|
||||
import type { SwarmNodeInfo } from './types';
|
||||
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
@@ -141,16 +143,24 @@ export interface SwarmMentionPayload {
|
||||
* Check if a domain is a known Synapsis swarm node
|
||||
*/
|
||||
export async function isSwarmNode(domain: string): Promise<boolean> {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
return false;
|
||||
}
|
||||
const nodes = await getActiveSwarmNodes(500);
|
||||
return nodes.some(n => n.domain === domain);
|
||||
return nodes.some(n => n.domain === normalizedDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get swarm node info if the domain is a swarm node
|
||||
*/
|
||||
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
return null;
|
||||
}
|
||||
const nodes = await getActiveSwarmNodes(500);
|
||||
return nodes.find(n => n.domain === domain) || null;
|
||||
return nodes.find(n => n.domain === normalizedDomain) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,11 +267,19 @@ async function deliverSwarmInteraction(
|
||||
payload: unknown
|
||||
): Promise<SwarmInteractionResponse> {
|
||||
try {
|
||||
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
|
||||
if (await isNodeBlocked(normalizedTargetDomain)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Blocked node: ${normalizedTargetDomain}`,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = targetDomain.startsWith('http')
|
||||
? targetDomain
|
||||
: targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
|
||||
? `http://${targetDomain}`
|
||||
: `https://${targetDomain}`;
|
||||
: normalizedTargetDomain.startsWith('localhost') || normalizedTargetDomain.startsWith('127.0.0.1')
|
||||
? `http://${normalizedTargetDomain}`
|
||||
: `https://${normalizedTargetDomain}`;
|
||||
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
@@ -334,17 +352,31 @@ 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;
|
||||
}
|
||||
|
||||
export interface SwarmProfileResponse {
|
||||
@@ -363,11 +395,16 @@ export async function fetchSwarmUserProfile(
|
||||
postsLimit: number = 25
|
||||
): Promise<SwarmProfileResponse | null> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = domain.startsWith('http')
|
||||
? domain
|
||||
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
|
||||
? `http://${domain}`
|
||||
: `https://${domain}`;
|
||||
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||
? `http://${normalizedDomain}`
|
||||
: `https://${normalizedDomain}`;
|
||||
|
||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
|
||||
|
||||
@@ -449,6 +486,9 @@ export async function cacheSwarmUserPosts(
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -470,11 +510,16 @@ export async function fetchSwarmPost(
|
||||
domain: string
|
||||
): Promise<SwarmUserPost | null> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = domain.startsWith('http')
|
||||
? domain
|
||||
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
|
||||
? `http://${domain}`
|
||||
: `https://${domain}`;
|
||||
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||
? `http://${normalizedDomain}`
|
||||
: `https://${normalizedDomain}`;
|
||||
|
||||
const url = `${baseUrl}/api/swarm/posts/${postId}`;
|
||||
|
||||
@@ -591,6 +636,9 @@ export interface SwarmPostDeliveryPayload {
|
||||
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 }>;
|
||||
};
|
||||
author: {
|
||||
handle: string;
|
||||
@@ -637,7 +685,7 @@ export async function getSwarmFollowerDomains(userId: string): Promise<string[]>
|
||||
return match ? match[1] : null;
|
||||
}).filter((d): d is string => d !== null);
|
||||
|
||||
return [...new Set(domains)];
|
||||
return await filterBlockedDomains([...new Set(domains)]);
|
||||
} catch (error) {
|
||||
console.error('[Swarm] Error getting swarm follower domains:', error);
|
||||
return [];
|
||||
@@ -660,6 +708,9 @@ export async function deliverPostToSwarmFollowers(
|
||||
linkPreviewTitle?: string | null;
|
||||
linkPreviewDescription?: string | null;
|
||||
linkPreviewImage?: string | null;
|
||||
linkPreviewType?: string | null;
|
||||
linkPreviewVideoUrl?: string | null;
|
||||
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }> | null;
|
||||
},
|
||||
author: {
|
||||
handle: string;
|
||||
@@ -693,6 +744,9 @@ export async function deliverPostToSwarmFollowers(
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
linkPreviewType: (post.linkPreviewType as SwarmPostDeliveryPayload['post']['linkPreviewType']) || undefined,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||
linkPreviewMedia: post.linkPreviewMedia || undefined,
|
||||
},
|
||||
author: {
|
||||
handle: author.handle,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { db, swarmNodes } from '@/db';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export function normalizeNodeDomain(domain: string): string {
|
||||
return domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/^@/, '');
|
||||
}
|
||||
|
||||
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
|
||||
if (!db || !domain) return false;
|
||||
|
||||
const normalized = normalizeNodeDomain(domain);
|
||||
if (!normalized) return false;
|
||||
|
||||
const node = await db.query.swarmNodes.findFirst({
|
||||
where: eq(swarmNodes.domain, normalized),
|
||||
columns: {
|
||||
isBlocked: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(node?.isBlocked);
|
||||
}
|
||||
|
||||
export async function getBlockedNodeDomains(): Promise<Set<string>> {
|
||||
if (!db) return new Set();
|
||||
|
||||
const rows = await db.query.swarmNodes.findMany({
|
||||
where: eq(swarmNodes.isBlocked, true),
|
||||
columns: {
|
||||
domain: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new Set(rows.map((row) => row.domain));
|
||||
}
|
||||
|
||||
export async function filterBlockedDomains(domains: string[]): Promise<string[]> {
|
||||
if (!db || domains.length === 0) return domains;
|
||||
|
||||
const normalized = Array.from(new Set(domains.map(normalizeNodeDomain).filter(Boolean)));
|
||||
if (normalized.length === 0) return [];
|
||||
|
||||
const blocked = await db.query.swarmNodes.findMany({
|
||||
where: and(
|
||||
inArray(swarmNodes.domain, normalized),
|
||||
eq(swarmNodes.isBlocked, true),
|
||||
),
|
||||
columns: {
|
||||
domain: true,
|
||||
},
|
||||
});
|
||||
|
||||
const blockedSet = new Set(blocked.map((row) => row.domain));
|
||||
return normalized.filter((domain) => !blockedSet.has(domain));
|
||||
}
|
||||
|
||||
export async function upsertBlockedNode(domain: string, reason?: string | null) {
|
||||
if (!db) return null;
|
||||
|
||||
const normalized = normalizeNodeDomain(domain);
|
||||
if (!normalized) return null;
|
||||
|
||||
const existing = await db.query.swarmNodes.findFirst({
|
||||
where: eq(swarmNodes.domain, normalized),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(swarmNodes)
|
||||
.set({
|
||||
isBlocked: true,
|
||||
blockReason: reason || null,
|
||||
blockedAt: new Date(),
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(swarmNodes.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(swarmNodes)
|
||||
.values({
|
||||
domain: normalized,
|
||||
isBlocked: true,
|
||||
blockReason: reason || null,
|
||||
blockedAt: new Date(),
|
||||
isActive: false,
|
||||
trustScore: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function unblockNode(domain: string) {
|
||||
if (!db) return null;
|
||||
|
||||
const normalized = normalizeNodeDomain(domain);
|
||||
if (!normalized) return null;
|
||||
|
||||
const existing = await db.query.swarmNodes.findFirst({
|
||||
where: eq(swarmNodes.domain, normalized),
|
||||
});
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await db.update(swarmNodes)
|
||||
.set({
|
||||
isBlocked: false,
|
||||
blockReason: null,
|
||||
blockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(swarmNodes.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
|
||||
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
||||
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
||||
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
||||
import { normalizeNodeDomain } from './node-blocklist';
|
||||
|
||||
/**
|
||||
* Get or create a swarm node entry
|
||||
@@ -21,14 +22,16 @@ export async function upsertSwarmNode(
|
||||
}
|
||||
|
||||
const existing = await db.query.swarmNodes.findFirst({
|
||||
where: eq(swarmNodes.domain, node.domain),
|
||||
where: eq(swarmNodes.domain, normalizeNodeDomain(node.domain)),
|
||||
});
|
||||
|
||||
const normalizedDomain = normalizeNodeDomain(node.domain);
|
||||
|
||||
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(swarmNodes).values({
|
||||
domain: node.domain,
|
||||
domain: normalizedDomain,
|
||||
name: node.name,
|
||||
description: node.description,
|
||||
logoUrl: node.logoUrl,
|
||||
@@ -58,10 +61,10 @@ export async function upsertSwarmNode(
|
||||
capabilities: capabilities ?? existing.capabilities,
|
||||
lastSeenAt: new Date(),
|
||||
consecutiveFailures: 0,
|
||||
isActive: true,
|
||||
isActive: existing.isBlocked ? false : true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(swarmNodes.domain, node.domain));
|
||||
.where(eq(swarmNodes.domain, normalizedDomain));
|
||||
|
||||
return { isNew: false };
|
||||
}
|
||||
@@ -83,8 +86,10 @@ export async function upsertSwarmNodes(
|
||||
// Filter out our own domain
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
||||
const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null;
|
||||
const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain);
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
for (const node of safeNodes) {
|
||||
const result = await upsertSwarmNode(node, discoveredVia);
|
||||
if (result.isNew) {
|
||||
added++;
|
||||
@@ -105,7 +110,10 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
|
||||
}
|
||||
|
||||
const nodes = await db.query.swarmNodes.findMany({
|
||||
where: eq(swarmNodes.isActive, true),
|
||||
where: and(
|
||||
eq(swarmNodes.isActive, true),
|
||||
eq(swarmNodes.isBlocked, false),
|
||||
),
|
||||
orderBy: [desc(swarmNodes.lastSeenAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -125,6 +133,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]>
|
||||
const nodes = await db.query.swarmNodes.findMany({
|
||||
where: and(
|
||||
eq(swarmNodes.isActive, true),
|
||||
eq(swarmNodes.isBlocked, false),
|
||||
gt(swarmNodes.trustScore, 20)
|
||||
),
|
||||
orderBy: sql`RANDOM()`,
|
||||
@@ -143,7 +152,10 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
|
||||
}
|
||||
|
||||
const nodes = await db.query.swarmNodes.findMany({
|
||||
where: gt(swarmNodes.updatedAt, since),
|
||||
where: and(
|
||||
gt(swarmNodes.updatedAt, since),
|
||||
eq(swarmNodes.isBlocked, false),
|
||||
),
|
||||
orderBy: [desc(swarmNodes.updatedAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -177,7 +189,7 @@ export async function markNodeFailure(domain: string): Promise<void> {
|
||||
.set({
|
||||
consecutiveFailures: newFailures,
|
||||
trustScore: newTrust,
|
||||
isActive,
|
||||
isActive: node.isBlocked ? false : isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(swarmNodes.domain, domain));
|
||||
@@ -211,7 +223,7 @@ export async function markNodeSuccess(domain: string): Promise<void> {
|
||||
.set({
|
||||
consecutiveFailures: 0,
|
||||
trustScore: newTrust,
|
||||
isActive: true,
|
||||
isActive: node.isBlocked ? false : true,
|
||||
lastSeenAt: new Date(),
|
||||
lastSyncAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export const parseRemoteHandle = (handle: string) => {
|
||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getRemoteBaseUrl = (domain: string) =>
|
||||
domain.startsWith('http')
|
||||
? domain
|
||||
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
|
||||
? `http://${domain}`
|
||||
: `https://${domain}`;
|
||||
|
||||
type RemoteProfilePost = {
|
||||
id: string;
|
||||
originalPostId?: string;
|
||||
author?: {
|
||||
id?: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isBot?: boolean;
|
||||
};
|
||||
nodeDomain?: string | null;
|
||||
isSwarm?: boolean;
|
||||
repostOf?: RemoteProfilePost | null;
|
||||
replyTo?: RemoteProfilePost | null;
|
||||
media?: Array<{ id?: string; url: string; altText?: string | null; mimeType?: string | null }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function mapRemoteProfilePost(post: RemoteProfilePost, remoteDomain: string): RemoteProfilePost {
|
||||
const isAlreadySwarm = post.id.startsWith('swarm:');
|
||||
const rawOriginalId = post.originalPostId || (isAlreadySwarm ? post.id.split(':').pop() || post.id : post.id);
|
||||
const effectiveDomain = post.nodeDomain || remoteDomain;
|
||||
|
||||
return {
|
||||
...post,
|
||||
id: isAlreadySwarm ? post.id : `swarm:${effectiveDomain}:${rawOriginalId}`,
|
||||
originalPostId: rawOriginalId,
|
||||
isSwarm: true,
|
||||
nodeDomain: effectiveDomain,
|
||||
author: post.author ? {
|
||||
...post.author,
|
||||
id: post.author.id?.startsWith('swarm:')
|
||||
? post.author.id
|
||||
: `swarm:${effectiveDomain}:${post.author.handle.includes('@') ? post.author.handle : post.author.handle}`,
|
||||
handle: post.author.handle.includes('@')
|
||||
? post.author.handle
|
||||
: `${post.author.handle}@${effectiveDomain}`,
|
||||
} : post.author,
|
||||
media: post.media?.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || `swarm:${effectiveDomain}:${rawOriginalId}:media:${index}`,
|
||||
})),
|
||||
repostOf: post.repostOf ? mapRemoteProfilePost(post.repostOf, remoteDomain) : post.repostOf,
|
||||
replyTo: post.replyTo ? mapRemoteProfilePost(post.replyTo, remoteDomain) : post.replyTo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface SwarmRepostTarget {
|
||||
id: string;
|
||||
nodeDomain: string;
|
||||
originalPostId: string;
|
||||
}
|
||||
|
||||
export async function getViewerSwarmRepostedPostIds(
|
||||
targets: SwarmRepostTarget[],
|
||||
viewerId: string
|
||||
): Promise<Set<string>> {
|
||||
const repostedIds = new Set<string>();
|
||||
|
||||
if (!targets.length || !viewerId) {
|
||||
return repostedIds;
|
||||
}
|
||||
|
||||
const { db, userSwarmReposts } = await import('@/db');
|
||||
const { and, eq, inArray } = await import('drizzle-orm');
|
||||
|
||||
const domains = Array.from(new Set(targets.map((target) => target.nodeDomain)));
|
||||
const originalPostIds = Array.from(new Set(targets.map((target) => target.originalPostId)));
|
||||
|
||||
const rows = await db.query.userSwarmReposts.findMany({
|
||||
where: and(
|
||||
eq(userSwarmReposts.userId, viewerId),
|
||||
inArray(userSwarmReposts.nodeDomain, domains),
|
||||
inArray(userSwarmReposts.originalPostId, originalPostIds),
|
||||
),
|
||||
});
|
||||
|
||||
const rowKeys = new Set(rows.map((row) => `${row.nodeDomain}:${row.originalPostId}`));
|
||||
|
||||
for (const target of targets) {
|
||||
if (rowKeys.has(`${target.nodeDomain}:${target.originalPostId}`)) {
|
||||
repostedIds.add(target.id);
|
||||
}
|
||||
}
|
||||
|
||||
return repostedIds;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import crypto from 'crypto';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
|
||||
/**
|
||||
* Sign a payload with the node's private key
|
||||
@@ -56,14 +57,21 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
|
||||
*/
|
||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have a cached node info
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${domain}/api/node`, {
|
||||
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`);
|
||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -88,8 +96,14 @@ export async function verifySwarmRequest(
|
||||
signature: string,
|
||||
senderDomain: string
|
||||
): Promise<boolean> {
|
||||
const normalizedDomain = normalizeNodeDomain(senderDomain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the sender node's public key
|
||||
const publicKey = await getNodePublicKey(senderDomain);
|
||||
const publicKey = await getNodePublicKey(normalizedDomain);
|
||||
|
||||
if (!publicKey) {
|
||||
console.error(`[Signature] Could not get public key for ${senderDomain}`);
|
||||
@@ -118,8 +132,14 @@ export async function verifyUserInteraction(
|
||||
userDomain: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(userDomain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to get cached user
|
||||
const fullHandle = `${userHandle}@${userDomain}`;
|
||||
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
||||
let user = await db?.query.users.findFirst({
|
||||
where: eq(users.handle, fullHandle),
|
||||
});
|
||||
@@ -130,11 +150,14 @@ export async function verifyUserInteraction(
|
||||
publicKey = user.publicKey;
|
||||
} else {
|
||||
// Fetch from remote node
|
||||
const protocol = userDomain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
|
||||
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`);
|
||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -144,7 +167,7 @@ export async function verifyUserInteraction(
|
||||
// Cache the user if we don't have them
|
||||
if (!user && publicKey && db) {
|
||||
await db.insert(users).values({
|
||||
did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
|
||||
did: userData.user?.did || `did:swarm:${normalizedDomain}:${userHandle}`,
|
||||
handle: fullHandle,
|
||||
displayName: userData.user?.displayName || userHandle,
|
||||
avatarUrl: userData.user?.avatarUrl,
|
||||
|
||||
+21
-12
@@ -6,6 +6,8 @@
|
||||
|
||||
import { getActiveSwarmNodes } from './registry';
|
||||
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
|
||||
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
|
||||
interface TimelineResult {
|
||||
posts: SwarmPost[];
|
||||
@@ -41,12 +43,7 @@ function extractFirstUrl(content: string): string | null {
|
||||
/**
|
||||
* Fetch link preview for a URL
|
||||
*/
|
||||
async function fetchLinkPreview(url: string): Promise<{
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
} | null> {
|
||||
async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
|
||||
try {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
|
||||
@@ -70,6 +67,9 @@ async function fetchLinkPreview(url: string): Promise<{
|
||||
title: data.title || null,
|
||||
description: data.description || null,
|
||||
image: data.image || null,
|
||||
type: data.type || null,
|
||||
videoUrl: data.videoUrl || null,
|
||||
media: data.media || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -101,6 +101,9 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
|
||||
linkPreviewTitle: preview.title || undefined,
|
||||
linkPreviewDescription: preview.description || undefined,
|
||||
linkPreviewImage: preview.image || undefined,
|
||||
linkPreviewType: preview.type || undefined,
|
||||
linkPreviewVideoUrl: preview.videoUrl || undefined,
|
||||
linkPreviewMedia: preview.media || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -116,14 +119,19 @@ async function fetchNodeTimeline(
|
||||
cursor?: string
|
||||
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
return { posts: [], error: 'Blocked node' };
|
||||
}
|
||||
|
||||
// Determine protocol - use http for localhost, https for everything else
|
||||
let baseUrl: string;
|
||||
if (domain.startsWith('http')) {
|
||||
baseUrl = domain;
|
||||
} else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) {
|
||||
baseUrl = `http://${domain}`;
|
||||
} else if (normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')) {
|
||||
baseUrl = `http://${normalizedDomain}`;
|
||||
} else {
|
||||
baseUrl = `https://${domain}`;
|
||||
baseUrl = `https://${normalizedDomain}`;
|
||||
}
|
||||
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||
|
||||
@@ -166,13 +174,14 @@ export async function fetchSwarmTimeline(
|
||||
const nodes = await getActiveSwarmNodes(maxNodes);
|
||||
|
||||
// Always include our own posts
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const ourDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost');
|
||||
|
||||
// Always query all nodes - we filter posts, not nodes
|
||||
const nodesToQuery = [
|
||||
const candidateDomains = [
|
||||
ourDomain,
|
||||
...nodes.map(n => n.domain).filter(d => d !== ourDomain)
|
||||
].slice(0, maxNodes);
|
||||
];
|
||||
const nodesToQuery = (await filterBlockedDomains(candidateDomains)).slice(0, maxNodes);
|
||||
|
||||
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
|
||||
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
|
||||
|
||||
Reference in New Issue
Block a user