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:
+174
-30
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { db, users, sessions } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
@@ -13,8 +13,107 @@ import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'synapsis_session';
|
||||
const SESSION_EXPIRY_DAYS = 30;
|
||||
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
|
||||
const SESSION_COOKIE_NAME = 'synapsis_sessions';
|
||||
const SESSION_EXPIRY_DAYS = 3650;
|
||||
|
||||
type SessionRecord = typeof sessions.$inferSelect & {
|
||||
user: typeof users.$inferSelect;
|
||||
};
|
||||
|
||||
export interface AuthAccount {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
did: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string | null;
|
||||
email: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function parseSessionCookie(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readSessionState() {
|
||||
const cookieStore = await cookies();
|
||||
return {
|
||||
cookieStore,
|
||||
activeToken: cookieStore.get(ACTIVE_SESSION_COOKIE_NAME)?.value ?? null,
|
||||
tokens: parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSessionState(tokens: string[], activeToken?: string | null) {
|
||||
const cookieStore = await cookies();
|
||||
const dedupedTokens = [...new Set(tokens.filter(Boolean))];
|
||||
|
||||
if (dedupedTokens.length === 0) {
|
||||
cookieStore.delete(ACTIVE_SESSION_COOKIE_NAME);
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
||||
|
||||
const nextActiveToken = activeToken && dedupedTokens.includes(activeToken)
|
||||
? activeToken
|
||||
: dedupedTokens[0];
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
cookieStore.set(ACTIVE_SESSION_COOKIE_NAME, nextActiveToken, cookieOptions);
|
||||
cookieStore.set(SESSION_COOKIE_NAME, dedupedTokens.join(','), cookieOptions);
|
||||
}
|
||||
|
||||
async function loadSessionsByTokens(tokens: string[]): Promise<SessionRecord[]> {
|
||||
const uniqueTokens = [...new Set(tokens.filter(Boolean))];
|
||||
if (uniqueTokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionRecords = await db.query.sessions.findMany({
|
||||
where: inArray(sessions.token, uniqueTokens),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionMap = new Map(sessionRecords.map(session => [session.token, session]));
|
||||
return uniqueTokens
|
||||
.map(token => sessionMap.get(token))
|
||||
.filter((session): session is SessionRecord => Boolean(session));
|
||||
}
|
||||
|
||||
function toAuthAccount(session: SessionRecord, activeToken: string | null): AuthAccount {
|
||||
return {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
did: session.user.did,
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
email: session.user.email,
|
||||
isActive: session.token === activeToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password
|
||||
@@ -55,6 +154,16 @@ export function generateLegacyDID(): string {
|
||||
* Create a new session for a user
|
||||
*/
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const { tokens } = await readSessionState();
|
||||
const existingSessions = await loadSessionsByTokens(tokens);
|
||||
const existingUserTokens = existingSessions
|
||||
.filter(session => session.userId === userId)
|
||||
.map(session => session.token);
|
||||
|
||||
if (existingUserTokens.length > 0) {
|
||||
await db.delete(sessions).where(inArray(sessions.token, existingUserTokens));
|
||||
}
|
||||
|
||||
const token = uuid();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
||||
@@ -65,15 +174,8 @@ export async function createSession(userId: string): Promise<string> {
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Set the session cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
});
|
||||
const filteredTokens = tokens.filter(existingToken => !existingUserTokens.includes(existingToken));
|
||||
await writeSessionState([token, ...filteredTokens], token);
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -82,25 +184,50 @@ export async function createSession(userId: string): Promise<string> {
|
||||
* Get the current session from cookies
|
||||
*/
|
||||
export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
const { activeToken, tokens } = await readSessionState();
|
||||
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||
|
||||
if (!token) {
|
||||
if (sessionRecords.length === 0) {
|
||||
await writeSessionState([], null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await db.query.sessions.findFirst({
|
||||
where: eq(sessions.token, token),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
const activeSession = sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
|
||||
await writeSessionState(sessionRecords.map(session => session.token), activeSession.token);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return null;
|
||||
return { user: activeSession.user };
|
||||
}
|
||||
|
||||
export async function getSessionAccounts(): Promise<AuthAccount[]> {
|
||||
const { activeToken, tokens } = await readSessionState();
|
||||
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||
|
||||
if (sessionRecords.length === 0) {
|
||||
await writeSessionState([], null);
|
||||
return [];
|
||||
}
|
||||
|
||||
return { user: session.user };
|
||||
const resolvedActiveToken = sessionRecords.some(session => session.token === activeToken)
|
||||
? activeToken
|
||||
: sessionRecords[0].token;
|
||||
|
||||
await writeSessionState(sessionRecords.map(session => session.token), resolvedActiveToken);
|
||||
|
||||
return sessionRecords.map(session => toAuthAccount(session, resolvedActiveToken));
|
||||
}
|
||||
|
||||
export async function switchSession(userId: string): Promise<{ user: typeof users.$inferSelect }> {
|
||||
const { tokens } = await readSessionState();
|
||||
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||
const matchingSession = sessionRecords.find(session => session.user.id === userId);
|
||||
|
||||
if (!matchingSession) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
await writeSessionState(sessionRecords.map(session => session.token), matchingSession.token);
|
||||
|
||||
return { user: matchingSession.user };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,14 +246,31 @@ export async function requireAuth(): Promise<typeof users.$inferSelect> {
|
||||
/**
|
||||
* Destroy the current session
|
||||
*/
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
export async function destroySession(userId?: string): Promise<void> {
|
||||
const { activeToken, tokens } = await readSessionState();
|
||||
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||
|
||||
if (token) {
|
||||
await db.delete(sessions).where(eq(sessions.token, token));
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
if (sessionRecords.length === 0) {
|
||||
await writeSessionState([], null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSession = userId
|
||||
? sessionRecords.find(session => session.user.id === userId)
|
||||
: sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
|
||||
|
||||
if (!targetSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.token, targetSession.token));
|
||||
|
||||
const remainingSessions = sessionRecords.filter(session => session.token !== targetSession.token);
|
||||
const nextActiveToken = targetSession.token === activeToken
|
||||
? remainingSessions[0]?.token ?? null
|
||||
: activeToken;
|
||||
|
||||
await writeSessionState(remainingSessions.map(session => session.token), nextActiveToken);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { db, botContentItems, bots } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
||||
import { canPost } from './rateLimiter';
|
||||
import { decryptApiKey, deserializeEncryptedData } from './encryption';
|
||||
import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
|
||||
import { parseScheduleConfig, isDue } from './scheduler';
|
||||
import { triggerPost } from './posting';
|
||||
|
||||
@@ -213,8 +213,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): C
|
||||
name: bot.name,
|
||||
handle: handle,
|
||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
|
||||
llmProvider: bot.llmProvider as LLMProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import { isIP } from 'net';
|
||||
import {
|
||||
encryptApiKey,
|
||||
decryptApiKey,
|
||||
@@ -52,6 +53,7 @@ export interface BotCreateInput {
|
||||
personality: PersonalityConfig;
|
||||
llmProvider: LLMProvider;
|
||||
llmModel: string;
|
||||
llmEndpoint?: string;
|
||||
llmApiKey: string;
|
||||
schedule?: ScheduleConfig;
|
||||
autonomousMode?: boolean;
|
||||
@@ -65,6 +67,7 @@ export interface BotUpdateInput {
|
||||
personality?: PersonalityConfig;
|
||||
llmProvider?: LLMProvider;
|
||||
llmModel?: string;
|
||||
llmEndpoint?: string | null;
|
||||
llmApiKey?: string;
|
||||
schedule?: ScheduleConfig | null;
|
||||
autonomousMode?: boolean;
|
||||
@@ -83,6 +86,7 @@ export interface Bot {
|
||||
personalityConfig: PersonalityConfig;
|
||||
llmProvider: LLMProvider;
|
||||
llmModel: string;
|
||||
llmEndpoint?: string | null;
|
||||
scheduleConfig: ScheduleConfig | null;
|
||||
autonomousMode: boolean;
|
||||
isActive: boolean;
|
||||
@@ -248,6 +252,65 @@ export function validateScheduleConfig(config: ScheduleConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized === 'localhost' ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipVersion = isIP(normalized);
|
||||
if (ipVersion === 4) {
|
||||
const octets = normalized.split('.').map(Number);
|
||||
const [first, second] = octets;
|
||||
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
if (ipVersion === 6) {
|
||||
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeAndValidateLlmEndpoint(endpoint: string): string {
|
||||
if (!endpoint || typeof endpoint !== 'string') {
|
||||
throw new BotValidationError('Custom endpoint is required');
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(endpoint.trim());
|
||||
} catch {
|
||||
throw new BotValidationError('Custom endpoint must be a valid URL');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new BotValidationError('Custom endpoint must use HTTPS');
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new BotValidationError('Custom endpoint cannot include embedded credentials');
|
||||
}
|
||||
|
||||
if (isPrivateHostname(parsed.hostname)) {
|
||||
throw new BotValidationError('Custom endpoint must be publicly reachable and cannot target localhost or a private network');
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
@@ -269,6 +332,7 @@ function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$infe
|
||||
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
|
||||
llmProvider: dbBot.llmProvider as LLMProvider,
|
||||
llmModel: dbBot.llmModel,
|
||||
llmEndpoint: dbBot.llmEndpoint,
|
||||
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
|
||||
autonomousMode: dbBot.autonomousMode,
|
||||
isActive: dbBot.isActive,
|
||||
@@ -308,6 +372,10 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
|
||||
if (config.schedule) {
|
||||
validateScheduleConfig(config.schedule);
|
||||
}
|
||||
|
||||
const normalizedEndpoint = config.llmProvider === 'custom'
|
||||
? normalizeAndValidateLlmEndpoint(config.llmEndpoint || '')
|
||||
: null;
|
||||
|
||||
// Validate API key format
|
||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
|
||||
@@ -391,6 +459,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
|
||||
personalityConfig: JSON.stringify(config.personality),
|
||||
llmProvider: config.llmProvider,
|
||||
llmModel: config.llmModel,
|
||||
llmEndpoint: normalizedEndpoint,
|
||||
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
|
||||
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
|
||||
autonomousMode: config.autonomousMode ?? false,
|
||||
@@ -443,15 +512,7 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
||||
if (config.schedule !== undefined && config.schedule !== null) {
|
||||
validateScheduleConfig(config.schedule);
|
||||
}
|
||||
|
||||
// Validate API key if provided
|
||||
if (config.llmApiKey !== undefined && config.llmProvider !== undefined) {
|
||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
|
||||
if (!apiKeyValidation.valid) {
|
||||
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check if bot exists and get its user account
|
||||
const existingBot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
@@ -469,6 +530,25 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
||||
if (!botUser) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
const targetProvider = (config.llmProvider ?? existingBot.llmProvider) as LLMProvider;
|
||||
|
||||
if (targetProvider === 'custom') {
|
||||
const endpointToValidate = config.llmEndpoint !== undefined ? config.llmEndpoint : existingBot.llmEndpoint;
|
||||
if (!endpointToValidate) {
|
||||
throw new BotValidationError('Custom endpoint is required');
|
||||
}
|
||||
config.llmEndpoint = normalizeAndValidateLlmEndpoint(endpointToValidate);
|
||||
} else if (config.llmEndpoint !== undefined || config.llmProvider !== undefined) {
|
||||
config.llmEndpoint = null;
|
||||
}
|
||||
|
||||
if (config.llmApiKey !== undefined) {
|
||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, targetProvider);
|
||||
if (!apiKeyValidation.valid) {
|
||||
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object for bot config
|
||||
const botUpdateData: Partial<typeof bots.$inferInsert> = {
|
||||
@@ -508,6 +588,12 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
||||
if (config.llmModel !== undefined) {
|
||||
botUpdateData.llmModel = config.llmModel;
|
||||
}
|
||||
|
||||
if (config.llmEndpoint !== undefined) {
|
||||
botUpdateData.llmEndpoint = config.llmEndpoint;
|
||||
} else if (config.llmProvider !== undefined && config.llmProvider !== 'custom') {
|
||||
botUpdateData.llmEndpoint = null;
|
||||
}
|
||||
|
||||
if (config.llmApiKey !== undefined) {
|
||||
const encryptedApiKey = encryptApiKey(config.llmApiKey);
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
calculateBackoffDelay,
|
||||
shouldRetrySource,
|
||||
isSourceDueForFetch,
|
||||
shouldExcludeRedditPost,
|
||||
fetchRedditPosts,
|
||||
BASE_BACKOFF_DELAY_MS,
|
||||
MAX_BACKOFF_DELAY_MS,
|
||||
MAX_CONSECUTIVE_ERRORS,
|
||||
@@ -195,6 +197,88 @@ describe('isSourceDueForFetch', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('Reddit filtering', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('excludes stickied and pinned reddit posts', () => {
|
||||
expect(shouldExcludeRedditPost({
|
||||
id: 't3_stickied',
|
||||
title: 'Community thread',
|
||||
permalink: '/r/test/comments/stickied/community_thread/',
|
||||
url: 'https://reddit.com/r/test/comments/stickied/community_thread/',
|
||||
created_utc: 1710000000,
|
||||
stickied: true,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldExcludeRedditPost({
|
||||
id: 't3_pinned',
|
||||
title: 'Pinned thread',
|
||||
permalink: '/r/test/comments/pinned/pinned_thread/',
|
||||
url: 'https://reddit.com/r/test/comments/pinned/pinned_thread/',
|
||||
created_utc: 1710000000,
|
||||
pinned: true,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldExcludeRedditPost({
|
||||
id: 't3_normal',
|
||||
title: 'Normal post',
|
||||
permalink: '/r/test/comments/normal/normal_post/',
|
||||
url: 'https://reddit.com/r/test/comments/normal/normal_post/',
|
||||
created_utc: 1710000000,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchRedditPosts removes community highlights before returning items', async () => {
|
||||
const payload = {
|
||||
data: {
|
||||
children: [
|
||||
{
|
||||
data: {
|
||||
id: 'abc123',
|
||||
name: 't3_abc123',
|
||||
title: 'Community Highlights',
|
||||
selftext: 'Pinned moderator thread',
|
||||
permalink: '/r/test/comments/abc123/community_highlights/',
|
||||
url: 'https://www.reddit.com/r/test/comments/abc123/community_highlights/',
|
||||
created_utc: 1710000000,
|
||||
stickied: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'def456',
|
||||
name: 't3_def456',
|
||||
title: 'Actual content post',
|
||||
selftext: 'Useful content',
|
||||
permalink: '/r/test/comments/def456/actual_content_post/',
|
||||
url: 'https://example.com/article',
|
||||
created_utc: 1710000100,
|
||||
stickied: false,
|
||||
pinned: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(payload),
|
||||
} as Response);
|
||||
|
||||
const items = await fetchRedditPosts('test', { maxItems: 10 });
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.title).toBe('Actual content post');
|
||||
expect(items[0]?.id).toBe('t3_def456');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// BACKOFF DELAY PROPERTY TESTS
|
||||
// ============================================
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ContentItemInput {
|
||||
title: string;
|
||||
content: string | null;
|
||||
url: string;
|
||||
url_overridden_by_dest?: string;
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
@@ -325,16 +326,39 @@ export async function fetchRedditPosts(
|
||||
subreddit: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<FeedItem[]> {
|
||||
// Use RSS feed instead of JSON API - more reliable and doesn't require auth
|
||||
const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`;
|
||||
|
||||
const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const fetchLimit = Math.min(Math.max(requestedMaxItems + 10, requestedMaxItems), 100);
|
||||
const url = new URL(`${REDDIT_API_BASE}/r/${subreddit}/hot.json`);
|
||||
url.searchParams.set('raw_json', '1');
|
||||
url.searchParams.set('limit', String(fetchLimit));
|
||||
|
||||
const responseText = await fetchUrl(url.toString(), {
|
||||
timeout: options.timeout,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
let response: RedditListingResponse;
|
||||
try {
|
||||
return await fetchRSSFeed(rssUrl, options);
|
||||
} catch (error) {
|
||||
// If RSS fails, try the old.reddit.com RSS which sometimes works better
|
||||
const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`;
|
||||
return await fetchRSSFeed(oldRedditUrl, options);
|
||||
response = JSON.parse(responseText) as RedditListingResponse;
|
||||
} catch {
|
||||
throw new ParseError('Failed to parse Reddit JSON response');
|
||||
}
|
||||
|
||||
const posts = response.data?.children
|
||||
?.map((child) => child.data)
|
||||
.filter((post): post is RedditListingChildData => Boolean(post)) || [];
|
||||
|
||||
const filteredPosts = posts.filter((post) => !shouldExcludeRedditPost(post));
|
||||
|
||||
return filteredPosts.slice(0, requestedMaxItems).map((post): FeedItem => ({
|
||||
id: post.name || post.id,
|
||||
title: post.title,
|
||||
content: post.selftext || '',
|
||||
url: `${REDDIT_API_BASE}${post.permalink}`,
|
||||
publishedAt: new Date(post.created_utc * 1000),
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -384,6 +408,31 @@ interface BraveNewsResponse {
|
||||
results?: BraveNewsResult[];
|
||||
}
|
||||
|
||||
interface RedditListingChildData {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
selftext?: string;
|
||||
permalink: string;
|
||||
url: string;
|
||||
url_overridden_by_dest?: string;
|
||||
created_utc: number;
|
||||
stickied?: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
interface RedditListingResponse {
|
||||
data?: {
|
||||
children?: Array<{
|
||||
data?: RedditListingChildData;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldExcludeRedditPost(post: RedditListingChildData): boolean {
|
||||
return Boolean(post.stickied || post.pinned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch articles from a news API.
|
||||
*
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Bot {
|
||||
personalityConfig: PersonalityConfig;
|
||||
llmProvider: LLMProvider;
|
||||
llmModel: string;
|
||||
llmEndpoint?: string | null;
|
||||
llmApiKeyEncrypted: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ export interface Bot {
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
externalId?: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
url: string;
|
||||
@@ -467,6 +469,7 @@ export class ContentGenerator {
|
||||
provider: bot.llmProvider,
|
||||
apiKey: bot.llmApiKeyEncrypted,
|
||||
model: bot.llmModel,
|
||||
endpoint: bot.llmEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as crypto from 'crypto';
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
|
||||
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'custom';
|
||||
|
||||
export interface EncryptedData {
|
||||
encrypted: string; // Base64 encoded ciphertext + auth tag
|
||||
@@ -40,6 +40,7 @@ const API_KEY_PATTERNS: Record<LLMProvider, RegExp> = {
|
||||
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
|
||||
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
|
||||
openai: /^sk-[a-zA-Z0-9_-]+$/,
|
||||
custom: /^[\s\S]+$/,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -330,7 +331,7 @@ export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult
|
||||
* @returns True if the provider is supported
|
||||
*/
|
||||
export function isSupportedProvider(provider: string): provider is LLMProvider {
|
||||
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic';
|
||||
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic' || provider === 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,5 +340,5 @@ export function isSupportedProvider(provider: string): provider is LLMProvider {
|
||||
* @returns Array of supported provider names
|
||||
*/
|
||||
export function getSupportedProviders(): LLMProvider[] {
|
||||
return ['openrouter', 'openai', 'anthropic'];
|
||||
return ['openrouter', 'openai', 'anthropic', 'custom'];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface LLMConfig {
|
||||
provider: LLMProvider;
|
||||
apiKey: string; // Encrypted before storage
|
||||
model: string;
|
||||
endpoint?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +124,7 @@ export const PROVIDER_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
|
||||
openai: 'https://api.openai.com/v1/chat/completions',
|
||||
anthropic: 'https://api.anthropic.com/v1/messages',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,7 @@ export const DEFAULT_MODELS: Record<LLMProvider, string> = {
|
||||
openrouter: 'openai/gpt-3.5-turbo',
|
||||
openai: 'gpt-3.5-turbo',
|
||||
anthropic: 'claude-3-haiku-20240307',
|
||||
custom: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -299,7 +302,8 @@ export function parseOpenRouterResponse(
|
||||
*/
|
||||
export function parseOpenAIResponse(
|
||||
data: Record<string, unknown>,
|
||||
model: string
|
||||
model: string,
|
||||
provider: LLMProvider = 'openai'
|
||||
): LLMCompletionResponse {
|
||||
const choices = data.choices as Array<{ message: { content: string } }>;
|
||||
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
@@ -312,7 +316,7 @@ export function parseOpenAIResponse(
|
||||
total: usage?.total_tokens ?? 0,
|
||||
},
|
||||
model: (data.model as string) ?? model,
|
||||
provider: 'openai',
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,6 +363,7 @@ export function buildHeaders(provider: LLMProvider, apiKey: string): Record<stri
|
||||
headers['X-Title'] = 'Synapsis Bot';
|
||||
break;
|
||||
case 'openai':
|
||||
case 'custom':
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
case 'anthropic':
|
||||
@@ -386,6 +391,7 @@ export class LLMClient {
|
||||
private provider: LLMProvider;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private endpoint: string | null;
|
||||
private retryConfig: RetryConfig;
|
||||
private timeoutMs: number;
|
||||
|
||||
@@ -403,6 +409,7 @@ export class LLMClient {
|
||||
) {
|
||||
this.provider = config.provider;
|
||||
this.model = config.model || DEFAULT_MODELS[config.provider];
|
||||
this.endpoint = config.endpoint || null;
|
||||
this.retryConfig = retryConfig;
|
||||
this.timeoutMs = timeoutMs;
|
||||
|
||||
@@ -480,7 +487,16 @@ export class LLMClient {
|
||||
* @throws LLMClientError if the request fails
|
||||
*/
|
||||
private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> {
|
||||
const endpoint = PROVIDER_ENDPOINTS[this.provider];
|
||||
const endpoint = this.provider === 'custom' ? this.endpoint : PROVIDER_ENDPOINTS[this.provider];
|
||||
if (!endpoint) {
|
||||
throw new LLMClientError(
|
||||
'Custom provider requires an endpoint',
|
||||
'INVALID_REQUEST',
|
||||
this.provider,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
}
|
||||
const headers = buildHeaders(this.provider, this.apiKey);
|
||||
const body = this.buildRequestBody(request);
|
||||
|
||||
@@ -566,6 +582,7 @@ export class LLMClient {
|
||||
case 'openrouter':
|
||||
return buildOpenRouterRequest(request, this.model);
|
||||
case 'openai':
|
||||
case 'custom':
|
||||
return buildOpenAIRequest(request, this.model);
|
||||
case 'anthropic':
|
||||
return buildAnthropicRequest(request, this.model);
|
||||
@@ -580,7 +597,9 @@ export class LLMClient {
|
||||
case 'openrouter':
|
||||
return parseOpenRouterResponse(data, this.model);
|
||||
case 'openai':
|
||||
return parseOpenAIResponse(data, this.model);
|
||||
return parseOpenAIResponse(data, this.model, 'openai');
|
||||
case 'custom':
|
||||
return parseOpenAIResponse(data, this.model, 'custom');
|
||||
case 'anthropic':
|
||||
return parseAnthropicResponse(data, this.model);
|
||||
}
|
||||
@@ -618,12 +637,14 @@ export function createLLMClient(
|
||||
export function createLLMClientFromBot(
|
||||
provider: LLMProvider,
|
||||
encryptedApiKey: string,
|
||||
model: string
|
||||
model: string,
|
||||
endpoint?: string | null
|
||||
): LLMClient {
|
||||
return new LLMClient({
|
||||
provider,
|
||||
apiKey: encryptedApiKey,
|
||||
model,
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -643,7 +664,7 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
|
||||
const configObj = config as Record<string, unknown>;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
|
||||
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic', 'custom'];
|
||||
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
|
||||
errors.push(`Provider must be one of: ${validProviders.join(', ')}`);
|
||||
}
|
||||
@@ -657,6 +678,10 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
|
||||
if (configObj.model !== undefined && typeof configObj.model !== 'string') {
|
||||
errors.push('Model must be a string');
|
||||
}
|
||||
|
||||
if (configObj.provider === 'custom' && (!configObj.endpoint || typeof configObj.endpoint !== 'string')) {
|
||||
errors.push('Endpoint is required for custom provider');
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||
llmProvider: bot.llmProvider as any,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||
};
|
||||
|
||||
|
||||
+67
-103
@@ -7,12 +7,15 @@
|
||||
* Requirements: 5.4, 11.5, 11.6
|
||||
*/
|
||||
|
||||
import { db, posts, users, bots, botContentItems, botActivityLogs } from '@/db';
|
||||
import { db, posts, users, bots, botContentItems, botActivityLogs, botContentSources } from '@/db';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
||||
import { canPost, recordPost } from './rateLimiter';
|
||||
import { getBotById } from './botManager';
|
||||
import { decryptApiKey, deserializeEncryptedData } from './encryption';
|
||||
import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
|
||||
import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
@@ -139,8 +142,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect & { user: { handle:
|
||||
name: bot.name,
|
||||
handle: bot.user.handle,
|
||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
|
||||
llmProvider: bot.llmProvider as LLMProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||
};
|
||||
}
|
||||
@@ -175,6 +179,7 @@ async function getContentItemById(contentItemId: string): Promise<ContentItem |
|
||||
return {
|
||||
id: item.id,
|
||||
sourceId: item.sourceId,
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
@@ -250,6 +255,7 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
|
||||
return {
|
||||
id: item.id,
|
||||
sourceId: item.sourceId,
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
@@ -630,62 +636,8 @@ function isRedditUrl(url: string): boolean {
|
||||
* Fetch link preview for Reddit URLs using their oEmbed API.
|
||||
* Reddit blocks regular scraping but provides oEmbed for embedding.
|
||||
*/
|
||||
async function fetchRedditPreview(url: string): Promise<{
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
// Reddit's oEmbed endpoint
|
||||
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`Reddit oEmbed returned ${response.status} for ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract title - try title field first, then parse from HTML
|
||||
let title = data.title || null;
|
||||
if (!title && data.html) {
|
||||
// Try to extract title from the embed HTML
|
||||
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
|
||||
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Build description from subreddit info if available
|
||||
let description = null;
|
||||
if (data.author_name) {
|
||||
description = `Posted by ${data.author_name}`;
|
||||
} else if (data.html) {
|
||||
// Try to extract subreddit from HTML
|
||||
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
|
||||
if (subredditMatch) {
|
||||
description = `r/${subredditMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: data.thumbnail_url || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Reddit oEmbed preview:', error);
|
||||
return null;
|
||||
}
|
||||
async function fetchRedditPreview(url: string): Promise<LinkPreviewData | null> {
|
||||
return fetchRedditRichPreview(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,12 +647,7 @@ async function fetchRedditPreview(url: string): Promise<{
|
||||
* @param url - The URL to fetch preview for
|
||||
* @returns Link preview data or null
|
||||
*/
|
||||
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> {
|
||||
// Use Reddit-specific handler
|
||||
if (isRedditUrl(url)) {
|
||||
return fetchRedditPreview(url);
|
||||
@@ -708,48 +655,61 @@ async function fetchLinkPreview(url: string): Promise<{
|
||||
|
||||
// Generic OG tag scraping for other sites
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Simple regex extraction for OG tags
|
||||
const getMeta = (property: string): string | null => {
|
||||
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
|
||||
const match = html.match(regex);
|
||||
if (match) return match[1];
|
||||
|
||||
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
|
||||
const matchRev = html.match(regexRev);
|
||||
return matchRev ? matchRev[1] : null;
|
||||
};
|
||||
|
||||
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
|
||||
const description = getMeta('description');
|
||||
const image = getMeta('image');
|
||||
|
||||
return {
|
||||
url,
|
||||
title: title?.trim() || null,
|
||||
description: description?.trim() || null,
|
||||
image: image?.trim() || null,
|
||||
};
|
||||
return await fetchGenericLinkPreview(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch link preview:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractRedditPostId(contentItem: ContentItem): string | null {
|
||||
if (contentItem.externalId) {
|
||||
const normalizedExternalId = contentItem.externalId.replace(/^t3_/, '');
|
||||
if (/^[a-z0-9]+$/i.test(normalizedExternalId)) {
|
||||
return normalizedExternalId;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentItem.url) {
|
||||
try {
|
||||
const pathnameParts = new URL(contentItem.url).pathname.split('/').filter(Boolean);
|
||||
const commentsIndex = pathnameParts.indexOf('comments');
|
||||
if (commentsIndex >= 0 && pathnameParts[commentsIndex + 1]) {
|
||||
return pathnameParts[commentsIndex + 1];
|
||||
}
|
||||
} catch {
|
||||
// Fall back to returning null below.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveContentItemSourceUrl(contentItem?: ContentItem | null): Promise<string | undefined> {
|
||||
if (!contentItem?.url) {
|
||||
return contentItem?.url;
|
||||
}
|
||||
|
||||
const source = await db.query.botContentSources.findFirst({
|
||||
where: eq(botContentSources.id, contentItem.sourceId),
|
||||
columns: {
|
||||
type: true,
|
||||
subreddit: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (source?.type !== 'reddit' || !source.subreddit) {
|
||||
return contentItem.url;
|
||||
}
|
||||
|
||||
const postId = extractRedditPostId(contentItem);
|
||||
if (!postId) {
|
||||
return contentItem.url;
|
||||
}
|
||||
|
||||
return `https://www.reddit.com/r/${source.subreddit}/comments/${postId}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a post in the database.
|
||||
* Posts are created under the bot's own user account.
|
||||
@@ -762,7 +722,7 @@ async function fetchLinkPreview(url: string): Promise<{
|
||||
async function createPostInDatabase(
|
||||
botId: string,
|
||||
content: string,
|
||||
sourceUrl?: string
|
||||
contentItem?: ContentItem | null
|
||||
): Promise<typeof posts.$inferSelect> {
|
||||
// Get bot config
|
||||
const bot = await db.query.bots.findFirst({
|
||||
@@ -789,6 +749,7 @@ async function createPostInDatabase(
|
||||
}
|
||||
|
||||
// Fetch link preview if source URL provided
|
||||
const sourceUrl = await resolveContentItemSourceUrl(contentItem);
|
||||
let linkPreview: Awaited<ReturnType<typeof fetchLinkPreview>> = null;
|
||||
if (sourceUrl) {
|
||||
linkPreview = await fetchLinkPreview(sourceUrl);
|
||||
@@ -809,6 +770,9 @@ async function createPostInDatabase(
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
linkPreviewType: linkPreview?.type || null,
|
||||
linkPreviewVideoUrl: linkPreview?.videoUrl || null,
|
||||
linkPreviewMediaJson: linkPreview?.media ? JSON.stringify(linkPreview.media) : null,
|
||||
}).returning();
|
||||
|
||||
// Update bot user's post count
|
||||
@@ -1079,7 +1043,7 @@ export async function triggerPost(
|
||||
}
|
||||
|
||||
// Create post in database with source URL for link preview (if content item exists)
|
||||
const post = await createPostInDatabase(botId, postContent, contentItem?.url);
|
||||
const post = await createPostInDatabase(botId, postContent, contentItem);
|
||||
|
||||
// Record post for rate limiting
|
||||
if (!skipRateLimitCheck) {
|
||||
|
||||
@@ -14,8 +14,14 @@ export interface User {
|
||||
privateKeyEncrypted?: string;
|
||||
}
|
||||
|
||||
export interface AuthAccount extends User {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
accounts: AuthAccount[];
|
||||
activeAccountId: string | null;
|
||||
isAdmin: boolean;
|
||||
loading: boolean;
|
||||
isIdentityUnlocked: boolean;
|
||||
@@ -24,8 +30,10 @@ interface AuthContextType {
|
||||
handle: string | null;
|
||||
checkAdmin: () => Promise<void>;
|
||||
unlockIdentity: (password: string, explicitUser?: User) => Promise<void>;
|
||||
login: (user: User) => void;
|
||||
logout: () => Promise<void>;
|
||||
login: (user?: User) => Promise<void>;
|
||||
logout: (userId?: string) => Promise<void>;
|
||||
switchAccount: (userId: string) => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
lockIdentity: () => Promise<void>; // New: manual lock
|
||||
signUserAction: (action: string, data: any) => Promise<any>;
|
||||
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
|
||||
@@ -35,6 +43,8 @@ interface AuthContextType {
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
isAdmin: false,
|
||||
loading: true,
|
||||
isIdentityUnlocked: false,
|
||||
@@ -43,8 +53,10 @@ const AuthContext = createContext<AuthContextType>({
|
||||
handle: null,
|
||||
checkAdmin: async () => { },
|
||||
unlockIdentity: async () => { },
|
||||
login: () => { },
|
||||
login: async () => { },
|
||||
logout: async () => { },
|
||||
switchAccount: async () => { },
|
||||
refreshAuth: async () => { },
|
||||
lockIdentity: async () => { },
|
||||
signUserAction: async () => Promise.reject('Not initialized'),
|
||||
requiresUnlock: false,
|
||||
@@ -54,6 +66,7 @@ const AuthContext = createContext<AuthContextType>({
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [accounts, setAccounts] = useState<AuthAccount[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
||||
@@ -80,6 +93,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyAuthState = useCallback(async (data: { user: User | null; accounts?: AuthAccount[] | null }) => {
|
||||
const nextAccounts = data.accounts ?? [];
|
||||
setAccounts(nextAccounts);
|
||||
setUser(data.user);
|
||||
|
||||
if (data.user?.did && data.user?.publicKey) {
|
||||
await initializeIdentity({
|
||||
did: data.user.did,
|
||||
handle: data.user.handle,
|
||||
publicKey: data.user.publicKey,
|
||||
privateKeyEncrypted: data.user.privateKeyEncrypted,
|
||||
});
|
||||
await checkAdmin();
|
||||
} else {
|
||||
await clearIdentity();
|
||||
setIsAdmin(false);
|
||||
}
|
||||
}, [checkAdmin, clearIdentity, initializeIdentity]);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
await applyAuthState({
|
||||
user: res.ok ? data.user ?? null : null,
|
||||
accounts: res.ok ? data.accounts ?? [] : [],
|
||||
});
|
||||
} catch {
|
||||
await applyAuthState({ user: null, accounts: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyAuthState]);
|
||||
|
||||
/**
|
||||
* Unlock the user's identity with their password
|
||||
* Persists the key for auto-unlock on refresh
|
||||
@@ -112,81 +160,65 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Manually set the user state (called after successful login)
|
||||
*/
|
||||
const login = useCallback((userData: User) => {
|
||||
setUser(userData);
|
||||
checkAdmin();
|
||||
|
||||
// Initialize identity - will try to auto-restore if possible
|
||||
if (userData.did && userData.publicKey) {
|
||||
initializeIdentity({
|
||||
did: userData.did,
|
||||
handle: userData.handle,
|
||||
publicKey: userData.publicKey,
|
||||
privateKeyEncrypted: userData.privateKeyEncrypted,
|
||||
});
|
||||
}
|
||||
}, [checkAdmin, initializeIdentity]);
|
||||
const login = useCallback(async (_userData?: User) => {
|
||||
await refreshAuth();
|
||||
}, [refreshAuth]);
|
||||
|
||||
/**
|
||||
* Logout the user and clear their identity
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
const logout = useCallback(async (userId?: string) => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
await clearIdentity();
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userId ? { userId } : {}),
|
||||
});
|
||||
setShowUnlockPrompt(false);
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
await refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('[Auth] Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [clearIdentity]);
|
||||
}, [refreshAuth]);
|
||||
|
||||
const switchAccount = useCallback(async (userId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/auth/switch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to switch account');
|
||||
}
|
||||
|
||||
await refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('[Auth] Switch account failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshAuth]);
|
||||
|
||||
// Load auth state on mount
|
||||
useEffect(() => {
|
||||
const loadAuth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
|
||||
// Initialize identity - will auto-restore if persisted
|
||||
if (data.user?.did && data.user?.publicKey) {
|
||||
await initializeIdentity({
|
||||
did: data.user.did,
|
||||
handle: data.user.handle,
|
||||
publicKey: data.user.publicKey,
|
||||
privateKeyEncrypted: data.user.privateKeyEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
await checkAdmin();
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
await clearIdentity();
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
await clearIdentity();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAuth();
|
||||
}, [checkAdmin, initializeIdentity, clearIdentity]);
|
||||
refreshAuth();
|
||||
}, [refreshAuth]);
|
||||
|
||||
// Determine if unlock is required (has encrypted key but not unlocked)
|
||||
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
|
||||
const activeAccountId = user?.id ?? null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
accounts,
|
||||
activeAccountId,
|
||||
isAdmin,
|
||||
loading,
|
||||
isIdentityUnlocked: isUnlocked,
|
||||
@@ -197,6 +229,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
unlockIdentity,
|
||||
login,
|
||||
logout,
|
||||
switchAccount,
|
||||
refreshAuth,
|
||||
lockIdentity,
|
||||
signUserAction,
|
||||
requiresUnlock,
|
||||
|
||||
@@ -17,8 +17,8 @@ import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key
|
||||
const DB_NAME = 'synapsis-identity';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'keys';
|
||||
const SESSION_KEY_ITEM = 'synapsis_session_key';
|
||||
const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key';
|
||||
const SESSION_KEY_PREFIX = 'synapsis_session_key:';
|
||||
const WRAPPED_KEY_PREFIX = 'synapsis_wrapped_key:';
|
||||
|
||||
interface WrappedKey {
|
||||
wrapped: string; // Base64 of wrapped key
|
||||
@@ -32,6 +32,14 @@ interface SessionData {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function getSessionKeyItem(identifier: string): string {
|
||||
return `${SESSION_KEY_PREFIX}${identifier}`;
|
||||
}
|
||||
|
||||
function getWrappedKeyItem(identifier: string): string {
|
||||
return `${WRAPPED_KEY_PREFIX}${identifier}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// IndexedDB Operations
|
||||
// ============================================
|
||||
@@ -218,7 +226,8 @@ async function unwrapRawPrivateKey(
|
||||
*/
|
||||
export async function persistUnlockedKey(
|
||||
privateKeyBase64: string,
|
||||
password: string
|
||||
password: string,
|
||||
identifier: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Derive session key from password
|
||||
@@ -228,7 +237,7 @@ export async function persistUnlockedKey(
|
||||
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
|
||||
|
||||
// Store wrapped key in IndexedDB
|
||||
await storeInDB(WRAPPED_KEY_ITEM, wrapped);
|
||||
await storeInDB(getWrappedKeyItem(identifier), wrapped);
|
||||
|
||||
// Store session key in localStorage (so it survives refreshes)
|
||||
const sessionKeyData = await exportSessionKey(sessionKey);
|
||||
@@ -236,7 +245,7 @@ export async function persistUnlockedKey(
|
||||
key: sessionKeyData,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
|
||||
localStorage.setItem(getSessionKeyItem(identifier), JSON.stringify(sessionData));
|
||||
|
||||
console.log('[KeyPersistence] Key persisted successfully');
|
||||
} catch (error) {
|
||||
@@ -250,10 +259,10 @@ export async function persistUnlockedKey(
|
||||
* Returns the raw key bytes if available, null otherwise
|
||||
* The caller must then import these bytes as a non-extractable CryptoKey
|
||||
*/
|
||||
export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
||||
export async function tryRestoreKey(identifier: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
// Get session key from localStorage
|
||||
const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
|
||||
const sessionDataRaw = localStorage.getItem(getSessionKeyItem(identifier));
|
||||
if (!sessionDataRaw) {
|
||||
console.log('[KeyPersistence] No session key found');
|
||||
return null;
|
||||
@@ -262,15 +271,15 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
||||
const sessionData: SessionData = JSON.parse(sessionDataRaw);
|
||||
|
||||
// Check expiry (24 hours)
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
|
||||
if (Date.now() - sessionData.createdAt > MAX_AGE) {
|
||||
console.log('[KeyPersistence] Session expired');
|
||||
await clearPersistentKey();
|
||||
await clearPersistentKey(identifier);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get wrapped key from IndexedDB
|
||||
const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM);
|
||||
const wrapped = await getFromDB<WrappedKey>(getWrappedKeyItem(identifier));
|
||||
if (!wrapped) {
|
||||
console.log('[KeyPersistence] No wrapped key found');
|
||||
return null;
|
||||
@@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
||||
/**
|
||||
* Clear the persisted key (logout)
|
||||
*/
|
||||
export async function clearPersistentKey(): Promise<void> {
|
||||
export async function clearPersistentKey(identifier?: string): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY_ITEM);
|
||||
await removeFromDB(WRAPPED_KEY_ITEM);
|
||||
if (identifier) {
|
||||
localStorage.removeItem(getSessionKeyItem(identifier));
|
||||
await removeFromDB(getWrappedKeyItem(identifier));
|
||||
return;
|
||||
}
|
||||
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(SESSION_KEY_PREFIX)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
console.log('[KeyPersistence] Key cleared');
|
||||
} catch (error) {
|
||||
console.error('[KeyPersistence] Error clearing key:', error);
|
||||
@@ -306,13 +329,13 @@ export async function clearPersistentKey(): Promise<void> {
|
||||
/**
|
||||
* Check if a persisted key is available
|
||||
*/
|
||||
export async function hasPersistentKey(): Promise<boolean> {
|
||||
const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
|
||||
export async function hasPersistentKey(identifier: string): Promise<boolean> {
|
||||
const sessionData = localStorage.getItem(getSessionKeyItem(identifier));
|
||||
if (!sessionData) return false;
|
||||
|
||||
try {
|
||||
const parsed: SessionData = JSON.parse(sessionData);
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
|
||||
return Date.now() - parsed.createdAt <= MAX_AGE;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -51,10 +51,14 @@ export function useUserIdentity() {
|
||||
setIsRestoring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!globalIdentity?.did) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to restore from persistent storage
|
||||
const restoredKeyBytes = await tryRestoreKey();
|
||||
if (restoredKeyBytes && globalIdentity) {
|
||||
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
|
||||
if (restoredKeyBytes) {
|
||||
// Import the restored key as non-extractable
|
||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
@@ -86,6 +90,8 @@ export function useUserIdentity() {
|
||||
publicKey: string;
|
||||
privateKeyEncrypted?: string;
|
||||
}) => {
|
||||
keyStore.clear();
|
||||
|
||||
const coreIdentity = {
|
||||
did: userData.did,
|
||||
handle: userData.handle,
|
||||
@@ -94,7 +100,7 @@ export function useUserIdentity() {
|
||||
keyStore.setIdentity(coreIdentity);
|
||||
|
||||
// Try to auto-restore if we have persisted key
|
||||
const restoredKeyBytes = await tryRestoreKey();
|
||||
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
||||
if (restoredKeyBytes) {
|
||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
@@ -150,7 +156,11 @@ export function useUserIdentity() {
|
||||
|
||||
// PERSIST: Save raw key bytes for auto-restore on refresh
|
||||
// We pass the raw bytes because the CryptoKey is non-extractable
|
||||
await persistUnlockedKey(privateKeyBase64, password);
|
||||
if (!userDid) {
|
||||
throw new Error('User DID is required to persist the unlocked identity');
|
||||
}
|
||||
|
||||
await persistUnlockedKey(privateKeyBase64, password, userDid);
|
||||
|
||||
console.log('[Identity] Private key stored in memory and persisted');
|
||||
|
||||
@@ -171,8 +181,9 @@ export function useUserIdentity() {
|
||||
* Lock the identity (manual lock, keeps identity info)
|
||||
*/
|
||||
const lockIdentity = useCallback(async () => {
|
||||
const identifier = keyStore.getIdentity()?.did;
|
||||
keyStore.clear();
|
||||
await clearPersistentKey();
|
||||
await clearPersistentKey(identifier);
|
||||
setIsUnlocked(false);
|
||||
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
||||
}, []);
|
||||
@@ -181,8 +192,9 @@ export function useUserIdentity() {
|
||||
* Clear the identity (logout)
|
||||
*/
|
||||
const clearIdentity = useCallback(async () => {
|
||||
const identifier = keyStore.getIdentity()?.did;
|
||||
keyStore.clear();
|
||||
await clearPersistentKey();
|
||||
await clearPersistentKey(identifier);
|
||||
setIdentity(null);
|
||||
setIsUnlocked(false);
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LinkPreviewData } from './linkPreview';
|
||||
|
||||
const GENERIC_PREVIEW_USER_AGENT = 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)';
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function extractMeta(html: string, property: string): string | null {
|
||||
const patterns = [
|
||||
new RegExp(`<meta[^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'),
|
||||
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["']`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return decodeHtmlEntities(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchGenericLinkPreview(url: string): Promise<LinkPreviewData | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': GENERIC_PREVIEW_USER_AGENT,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const title = extractMeta(html, 'title') || html.match(/<title>([^<]+)<\/title>/i)?.[1] || null;
|
||||
const description = extractMeta(html, 'description');
|
||||
const image = extractMeta(html, 'image');
|
||||
|
||||
return {
|
||||
url,
|
||||
title: title?.trim() || null,
|
||||
description: description?.trim() || null,
|
||||
image: image?.trim() || null,
|
||||
type: image?.trim() ? 'image' : 'card',
|
||||
videoUrl: null,
|
||||
media: image?.trim() ? [{ url: image.trim() }] : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type LinkPreviewType = 'card' | 'image' | 'gallery' | 'video';
|
||||
|
||||
export interface LinkPreviewMediaItem {
|
||||
url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
}
|
||||
|
||||
export interface LinkPreviewData {
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
type?: LinkPreviewType | null;
|
||||
videoUrl?: string | null;
|
||||
media?: LinkPreviewMediaItem[] | null;
|
||||
}
|
||||
|
||||
export function parseLinkPreviewMediaJson(
|
||||
value?: string | null
|
||||
): LinkPreviewMediaItem[] | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
|
||||
return parsed.filter((item): item is LinkPreviewMediaItem => (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
typeof item.url === 'string'
|
||||
));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLinkPreviewMedia(
|
||||
media?: LinkPreviewMediaItem[] | null
|
||||
): string | null {
|
||||
if (!media || media.length === 0) return null;
|
||||
return JSON.stringify(media);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LinkPreviewData } from './linkPreview';
|
||||
|
||||
interface RedditOEmbedResponse {
|
||||
title?: string;
|
||||
author_name?: string;
|
||||
provider_name?: string;
|
||||
thumbnail_url?: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
function extractTitleFromHtml(html?: string): string | null {
|
||||
if (!html) return null;
|
||||
const titleMatch = html.match(/href="[^"]+">([^<]+)<\/a>/);
|
||||
if (titleMatch?.[1] && titleMatch[1] !== 'Comment') {
|
||||
return titleMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSubredditFromHtml(html?: string): string | null {
|
||||
if (!html) return null;
|
||||
const subredditMatch = html.match(/r\/([a-zA-Z0-9_]+)/);
|
||||
return subredditMatch?.[1] || null;
|
||||
}
|
||||
|
||||
export async function fetchRedditRichPreview(url: string): Promise<LinkPreviewData | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(oembedUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Synapsis Link Preview/1.0',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as RedditOEmbedResponse;
|
||||
const title = data.title || extractTitleFromHtml(data.html) || 'Reddit';
|
||||
const subreddit = extractSubredditFromHtml(data.html);
|
||||
const description = data.author_name
|
||||
? `Posted by ${data.author_name}${subreddit ? ` in r/${subreddit}` : ''}`
|
||||
: subreddit
|
||||
? `r/${subreddit}`
|
||||
: (data.provider_name || 'Reddit');
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: null,
|
||||
type: 'card',
|
||||
videoUrl: null,
|
||||
media: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { users } from '@/db';
|
||||
|
||||
type NotificationTargetUser = Pick<
|
||||
typeof users.$inferSelect,
|
||||
'handle' | 'displayName' | 'avatarUrl' | 'isBot'
|
||||
>;
|
||||
|
||||
export function buildNotificationTarget(
|
||||
user: NotificationTargetUser,
|
||||
nodeDomain: string | null = null
|
||||
) {
|
||||
return {
|
||||
targetHandle: user.handle,
|
||||
targetDisplayName: user.displayName || user.handle,
|
||||
targetAvatarUrl: user.avatarUrl || null,
|
||||
targetNodeDomain: nodeDomain,
|
||||
targetIsBot: user.isBot,
|
||||
};
|
||||
}
|
||||
+79
-28
@@ -3,8 +3,8 @@ import { cookies } from 'next/headers';
|
||||
import type { users } from '@/db';
|
||||
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
|
||||
|
||||
const STORAGE_SESSION_COOKIE = 'synapsis_storage_session';
|
||||
const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const STORAGE_SESSION_COOKIE = 'synapsis_storage_sessions';
|
||||
const STORAGE_SESSION_TTL_MS = 3650 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface StorageSessionPayload {
|
||||
userId: string;
|
||||
@@ -18,6 +18,8 @@ interface StorageSessionPayload {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type StorageSessionMap = Record<string, StorageSessionPayload>;
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
|
||||
@@ -63,6 +65,55 @@ function decryptPayload(token: string): StorageSessionPayload {
|
||||
return JSON.parse(decrypted) as StorageSessionPayload;
|
||||
}
|
||||
|
||||
function encryptPayloadMap(payload: StorageSessionMap): string {
|
||||
return encryptPayload(payload as unknown as StorageSessionPayload);
|
||||
}
|
||||
|
||||
function decryptPayloadMap(token: string): StorageSessionMap {
|
||||
return decryptPayload(token) as unknown as StorageSessionMap;
|
||||
}
|
||||
|
||||
async function readStorageSessionMap(): Promise<StorageSessionMap> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = decryptPayloadMap(token);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
await clearStorageSession();
|
||||
return {};
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
await clearStorageSession();
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStorageSessionMap(payload: StorageSessionMap): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
cookieStore.delete(STORAGE_SESSION_COOKIE);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxExpiresAt = Math.max(...Object.values(payload).map(session => session.expiresAt));
|
||||
|
||||
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayloadMap(payload), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: new Date(maxExpiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createStorageSession(
|
||||
user: typeof users.$inferSelect,
|
||||
password: string
|
||||
@@ -73,7 +124,7 @@ export async function createStorageSession(
|
||||
!user.storageSecretKeyEncrypted ||
|
||||
!user.storageBucket
|
||||
) {
|
||||
await clearStorageSession();
|
||||
await clearStorageSession(user.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -95,42 +146,42 @@ export async function createStorageSession(
|
||||
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
|
||||
};
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: new Date(payload.expiresAt),
|
||||
});
|
||||
const existingSessions = await readStorageSessionMap();
|
||||
existingSessions[user.id] = payload;
|
||||
await writeStorageSessionMap(existingSessions);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
|
||||
const sessions = await readStorageSessionMap();
|
||||
const payload = sessions[userId];
|
||||
|
||||
if (!token) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = decryptPayload(token);
|
||||
|
||||
if (payload.userId !== userId || payload.expiresAt <= Date.now()) {
|
||||
await clearStorageSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
await clearStorageSession();
|
||||
if (payload.expiresAt <= Date.now()) {
|
||||
delete sessions[userId];
|
||||
await writeStorageSessionMap(sessions);
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function clearStorageSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(STORAGE_SESSION_COOKIE);
|
||||
export async function clearStorageSession(userId?: string): Promise<void> {
|
||||
if (!userId) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(STORAGE_SESSION_COOKIE);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = await readStorageSessionMap();
|
||||
if (!(userId in sessions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete sessions[userId];
|
||||
await writeStorageSessionMap(sessions);
|
||||
}
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -40,6 +40,13 @@ export interface Attachment {
|
||||
altText?: string | null;
|
||||
}
|
||||
|
||||
export interface LinkPreviewMediaItem {
|
||||
url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
content: string;
|
||||
@@ -53,8 +60,13 @@ export interface Post {
|
||||
linkPreviewTitle?: string | null;
|
||||
linkPreviewDescription?: string | null;
|
||||
linkPreviewImage?: string | null;
|
||||
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video' | null;
|
||||
linkPreviewVideoUrl?: string | null;
|
||||
linkPreviewMedia?: LinkPreviewMediaItem[] | null;
|
||||
replyTo?: Post | null;
|
||||
replyToId?: string | null;
|
||||
repostOf?: Post | null;
|
||||
repostOfId?: string | null;
|
||||
// Swarm reply info (when replying to a post on another node)
|
||||
swarmReplyToId?: string | null;
|
||||
swarmReplyToContent?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user