refactor: Migrate from ActivityPub federation to native Swarm network
- Remove ActivityPub infrastructure (webfinger, nodeinfo, inbox, activities, signatures) - Implement native peer-to-peer Swarm network with gossip protocol - Replace federated timeline with Swarm timeline aggregating posts from all nodes - Migrate direct messaging to end-to-end encrypted Swarm Chat system - Update user interactions (follow, like, repost) to use Swarm protocol - Add cryptographic key management for DID-based identity system - Remove server-bound identity model in favor of portable DID identities - Update documentation to reflect Swarm architecture and capabilities - Add debug utilities and chat testing tools for Swarm network - Refactor database schema to support distributed user directory - Update bot framework to work with Swarm network interactions - Simplify API routes to use Swarm protocol instead of ActivityPub - This transition enables true peer-to-peer communication, instant interactions, and encrypted messaging while maintaining sovereign identity through DIDs
This commit is contained in:
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* ActivityPub Activities
|
||||
*
|
||||
* Handles creation of ActivityPub activity objects for federation.
|
||||
* See: https://www.w3.org/TR/activitypub/#overview
|
||||
*/
|
||||
|
||||
import type { posts, users } from '@/db/schema';
|
||||
|
||||
type Post = typeof posts.$inferSelect;
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
|
||||
export interface ActivityPubNote {
|
||||
'@context': string;
|
||||
id: string;
|
||||
type: 'Note';
|
||||
attributedTo: string;
|
||||
content: string;
|
||||
published: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
inReplyTo?: string | null;
|
||||
url: string;
|
||||
attachment?: {
|
||||
type: string;
|
||||
mediaType: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ActivityPubActivity {
|
||||
'@context': string | (string | object)[];
|
||||
id: string;
|
||||
type: 'Create' | 'Follow' | 'Like' | 'Announce' | 'Undo' | 'Accept' | 'Reject' | 'Delete' | 'Move';
|
||||
actor: string;
|
||||
object: string | ActivityPubNote | object;
|
||||
target?: string;
|
||||
published?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
'synapsis:did'?: string; // Synapsis extension for DID-based migration
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Synapsis post to an ActivityPub Note
|
||||
*/
|
||||
export function postToNote(post: Post, author: User, nodeDomain: string): ActivityPubNote {
|
||||
const postUrl = `https://${nodeDomain}/posts/${post.id}`;
|
||||
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: postUrl,
|
||||
type: 'Note',
|
||||
attributedTo: actorUrl,
|
||||
content: escapeHtml(post.content),
|
||||
published: post.createdAt.toISOString(),
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
cc: [`${actorUrl}/followers`],
|
||||
inReplyTo: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
|
||||
url: postUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Create activity for a new post
|
||||
*/
|
||||
export function createCreateActivity(
|
||||
post: Post,
|
||||
author: User,
|
||||
nodeDomain: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||
const note = postToNote(post, author, nodeDomain);
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${post.id}`,
|
||||
type: 'Create',
|
||||
actor: actorUrl,
|
||||
published: post.createdAt.toISOString(),
|
||||
to: note.to,
|
||||
cc: note.cc,
|
||||
object: note,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Follow activity
|
||||
*/
|
||||
export function createFollowActivity(
|
||||
follower: User,
|
||||
targetActorUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${follower.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Follow',
|
||||
actor: actorUrl,
|
||||
object: targetActorUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Like activity
|
||||
*/
|
||||
export function createLikeActivity(
|
||||
user: User,
|
||||
targetPostUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Like',
|
||||
actor: actorUrl,
|
||||
object: targetPostUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Announce (repost) activity
|
||||
*/
|
||||
export function createAnnounceActivity(
|
||||
user: User,
|
||||
targetPostUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Announce',
|
||||
actor: actorUrl,
|
||||
object: targetPostUrl,
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
cc: [`${actorUrl}/followers`],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Undo activity (for unfollowing, unliking, etc.)
|
||||
*/
|
||||
export function createUndoActivity(
|
||||
user: User,
|
||||
originalActivity: ActivityPubActivity,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Undo',
|
||||
actor: actorUrl,
|
||||
object: originalActivity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Accept activity (for accepting follow requests)
|
||||
*/
|
||||
export function createAcceptActivity(
|
||||
user: User,
|
||||
followActivity: ActivityPubActivity,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Accept',
|
||||
actor: actorUrl,
|
||||
object: followActivity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synapsis namespace for DID extension
|
||||
*/
|
||||
const SYNAPSIS_CONTEXT = 'https://synapsis.social/ns';
|
||||
|
||||
/**
|
||||
* Create a Move activity for account migration
|
||||
* Includes the Synapsis DID extension for automatic follower migration
|
||||
*/
|
||||
export function createMoveActivity(
|
||||
user: User,
|
||||
oldActorUrl: string,
|
||||
newActorUrl: string,
|
||||
nodeDomain: string
|
||||
): ActivityPubActivity {
|
||||
return {
|
||||
'@context': [
|
||||
ACTIVITY_STREAMS_CONTEXT,
|
||||
SYNAPSIS_CONTEXT,
|
||||
{
|
||||
'synapsis': 'https://synapsis.social/ns#',
|
||||
'synapsis:did': {
|
||||
'@id': 'synapsis:did',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: `https://${nodeDomain}/activities/move-${user.id}-${Date.now()}`,
|
||||
type: 'Move',
|
||||
actor: oldActorUrl,
|
||||
object: oldActorUrl,
|
||||
target: newActorUrl,
|
||||
'synapsis:did': user.did, // This enables automatic migration for Synapsis nodes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML in content for safety
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* ActivityPub Actor Utilities
|
||||
*
|
||||
* Handles the serialization of Synapsis users to ActivityPub Actor format.
|
||||
* See: https://www.w3.org/TR/activitypub/#actor-objects
|
||||
*/
|
||||
|
||||
import type { users } from '@/db/schema';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
const SECURITY_CONTEXT = 'https://w3id.org/security/v1';
|
||||
|
||||
export interface ActivityPubActor {
|
||||
'@context': (string | object)[];
|
||||
id: string;
|
||||
type: 'Person' | 'Service';
|
||||
preferredUsername: string;
|
||||
name: string | null;
|
||||
summary: string | null;
|
||||
url: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
icon?: {
|
||||
type: 'Image';
|
||||
mediaType: string;
|
||||
url: string;
|
||||
};
|
||||
image?: {
|
||||
type: 'Image';
|
||||
mediaType: string;
|
||||
url: string;
|
||||
};
|
||||
publicKey: {
|
||||
id: string;
|
||||
owner: string;
|
||||
publicKeyPem: string;
|
||||
};
|
||||
endpoints: {
|
||||
sharedInbox: string;
|
||||
};
|
||||
movedTo?: string; // If account has migrated to a new location
|
||||
attributedTo?: string; // Bot creator reference
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Synapsis user to an ActivityPub Actor
|
||||
*/
|
||||
export function userToActor(user: User, nodeDomain: string): ActivityPubActor {
|
||||
const actorUrl = `https://${nodeDomain}/api/users/${user.handle}`;
|
||||
|
||||
const actor: ActivityPubActor = {
|
||||
'@context': [
|
||||
ACTIVITY_STREAMS_CONTEXT,
|
||||
SECURITY_CONTEXT,
|
||||
{
|
||||
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
|
||||
'toot': 'http://joinmastodon.org/ns#',
|
||||
'featured': {
|
||||
'@id': 'toot:featured',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: actorUrl,
|
||||
type: 'Person',
|
||||
preferredUsername: user.handle,
|
||||
name: user.displayName,
|
||||
summary: user.bio,
|
||||
url: actorUrl,
|
||||
inbox: `${actorUrl}/inbox`,
|
||||
outbox: `${actorUrl}/outbox`,
|
||||
followers: `${actorUrl}/followers`,
|
||||
following: `${actorUrl}/following`,
|
||||
publicKey: {
|
||||
id: `${actorUrl}#main-key`,
|
||||
owner: actorUrl,
|
||||
publicKeyPem: user.publicKey,
|
||||
},
|
||||
endpoints: {
|
||||
sharedInbox: `https://${nodeDomain}/inbox`,
|
||||
},
|
||||
};
|
||||
|
||||
// Add avatar if present
|
||||
if (user.avatarUrl) {
|
||||
actor.icon = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/png', // TODO: detect actual type
|
||||
url: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Add header if present
|
||||
if (user.headerUrl) {
|
||||
actor.image = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/png',
|
||||
url: user.headerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Add movedTo if account has migrated
|
||||
if (user.movedTo) {
|
||||
actor.movedTo = user.movedTo;
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actor URL for a user
|
||||
*/
|
||||
export function getActorUrl(handle: string, nodeDomain: string): string {
|
||||
return `https://${nodeDomain}/api/users/${handle}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inbox URL for a user
|
||||
*/
|
||||
export function getInboxUrl(handle: string, nodeDomain: string): string {
|
||||
return `https://${nodeDomain}/api/users/${handle}/inbox`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a bot to an ActivityPub Actor (Service type)
|
||||
*
|
||||
* Requirements: 9.3, 12.3, 12.4
|
||||
*/
|
||||
export function botToActor(
|
||||
bot: { handle: string; name: string; bio: string | null; avatarUrl: string | null; publicKey: string },
|
||||
creatorHandle: string,
|
||||
nodeDomain: string
|
||||
): ActivityPubActor {
|
||||
const actorUrl = `https://${nodeDomain}/api/users/${bot.handle}`;
|
||||
const creatorUrl = `https://${nodeDomain}/api/users/${creatorHandle}`;
|
||||
|
||||
const actor: ActivityPubActor = {
|
||||
'@context': [
|
||||
ACTIVITY_STREAMS_CONTEXT,
|
||||
SECURITY_CONTEXT,
|
||||
{
|
||||
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
|
||||
'toot': 'http://joinmastodon.org/ns#',
|
||||
'featured': {
|
||||
'@id': 'toot:featured',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: actorUrl,
|
||||
type: 'Service', // Bots use Service type
|
||||
preferredUsername: bot.handle,
|
||||
name: bot.name,
|
||||
summary: bot.bio,
|
||||
url: actorUrl,
|
||||
inbox: `${actorUrl}/inbox`,
|
||||
outbox: `${actorUrl}/outbox`,
|
||||
followers: `${actorUrl}/followers`,
|
||||
following: `${actorUrl}/following`,
|
||||
publicKey: {
|
||||
id: `${actorUrl}#main-key`,
|
||||
owner: actorUrl,
|
||||
publicKeyPem: bot.publicKey,
|
||||
},
|
||||
endpoints: {
|
||||
sharedInbox: `https://${nodeDomain}/inbox`,
|
||||
},
|
||||
attributedTo: creatorUrl, // Reference to bot creator
|
||||
};
|
||||
|
||||
// Add avatar if present
|
||||
if (bot.avatarUrl) {
|
||||
actor.icon = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/png',
|
||||
url: bot.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an actor is a bot based on type
|
||||
*/
|
||||
export function isBot(actor: ActivityPubActor): boolean {
|
||||
return actor.type === 'Service';
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { db, remotePosts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
interface RemoteProfile {
|
||||
id: string;
|
||||
preferredUsername?: string;
|
||||
name?: string;
|
||||
icon?: string | { url?: string };
|
||||
summary?: string;
|
||||
outbox?: string;
|
||||
}
|
||||
|
||||
interface OutboxItem {
|
||||
type?: string;
|
||||
object?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
published?: string;
|
||||
attachment?: Array<{ url?: string; name?: string }>;
|
||||
};
|
||||
id?: string;
|
||||
published?: string;
|
||||
}
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const sanitizeText = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
return decoded.replace(/\s+/g, ' ').trim() || null;
|
||||
};
|
||||
|
||||
const extractTextAndUrls = (value?: string | null) => {
|
||||
if (!value) return { text: '', urls: [] as string[] };
|
||||
let html = value;
|
||||
html = html.replace(/<br\s*\/?>/gi, ' ');
|
||||
html = html.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => {
|
||||
const cleanedHref = decodeEntities(String(href));
|
||||
const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim();
|
||||
return cleanedHref || cleanedText;
|
||||
});
|
||||
const withoutTags = html.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
const text = decoded.replace(/\s+/g, ' ').trim();
|
||||
const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]);
|
||||
return { text, urls };
|
||||
};
|
||||
|
||||
const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, '');
|
||||
|
||||
const stripFirstUrl = (text: string, url: string) => {
|
||||
const idx = text.indexOf(url);
|
||||
if (idx === -1) return text;
|
||||
const before = text.slice(0, idx).trimEnd();
|
||||
const after = text.slice(idx + url.length).trimStart();
|
||||
return `${before} ${after}`.trim();
|
||||
};
|
||||
|
||||
const fetchOutboxItems = async (outboxUrl: string, limit: number = 20): Promise<OutboxItem[]> => {
|
||||
try {
|
||||
const res = await fetch(outboxUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const first = data?.first;
|
||||
if (first) {
|
||||
if (typeof first === 'string') {
|
||||
const pageRes = await fetch(first, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!pageRes.ok) return [];
|
||||
const page = await pageRes.json();
|
||||
return page?.orderedItems || page?.items || [];
|
||||
}
|
||||
return first?.orderedItems || first?.items || [];
|
||||
}
|
||||
const items = data?.orderedItems || data?.items || [];
|
||||
return items.slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error('Error fetching outbox:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export async function cacheRemoteUserPosts(
|
||||
remoteProfile: RemoteProfile,
|
||||
authorHandle: string, // e.g., user@mastodon.social
|
||||
origin: string, // Used for link previews
|
||||
limit: number = 20
|
||||
): Promise<{ cached: number; errors: number }> {
|
||||
if (!remoteProfile.outbox) {
|
||||
return { cached: 0, errors: 0 };
|
||||
}
|
||||
|
||||
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
|
||||
if (outboxItems.length === 0) {
|
||||
return { cached: 0, errors: 0 };
|
||||
}
|
||||
|
||||
const authorActorUrl = remoteProfile.id;
|
||||
const authorDisplayName = remoteProfile.name || remoteProfile.preferredUsername || authorHandle;
|
||||
const authorAvatarUrl = typeof remoteProfile.icon === 'string'
|
||||
? remoteProfile.icon
|
||||
: remoteProfile.icon?.url;
|
||||
|
||||
let cached = 0;
|
||||
let errors = 0;
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const item of outboxItems) {
|
||||
try {
|
||||
const activity = item?.type === 'Create' ? item : null;
|
||||
const object = activity?.object;
|
||||
if (!object || typeof object === 'string' || object.type !== 'Note') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const apId = object.id || activity?.id;
|
||||
if (!apId || seenIds.has(apId)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(apId);
|
||||
|
||||
// Check if already cached
|
||||
const existing = await db.query.remotePosts.findFirst({
|
||||
where: eq(remotePosts.apId, apId),
|
||||
});
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
|
||||
const { text, urls } = extractTextAndUrls(object.content);
|
||||
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
|
||||
|
||||
// Fetch link preview if there's a URL
|
||||
let linkPreview: { url?: string; title?: string | null; description?: string | null; image?: string | null } | null = null;
|
||||
if (normalizedUrl) {
|
||||
try {
|
||||
const previewUrl = new URL('/api/media/preview', origin);
|
||||
previewUrl.searchParams.set('url', normalizedUrl);
|
||||
const res = await fetch(previewUrl.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
linkPreview = {
|
||||
url: data?.url || normalizedUrl,
|
||||
title: data?.title || null,
|
||||
description: data?.description || null,
|
||||
image: data?.image || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Link preview fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
|
||||
const mediaJson = attachments
|
||||
.filter((a: { url?: string }) => a?.url)
|
||||
.map((a: { url?: string; name?: string }) => ({
|
||||
url: a.url,
|
||||
altText: sanitizeText(a.name) || null,
|
||||
}));
|
||||
|
||||
const publishedAt = object.published ? new Date(object.published) : new Date();
|
||||
|
||||
await db.insert(remotePosts).values({
|
||||
apId,
|
||||
authorHandle,
|
||||
authorActorUrl,
|
||||
authorDisplayName,
|
||||
authorAvatarUrl: authorAvatarUrl || null,
|
||||
content: contentText || '',
|
||||
publishedAt,
|
||||
linkPreviewUrl: linkPreview?.url || normalizedUrl,
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
mediaJson: mediaJson.length > 0 ? JSON.stringify(mediaJson) : null,
|
||||
}).onConflictDoNothing();
|
||||
|
||||
cached++;
|
||||
} catch (error) {
|
||||
console.error('Error caching post:', error);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { cached, errors };
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
|
||||
import { fetchWebFinger, getActorUrlFromWebFinger } from './webfinger';
|
||||
|
||||
export interface ActivityPubProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
preferredUsername: string;
|
||||
name?: string;
|
||||
summary?: string;
|
||||
url?: string;
|
||||
inbox?: string;
|
||||
outbox?: string;
|
||||
followers?: string;
|
||||
following?: string;
|
||||
endpoints?: {
|
||||
sharedInbox?: string;
|
||||
};
|
||||
icon?: {
|
||||
url: string;
|
||||
} | string;
|
||||
image?: {
|
||||
url: string;
|
||||
} | string;
|
||||
publicKey?: {
|
||||
id: string;
|
||||
owner: string;
|
||||
publicKeyPem: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote ActivityPub actor
|
||||
*/
|
||||
export async function fetchRemoteActor(url: string): Promise<ActivityPubProfile | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent': 'Synapsis/1.0 (+https://synapsis.social)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch actor: ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Basic validation
|
||||
if (!data.id || !data.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as ActivityPubProfile;
|
||||
} catch (error) {
|
||||
console.error('Error fetching remote actor:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a remote user via WebFinger and fetch their profile
|
||||
* @param handle The username (without domain)
|
||||
* @param domain The domain name
|
||||
*/
|
||||
export async function resolveRemoteUser(handle: string, domain: string): Promise<ActivityPubProfile | null> {
|
||||
// 1. WebFinger lookup
|
||||
const webfinger = await fetchWebFinger(handle, domain);
|
||||
if (!webfinger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get Actor URL
|
||||
const actorUrl = getActorUrlFromWebFinger(webfinger);
|
||||
if (!actorUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Fetch Actor Profile
|
||||
return await fetchRemoteActor(actorUrl);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { db, remotePosts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface ActivityPubNote {
|
||||
'@context': string;
|
||||
id: string;
|
||||
type: 'Note';
|
||||
attributedTo: string;
|
||||
content: string;
|
||||
published: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
inReplyTo?: string | null;
|
||||
url: string;
|
||||
attachment?: {
|
||||
type: string;
|
||||
mediaType: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function parsePostIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
|
||||
const postsIndex = pathParts.indexOf('posts');
|
||||
if (postsIndex !== -1 && pathParts[postsIndex + 1]) {
|
||||
return pathParts[postsIndex + 1];
|
||||
}
|
||||
|
||||
const objectsIndex = pathParts.indexOf('objects');
|
||||
if (objectsIndex !== -1 && pathParts[objectsIndex + 1]) {
|
||||
return pathParts[objectsIndex + 1];
|
||||
}
|
||||
|
||||
if (pathParts.includes('objects') && pathParts.includes('users')) {
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
return lastPart;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemotePostFromUrl(url: string): Promise<ActivityPubNote | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent': 'Synapsis/1.0 (+https://synapsis.social)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch remote post: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching remote post:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheRemotePost(apNote: ActivityPubNote, nodeDomain: string): Promise<boolean> {
|
||||
try {
|
||||
const postId = parsePostIdFromUrl(apNote.url);
|
||||
if (!postId) {
|
||||
console.error('Could not parse post ID from:', apNote.url);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = await db.query.remotePosts.findFirst({
|
||||
where: eq(remotePosts.apId, apNote.id),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('Remote post already cached:', postId);
|
||||
return true;
|
||||
}
|
||||
|
||||
let mediaJson: string | null = null;
|
||||
if (apNote.attachment && apNote.attachment.length > 0) {
|
||||
mediaJson = JSON.stringify(apNote.attachment);
|
||||
}
|
||||
|
||||
const authorUrl = new URL(apNote.attributedTo);
|
||||
const authorHandle = authorUrl.pathname.split('/').pop() || apNote.attributedTo;
|
||||
const authorDomain = authorUrl.hostname;
|
||||
|
||||
await db.insert(remotePosts).values({
|
||||
id: crypto.randomUUID(),
|
||||
apId: apNote.id,
|
||||
authorHandle: authorHandle,
|
||||
authorActorUrl: apNote.attributedTo,
|
||||
authorDisplayName: apNote.attributedTo === authorHandle ? null : authorHandle,
|
||||
authorAvatarUrl: null,
|
||||
content: apNote.content || '',
|
||||
publishedAt: apNote.published ? new Date(apNote.published) : new Date(),
|
||||
linkPreviewUrl: null,
|
||||
linkPreviewTitle: null,
|
||||
linkPreviewDescription: null,
|
||||
linkPreviewImage: null,
|
||||
mediaJson: mediaJson,
|
||||
fetchedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log('Cached remote post:', postId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error caching remote post:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cachedRemotePostToFrontend(remotePost: any) {
|
||||
let media = null;
|
||||
try {
|
||||
if (remotePost.mediaJson) {
|
||||
media = JSON.parse(remotePost.mediaJson);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return {
|
||||
id: remotePost.id,
|
||||
content: remotePost.content,
|
||||
createdAt: remotePost.publishedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: {
|
||||
id: remotePost.authorHandle,
|
||||
handle: remotePost.authorHandle,
|
||||
displayName: remotePost.authorDisplayName || remotePost.authorHandle,
|
||||
avatarUrl: remotePost.authorAvatarUrl,
|
||||
bio: null,
|
||||
isRemote: true,
|
||||
},
|
||||
media: media || undefined,
|
||||
linkPreviewUrl: remotePost.linkPreviewUrl,
|
||||
linkPreviewTitle: remotePost.linkPreviewTitle,
|
||||
linkPreviewDescription: remotePost.linkPreviewDescription,
|
||||
linkPreviewImage: remotePost.linkPreviewImage,
|
||||
isLiked: false,
|
||||
isReposted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRemotePost(postUrl: string, nodeDomain: string): Promise<{ post: any | null; isCached: boolean }> {
|
||||
const apNote = await fetchRemotePostFromUrl(postUrl);
|
||||
if (!apNote) {
|
||||
return { post: null, isCached: false };
|
||||
}
|
||||
|
||||
const cached = await cacheRemotePost(apNote, nodeDomain);
|
||||
if (!cached) {
|
||||
return { post: null, isCached: false };
|
||||
}
|
||||
|
||||
const frontendPost = cachedRemotePostToFrontend(apNote);
|
||||
|
||||
return { post: frontendPost, isCached: true };
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
/**
|
||||
* ActivityPub Inbox Handler
|
||||
*
|
||||
* Processes incoming activities from remote servers.
|
||||
*/
|
||||
|
||||
import { db, users, remoteFollowers, remotePosts, posts, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { verifySignature, fetchActorPublicKey } from './signatures';
|
||||
import { createAcceptActivity } from './activities';
|
||||
import { deliverActivity } from './outbox';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
export interface IncomingActivity {
|
||||
'@context': string | string[];
|
||||
id: string;
|
||||
type: string;
|
||||
actor: string;
|
||||
object: string | object;
|
||||
published?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
}
|
||||
|
||||
interface RemoteActorInfo {
|
||||
inbox: string;
|
||||
endpoints?: {
|
||||
sharedInbox?: string;
|
||||
};
|
||||
preferredUsername?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch remote actor info
|
||||
*/
|
||||
async function fetchRemoteActorInfo(actorUrl: string): Promise<RemoteActorInfo | null> {
|
||||
try {
|
||||
const response = await fetch(actorUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Inbox] Failed to fetch actor: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Failed to fetch remote actor:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming activity
|
||||
*/
|
||||
export async function processIncomingActivity(
|
||||
activity: IncomingActivity,
|
||||
headers: Record<string, string>,
|
||||
path: string,
|
||||
targetUser: User | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Verify the signature
|
||||
const publicKey = await fetchActorPublicKey(activity.actor);
|
||||
if (!publicKey) {
|
||||
console.warn('[Inbox] Could not fetch actor public key for:', activity.actor);
|
||||
// Continue anyway for now - some servers have signature issues
|
||||
} else {
|
||||
const isValid = await verifySignature('POST', path, headers, publicKey);
|
||||
if (!isValid) {
|
||||
console.warn('[Inbox] Invalid signature for activity:', activity.id);
|
||||
// Continue anyway for now - signature verification can be strict later
|
||||
}
|
||||
}
|
||||
|
||||
// Process based on activity type
|
||||
switch (activity.type) {
|
||||
case 'Create':
|
||||
return await handleCreate(activity);
|
||||
case 'Follow':
|
||||
return await handleFollow(activity, targetUser);
|
||||
case 'Like':
|
||||
return await handleLike(activity);
|
||||
case 'Announce':
|
||||
return await handleAnnounce(activity);
|
||||
case 'Undo':
|
||||
return await handleUndo(activity, targetUser);
|
||||
case 'Delete':
|
||||
return await handleDelete(activity);
|
||||
case 'Accept':
|
||||
return await handleAccept(activity);
|
||||
case 'Reject':
|
||||
return await handleReject(activity);
|
||||
case 'Move':
|
||||
return await handleMove(activity);
|
||||
default:
|
||||
console.log('[Inbox] Unhandled activity type:', activity.type);
|
||||
return { success: true }; // Don't error on unknown types
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Create activities (new posts)
|
||||
*/
|
||||
async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const object = activity.object as {
|
||||
type: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
attributedTo?: string;
|
||||
published?: string;
|
||||
attachment?: Array<{
|
||||
type: string;
|
||||
mediaType?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (object.type !== 'Note') {
|
||||
return { success: true }; // We only handle Notes for now
|
||||
}
|
||||
|
||||
if (!object.id || !object.attributedTo) {
|
||||
console.warn('[Inbox] Create activity missing id or attributedTo');
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we already have this post cached
|
||||
const existingPost = await db.query.remotePosts.findFirst({
|
||||
where: eq(remotePosts.apId, object.id),
|
||||
});
|
||||
|
||||
if (existingPost) {
|
||||
console.log('[Inbox] Post already cached:', object.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Parse author info from attributedTo URL
|
||||
const authorUrl = new URL(object.attributedTo);
|
||||
const authorPathParts = authorUrl.pathname.split('/').filter(Boolean);
|
||||
const authorHandle = authorPathParts[authorPathParts.length - 1] || 'unknown';
|
||||
const authorDomain = authorUrl.hostname;
|
||||
const fullHandle = `${authorHandle}@${authorDomain}`;
|
||||
|
||||
// Fetch author profile for display name and avatar
|
||||
let displayName: string | null = null;
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const actorResponse = await fetch(object.attributedTo, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
},
|
||||
});
|
||||
if (actorResponse.ok) {
|
||||
const actorData = await actorResponse.json();
|
||||
displayName = actorData.name || actorData.preferredUsername || null;
|
||||
avatarUrl = actorData.icon?.url || actorData.icon || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Inbox] Could not fetch actor profile:', e);
|
||||
}
|
||||
|
||||
// Parse media attachments
|
||||
let mediaJson: string | null = null;
|
||||
if (object.attachment && object.attachment.length > 0) {
|
||||
const mediaItems = object.attachment
|
||||
.filter(att => att.type === 'Document' || att.type === 'Image' || att.type === 'Video')
|
||||
.map(att => ({
|
||||
url: att.url,
|
||||
altText: att.name || null,
|
||||
mediaType: att.mediaType,
|
||||
}));
|
||||
if (mediaItems.length > 0) {
|
||||
mediaJson = JSON.stringify(mediaItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the remote post
|
||||
await db.insert(remotePosts).values({
|
||||
apId: object.id,
|
||||
authorHandle: fullHandle,
|
||||
authorActorUrl: object.attributedTo,
|
||||
authorDisplayName: displayName,
|
||||
authorAvatarUrl: avatarUrl,
|
||||
content: object.content || '',
|
||||
publishedAt: object.published ? new Date(object.published) : new Date(),
|
||||
mediaJson: mediaJson,
|
||||
fetchedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`[Inbox] Cached remote post from ${fullHandle}:`, object.id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error caching remote post:', error);
|
||||
return { success: false, error: 'Failed to cache post' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Follow activities
|
||||
*/
|
||||
async function handleFollow(
|
||||
activity: IncomingActivity,
|
||||
targetUser: User | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const targetActorUrl = typeof activity.object === 'string'
|
||||
? activity.object
|
||||
: (activity.object as { id?: string }).id;
|
||||
|
||||
if (!targetActorUrl) {
|
||||
return { success: false, error: 'Invalid follow target' };
|
||||
}
|
||||
|
||||
// If targetUser wasn't provided, try to find them from the activity
|
||||
if (!targetUser) {
|
||||
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||
if (!handleMatch) {
|
||||
return { success: false, error: 'Could not parse target handle' };
|
||||
}
|
||||
|
||||
const handle = handleMatch[1].toLowerCase();
|
||||
targetUser = (await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
})) ?? null;
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
if (targetUser.isSuspended) {
|
||||
return { success: false, error: 'User is suspended' };
|
||||
}
|
||||
|
||||
console.log(`[Inbox] Processing follow request for @${targetUser.handle} from ${activity.actor}`);
|
||||
|
||||
// Fetch the remote actor's info to get their inbox
|
||||
const remoteActor = await fetchRemoteActorInfo(activity.actor);
|
||||
if (!remoteActor || !remoteActor.inbox) {
|
||||
console.error('[Inbox] Could not fetch remote actor inbox');
|
||||
return { success: false, error: 'Could not fetch remote actor' };
|
||||
}
|
||||
|
||||
// Check if we already have this follower
|
||||
const existingFollower = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, activity.actor)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollower) {
|
||||
console.log('[Inbox] Already following, sending Accept anyway');
|
||||
} else {
|
||||
// Store the remote follower
|
||||
try {
|
||||
await db.insert(remoteFollowers).values({
|
||||
userId: targetUser.id,
|
||||
actorUrl: activity.actor,
|
||||
inboxUrl: remoteActor.inbox,
|
||||
sharedInboxUrl: remoteActor.endpoints?.sharedInbox ?? null,
|
||||
handle: remoteActor.preferredUsername
|
||||
? `${remoteActor.preferredUsername}@${new URL(activity.actor).hostname}`
|
||||
: null,
|
||||
activityId: activity.id,
|
||||
});
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Inbox] Stored remote follower: ${activity.actor}`);
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Failed to store remote follower:', error);
|
||||
// Continue anyway - we still want to send the Accept
|
||||
}
|
||||
}
|
||||
|
||||
// Send Accept activity back
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const acceptActivity = createAcceptActivity(
|
||||
targetUser,
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: activity.id,
|
||||
type: 'Follow',
|
||||
actor: activity.actor,
|
||||
object: targetActorUrl,
|
||||
},
|
||||
nodeDomain,
|
||||
crypto.randomUUID()
|
||||
);
|
||||
|
||||
const privateKey = targetUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
console.error('[Inbox] User has no private key for signing');
|
||||
return { success: false, error: 'Missing signing key' };
|
||||
}
|
||||
|
||||
const keyId = `https://${nodeDomain}/users/${targetUser.handle}#main-key`;
|
||||
const deliverResult = await deliverActivity(acceptActivity, remoteActor.inbox, privateKey, keyId);
|
||||
|
||||
if (!deliverResult.success) {
|
||||
console.error('[Inbox] Failed to deliver Accept activity:', deliverResult.error);
|
||||
// Don't fail the whole operation - the follow is stored
|
||||
} else {
|
||||
console.log(`[Inbox] Sent Accept activity to ${remoteActor.inbox}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Like activities
|
||||
*/
|
||||
async function handleLike(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||
|
||||
if (!targetUrl) {
|
||||
return { success: false, error: 'Invalid like target' };
|
||||
}
|
||||
|
||||
console.log('[Inbox] Received like for:', targetUrl, 'from:', activity.actor);
|
||||
|
||||
try {
|
||||
// Find the local post by its apId or apUrl
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, targetUrl),
|
||||
});
|
||||
|
||||
if (post) {
|
||||
// Increment like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, post.id));
|
||||
console.log(`[Inbox] Updated like count for post ${post.id}: ${post.likesCount + 1}`);
|
||||
} else {
|
||||
console.log('[Inbox] Like target not found locally:', targetUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error updating like count:', error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Announce activities (reposts)
|
||||
*/
|
||||
async function handleAnnounce(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||
|
||||
if (!targetUrl) {
|
||||
return { success: false, error: 'Invalid announce target' };
|
||||
}
|
||||
|
||||
console.log('[Inbox] Received announce for:', targetUrl, 'from:', activity.actor);
|
||||
|
||||
try {
|
||||
// Find the local post by its apId or apUrl
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, targetUrl),
|
||||
});
|
||||
|
||||
if (post) {
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.where(eq(posts.id, post.id));
|
||||
console.log(`[Inbox] Updated repost count for post ${post.id}: ${post.repostsCount + 1}`);
|
||||
} else {
|
||||
console.log('[Inbox] Announce target not found locally:', targetUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error updating repost count:', error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Undo activities
|
||||
*/
|
||||
async function handleUndo(
|
||||
activity: IncomingActivity,
|
||||
targetUser: User | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const originalActivity = activity.object as IncomingActivity;
|
||||
|
||||
if (!originalActivity || !originalActivity.type) {
|
||||
return { success: false, error: 'Invalid undo target' };
|
||||
}
|
||||
|
||||
console.log('[Inbox] Received undo for:', originalActivity.type, 'from:', activity.actor);
|
||||
|
||||
// Handle Undo Follow (unfollow)
|
||||
if (originalActivity.type === 'Follow') {
|
||||
// If we don't have the target user, try to find them
|
||||
if (!targetUser) {
|
||||
const targetActorUrl = typeof originalActivity.object === 'string'
|
||||
? originalActivity.object
|
||||
: (originalActivity.object as { id?: string })?.id;
|
||||
|
||||
if (targetActorUrl) {
|
||||
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||
if (handleMatch) {
|
||||
targetUser = (await db.query.users.findFirst({
|
||||
where: eq(users.handle, handleMatch[1].toLowerCase()),
|
||||
})) ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUser) {
|
||||
// Remove the remote follower
|
||||
const existingFollower = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, activity.actor)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollower) {
|
||||
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollower.id));
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Inbox] Removed remote follower: ${activity.actor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Delete activities
|
||||
*/
|
||||
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Inbox] Received delete from:', activity.actor);
|
||||
|
||||
try {
|
||||
// The object can be the deleted item's URL or an object with an id
|
||||
const deletedId = typeof activity.object === 'string'
|
||||
? activity.object
|
||||
: (activity.object as { id?: string })?.id;
|
||||
|
||||
if (!deletedId) {
|
||||
console.log('[Inbox] Delete activity missing object id');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Try to find and remove cached remote post
|
||||
const cachedPost = await db.query.remotePosts.findFirst({
|
||||
where: eq(remotePosts.apId, deletedId),
|
||||
});
|
||||
|
||||
if (cachedPost) {
|
||||
// Verify the delete is from the original author
|
||||
if (cachedPost.authorActorUrl === activity.actor) {
|
||||
await db.delete(remotePosts).where(eq(remotePosts.id, cachedPost.id));
|
||||
console.log(`[Inbox] Deleted cached remote post: ${deletedId}`);
|
||||
} else {
|
||||
console.warn('[Inbox] Delete actor mismatch - ignoring');
|
||||
}
|
||||
} else {
|
||||
console.log('[Inbox] Deleted content not found in cache:', deletedId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error handling delete:', error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Accept activities (follow accepted)
|
||||
*/
|
||||
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Inbox] Follow accepted by:', activity.actor);
|
||||
|
||||
try {
|
||||
// The object should be the original Follow activity
|
||||
const followActivity = activity.object as { type?: string; actor?: string; object?: string };
|
||||
|
||||
if (followActivity?.type !== 'Follow') {
|
||||
console.log('[Inbox] Accept is not for a Follow activity');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Find the local user who sent the follow
|
||||
const localActorUrl = followActivity.actor;
|
||||
if (!localActorUrl) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Extract handle from our actor URL
|
||||
const handleMatch = localActorUrl.match(/\/users\/([^\/]+)$/);
|
||||
if (!handleMatch) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const localUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handleMatch[1].toLowerCase()),
|
||||
});
|
||||
|
||||
if (!localUser) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Find and update the remote follow record
|
||||
const remoteFollow = await db.query.remoteFollows.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollows.followerId, localUser.id),
|
||||
eq(remoteFollows.targetActorUrl, activity.actor)
|
||||
),
|
||||
});
|
||||
|
||||
if (remoteFollow) {
|
||||
// The follow is now confirmed - we could add an 'accepted' flag if needed
|
||||
// For now, just log it since the follow is already stored
|
||||
console.log(`[Inbox] Follow to ${activity.actor} confirmed for @${localUser.handle}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error handling accept:', error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Reject activities (follow rejected)
|
||||
*/
|
||||
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Inbox] Follow rejected by:', activity.actor);
|
||||
|
||||
// TODO: Remove pending follow from remoteFollows table
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Move activities (account migration)
|
||||
*
|
||||
* This is Synapsis's killer feature: if the Move activity contains a DID,
|
||||
* we can automatically update the follow relationship because we know
|
||||
* it's the same person, just on a different node.
|
||||
*/
|
||||
async function handleMove(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const oldActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
|
||||
const newActorUrl = (activity as { target?: string }).target;
|
||||
const did = (activity as { 'synapsis:did'?: string })['synapsis:did'];
|
||||
|
||||
if (!oldActorUrl || !newActorUrl) {
|
||||
return { success: false, error: 'Invalid move activity' };
|
||||
}
|
||||
|
||||
console.log(`[Inbox] Received Move activity: ${oldActorUrl} -> ${newActorUrl}`);
|
||||
|
||||
// Check if this is a Synapsis node with DID support
|
||||
if (did) {
|
||||
console.log(`[Inbox] Move includes DID: ${did} - attempting automatic migration`);
|
||||
|
||||
try {
|
||||
// Find all local users following the old actor URL
|
||||
const affectedFollows = await db.query.remoteFollows.findMany({
|
||||
where: eq(remoteFollows.targetActorUrl, oldActorUrl),
|
||||
});
|
||||
|
||||
if (affectedFollows.length === 0) {
|
||||
console.log('[Inbox] No local users following the migrating account');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
console.log(`[Inbox] Found ${affectedFollows.length} local users to migrate`);
|
||||
|
||||
// Fetch the new actor's info to get their inbox
|
||||
const newActorResponse = await fetch(newActorUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!newActorResponse.ok) {
|
||||
console.error('[Inbox] Failed to fetch new actor profile');
|
||||
return { success: true }; // Don't fail, just log
|
||||
}
|
||||
|
||||
const newActor = await newActorResponse.json();
|
||||
const newInbox = newActor.endpoints?.sharedInbox || newActor.inbox;
|
||||
const newHandle = newActor.preferredUsername
|
||||
? `${newActor.preferredUsername}@${new URL(newActorUrl).hostname}`
|
||||
: null;
|
||||
|
||||
if (!newInbox) {
|
||||
console.error('[Inbox] New actor has no inbox');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Update each follow relationship and send new Follow activities
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const { createFollowActivity } = await import('./activities');
|
||||
const { deliverActivity } = await import('./outbox');
|
||||
|
||||
for (const follow of affectedFollows) {
|
||||
try {
|
||||
// Get the local user who was following
|
||||
const localUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, follow.followerId),
|
||||
});
|
||||
|
||||
if (!localUser || !localUser.privateKeyEncrypted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the remoteFollows record with new actor info
|
||||
const newActivityId = crypto.randomUUID();
|
||||
await db.update(remoteFollows)
|
||||
.set({
|
||||
targetActorUrl: newActorUrl,
|
||||
targetHandle: newHandle || follow.targetHandle,
|
||||
inboxUrl: newInbox,
|
||||
activityId: newActivityId,
|
||||
displayName: newActor.name || follow.displayName,
|
||||
avatarUrl: newActor.icon?.url || newActor.icon || follow.avatarUrl,
|
||||
})
|
||||
.where(eq(remoteFollows.id, follow.id));
|
||||
|
||||
// Send a Follow activity to the new actor
|
||||
const followActivity = createFollowActivity(
|
||||
localUser,
|
||||
newActorUrl,
|
||||
nodeDomain,
|
||||
newActivityId
|
||||
);
|
||||
|
||||
const keyId = `https://${nodeDomain}/users/${localUser.handle}#main-key`;
|
||||
await deliverActivity(followActivity, newInbox, localUser.privateKeyEncrypted, keyId);
|
||||
|
||||
console.log(`[Inbox] Auto-migrated @${localUser.handle}'s follow to ${newActorUrl}`);
|
||||
} catch (err) {
|
||||
console.error(`[Inbox] Error migrating follow ${follow.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Inbox] DID-based migration complete. ${affectedFollows.length} followers migrated.`);
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error during DID-based migration:', error);
|
||||
}
|
||||
} else {
|
||||
// Standard Fediverse Move - just log it
|
||||
// Users will need to manually re-follow
|
||||
console.log('[Inbox] Standard Move activity (no DID). Manual re-follow required.');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// ActivityPub module exports
|
||||
export * from './actor';
|
||||
export * from './activities';
|
||||
export * from './webfinger';
|
||||
export * from './signatures';
|
||||
export * from './inbox';
|
||||
export * from './outbox';
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* ActivityPub Outbox / Delivery
|
||||
*
|
||||
* Handles sending activities to remote servers.
|
||||
*/
|
||||
|
||||
import { signRequest } from './signatures';
|
||||
import type { ActivityPubActivity } from './activities';
|
||||
|
||||
/**
|
||||
* Deliver an activity to a remote inbox
|
||||
*/
|
||||
export async function deliverActivity(
|
||||
activity: ActivityPubActivity,
|
||||
targetInbox: string,
|
||||
privateKey: string,
|
||||
keyId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const body = JSON.stringify(activity);
|
||||
|
||||
// Sign the request
|
||||
const signatureHeaders = await signRequest(
|
||||
'POST',
|
||||
targetInbox,
|
||||
body,
|
||||
privateKey,
|
||||
keyId
|
||||
);
|
||||
|
||||
// Send the activity
|
||||
const response = await fetch(targetInbox, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Accept': 'application/activity+json',
|
||||
...signatureHeaders,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Activity delivery failed:', response.status, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Delivery failed: ${response.status} ${errorText}`
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Activity delivery error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an activity to multiple inboxes
|
||||
*/
|
||||
export async function deliverToFollowers(
|
||||
activity: ActivityPubActivity,
|
||||
followerInboxes: string[],
|
||||
privateKey: string,
|
||||
keyId: string
|
||||
): Promise<{ delivered: number; failed: number }> {
|
||||
// Deduplicate inboxes (shared inboxes should only receive once)
|
||||
const uniqueInboxes = [...new Set(followerInboxes)];
|
||||
|
||||
let delivered = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Deliver in parallel with concurrency limit
|
||||
const concurrency = 10;
|
||||
for (let i = 0; i < uniqueInboxes.length; i += concurrency) {
|
||||
const batch = uniqueInboxes.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(inbox => deliverActivity(activity, inbox, privateKey, keyId))
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
delivered++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { delivered, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get followers' inboxes for delivery
|
||||
* Queries the remoteFollowers table for inbox URLs of remote users following this user
|
||||
*/
|
||||
export async function getFollowerInboxes(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const { db, remoteFollowers } = await import('@/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
if (!db) {
|
||||
console.warn('[Outbox] Database not available for follower query');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all remote followers of this user
|
||||
const followers = await db.query.remoteFollowers.findMany({
|
||||
where: eq(remoteFollowers.userId, userId),
|
||||
});
|
||||
|
||||
// Prefer shared inbox when available (more efficient)
|
||||
const inboxes = followers.map(f => f.sharedInboxUrl || f.inboxUrl);
|
||||
|
||||
// Deduplicate (shared inboxes may appear multiple times)
|
||||
return [...new Set(inboxes)];
|
||||
} catch (error) {
|
||||
console.error('[Outbox] Error fetching follower inboxes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* HTTP Signatures for ActivityPub
|
||||
*
|
||||
* ActivityPub uses HTTP Signatures to verify the authenticity of requests.
|
||||
* See: https://docs.joinmastodon.org/spec/security/
|
||||
*/
|
||||
|
||||
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from 'jose';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a new RSA keypair for an actor
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair(
|
||||
'rsa',
|
||||
{
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
},
|
||||
(err, publicKey, privateKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ publicKey, privateKey });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an HTTP request for ActivityPub
|
||||
*/
|
||||
export async function signRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: string | null,
|
||||
privateKeyPem: string,
|
||||
keyId: string
|
||||
): Promise<Record<string, string>> {
|
||||
const urlObj = new URL(url);
|
||||
const date = new Date().toUTCString();
|
||||
const digest = body ? `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}` : null;
|
||||
|
||||
// Build the string to sign
|
||||
const signedHeaders = body ? '(request-target) host date digest' : '(request-target) host date';
|
||||
let stringToSign = `(request-target): ${method.toLowerCase()} ${urlObj.pathname}`;
|
||||
stringToSign += `\nhost: ${urlObj.host}`;
|
||||
stringToSign += `\ndate: ${date}`;
|
||||
if (digest) {
|
||||
stringToSign += `\ndigest: ${digest}`;
|
||||
}
|
||||
|
||||
// Sign the string
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign('sha256', Buffer.from(stringToSign), privateKey).toString('base64');
|
||||
|
||||
// Build the signature header
|
||||
const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="${signedHeaders}",signature="${signature}"`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Date': date,
|
||||
'Signature': signatureHeader,
|
||||
};
|
||||
|
||||
if (digest) {
|
||||
headers['Digest'] = digest;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an HTTP signature from an incoming request
|
||||
*/
|
||||
export async function verifySignature(
|
||||
method: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
publicKeyPem: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const signatureHeader = headers['signature'] || headers['Signature'];
|
||||
if (!signatureHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the signature header
|
||||
const signatureParts: Record<string, string> = {};
|
||||
signatureHeader.split(',').forEach(part => {
|
||||
const [key, value] = part.split('=');
|
||||
signatureParts[key] = value?.replace(/^"|"$/g, '') ?? '';
|
||||
});
|
||||
|
||||
const signedHeadersList = signatureParts.headers?.split(' ') ?? [];
|
||||
const signature = signatureParts.signature;
|
||||
|
||||
if (!signature || signedHeadersList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reconstruct the string that was signed
|
||||
let stringToVerify = '';
|
||||
for (const header of signedHeadersList) {
|
||||
if (stringToVerify) {
|
||||
stringToVerify += '\n';
|
||||
}
|
||||
|
||||
if (header === '(request-target)') {
|
||||
stringToVerify += `(request-target): ${method.toLowerCase()} ${path}`;
|
||||
} else {
|
||||
const headerValue = headers[header] || headers[header.toLowerCase()];
|
||||
if (headerValue) {
|
||||
stringToVerify += `${header}: ${headerValue}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem);
|
||||
const signatureBuffer = Buffer.from(signature, 'base64');
|
||||
|
||||
return crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(stringToVerify),
|
||||
publicKey,
|
||||
signatureBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote actor's public key
|
||||
*/
|
||||
export async function fetchActorPublicKey(actorUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(actorUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = await response.json();
|
||||
return actor.publicKey?.publicKeyPem ?? null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch actor public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* WebFinger Protocol Implementation
|
||||
*
|
||||
* WebFinger is used to discover ActivityPub actors from acct: URIs.
|
||||
* See: https://www.rfc-editor.org/rfc/rfc7033
|
||||
*/
|
||||
|
||||
export interface WebFingerResponse {
|
||||
subject: string;
|
||||
aliases?: string[];
|
||||
links: WebFingerLink[];
|
||||
}
|
||||
|
||||
export interface WebFingerLink {
|
||||
rel: string;
|
||||
type?: string;
|
||||
href?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a WebFinger response for a local user
|
||||
*/
|
||||
export function generateWebFingerResponse(
|
||||
handle: string,
|
||||
nodeDomain: string
|
||||
): WebFingerResponse {
|
||||
const actorUrl = `https://${nodeDomain}/api/users/${handle}`;
|
||||
|
||||
return {
|
||||
subject: `acct:${handle}@${nodeDomain}`,
|
||||
aliases: [actorUrl],
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: actorUrl,
|
||||
},
|
||||
{
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/html',
|
||||
href: `https://${nodeDomain}/${handle}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a WebFinger resource query
|
||||
* @param resource - The resource query (e.g., "acct:user@domain.com")
|
||||
* @returns Object with handle and domain, or null if invalid
|
||||
*/
|
||||
export function parseWebFingerResource(resource: string): { handle: string; domain: string } | null {
|
||||
// Handle acct: URI format
|
||||
if (resource.startsWith('acct:')) {
|
||||
const parts = resource.slice(5).split('@');
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL format
|
||||
try {
|
||||
const url = new URL(resource);
|
||||
const pathParts = url.pathname.split('/');
|
||||
const usersIndex = pathParts.indexOf('users');
|
||||
if (usersIndex !== -1 && pathParts[usersIndex + 1]) {
|
||||
return { handle: pathParts[usersIndex + 1], domain: url.host };
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch WebFinger data for a remote user
|
||||
*/
|
||||
export async function fetchWebFinger(
|
||||
handle: string,
|
||||
domain: string
|
||||
): Promise<WebFingerResponse | null> {
|
||||
const resource = `acct:${handle}@${domain}`;
|
||||
const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/jrd+json, application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('WebFinger fetch failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ActivityPub actor URL from a WebFinger response
|
||||
*/
|
||||
export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
|
||||
const selfLink = webfinger.links.find((link) => {
|
||||
if (link.rel !== 'self' || !link.href) return false;
|
||||
if (!link.type) return true;
|
||||
const type = link.type.toLowerCase();
|
||||
return type.includes('activity+json') || type.includes('activitystreams') || type.includes('application/ld+json');
|
||||
});
|
||||
return selfLink?.href ?? null;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { db, users, sessions } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/activitypub/signatures';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function registerUser(
|
||||
throw new Error('Email is already registered');
|
||||
}
|
||||
|
||||
// Generate keys for ActivityPub
|
||||
// Generate cryptographic keys
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Create the user
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
* Remote Follows Sync
|
||||
*
|
||||
* Periodically syncs posts from remote users that local users follow.
|
||||
* This ensures the home timeline shows fresh posts from followed remote users.
|
||||
* Swarm-only implementation.
|
||||
*/
|
||||
|
||||
import { db, remoteFollows } from '@/db';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
||||
import { cacheSwarmUserPosts, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
|
||||
// Track last sync time per remote handle to avoid over-fetching
|
||||
@@ -23,6 +21,7 @@ interface SyncResult {
|
||||
|
||||
/**
|
||||
* Sync posts from all remote users that any local user follows
|
||||
* Now only syncs from swarm nodes
|
||||
*/
|
||||
export async function syncRemoteFollowsPosts(origin: string): Promise<SyncResult> {
|
||||
const result: SyncResult = { synced: 0, skipped: 0, errors: 0, details: [] };
|
||||
@@ -60,23 +59,17 @@ export async function syncRemoteFollowsPosts(origin: string): Promise<SyncResult
|
||||
const handle = targetHandle.slice(0, atIndex);
|
||||
const domain = targetHandle.slice(atIndex + 1);
|
||||
|
||||
// Check if this is a swarm node
|
||||
// Only sync from swarm nodes
|
||||
const isSwarm = await isSwarmNode(domain);
|
||||
|
||||
let cached = 0;
|
||||
if (isSwarm) {
|
||||
// Use swarm sync for swarm nodes
|
||||
const swarmResult = await cacheSwarmUserPosts(handle, domain, targetHandle, 20);
|
||||
cached = swarmResult.cached;
|
||||
} else {
|
||||
// Use ActivityPub sync for federated nodes
|
||||
const remoteProfile = await resolveRemoteUser(handle, domain);
|
||||
if (remoteProfile?.outbox) {
|
||||
const apResult = await cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20);
|
||||
cached = apResult.cached;
|
||||
}
|
||||
if (!isSwarm) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use swarm sync
|
||||
const swarmResult = await cacheSwarmUserPosts(handle, domain, targetHandle, 20);
|
||||
const cached = swarmResult.cached;
|
||||
|
||||
lastSyncTimes.set(targetHandle, now);
|
||||
|
||||
if (cached > 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Bot Manager Service
|
||||
*
|
||||
* Core orchestrator for bot lifecycle, configuration, and operations.
|
||||
* Handles bot CRUD operations, user linking, and ActivityPub key generation.
|
||||
* Handles bot CRUD operations, user linking, and cryptographic key generation.
|
||||
*
|
||||
* Bots are first-class users with their own profiles, handles, and posts.
|
||||
* Each bot has an owner (human user) who manages it.
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { generateKeyPair } from '@/lib/activitypub/signatures';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import {
|
||||
encryptApiKey,
|
||||
decryptApiKey,
|
||||
@@ -333,7 +333,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
|
||||
throw new BotHandleTakenError(config.handle);
|
||||
}
|
||||
|
||||
// Generate ActivityPub keys for the bot's user account
|
||||
// Generate cryptographic keys for the bot's user account
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt the API key and private key
|
||||
|
||||
@@ -549,7 +549,7 @@ export async function processAllMentions(botId: string): Promise<MentionResponse
|
||||
|
||||
/**
|
||||
* Store a detected mention in the database.
|
||||
* Used when mentions are detected from external sources (e.g., ActivityPub).
|
||||
* Used when mentions are detected from external sources (e.g., swarm nodes).
|
||||
*
|
||||
* @param data - Mention data to store
|
||||
* @returns Created mention
|
||||
|
||||
+20
-42
@@ -828,7 +828,7 @@ async function createPostInDatabase(
|
||||
|
||||
/**
|
||||
* Federate a post to remote followers.
|
||||
* Uses the bot's own user account for federation.
|
||||
* Uses swarm protocol for delivery to other Synapsis nodes.
|
||||
* This is a non-blocking operation that runs in the background.
|
||||
*
|
||||
* @param post - The post to federate
|
||||
@@ -861,46 +861,25 @@ async function federatePost(
|
||||
return;
|
||||
}
|
||||
|
||||
// Import federation modules
|
||||
const { createCreateActivity } = await import('@/lib/activitypub/activities');
|
||||
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Get follower inboxes for the bot's user account
|
||||
const followerInboxes = await getFollowerInboxes(bot.userId);
|
||||
if (followerInboxes.length === 0) {
|
||||
console.log('[Bot Federation] No remote followers to notify');
|
||||
return;
|
||||
}
|
||||
// Use swarm delivery for bot posts
|
||||
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
|
||||
|
||||
// Create ActivityPub Create activity using bot's user account
|
||||
const createActivity = createCreateActivity(post, botUser, nodeDomain);
|
||||
const swarmResult = await deliverPostToSwarmFollowers(
|
||||
bot.userId,
|
||||
post,
|
||||
{
|
||||
handle: botUser.handle,
|
||||
displayName: botUser.displayName,
|
||||
avatarUrl: botUser.avatarUrl,
|
||||
isNsfw: botUser.isNsfw,
|
||||
},
|
||||
[], // No media for now
|
||||
nodeDomain
|
||||
);
|
||||
|
||||
// Get private key for signing from bot's user account
|
||||
const privateKey = botUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
console.error('[Bot Federation] Bot user has no private key for signing');
|
||||
await logActivity(
|
||||
botId,
|
||||
'error',
|
||||
{
|
||||
type: 'federation',
|
||||
postId: post.id,
|
||||
error: 'No private key for signing',
|
||||
},
|
||||
false,
|
||||
'No private key for signing'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyId = `https://${nodeDomain}/users/${botUser.handle}#main-key`;
|
||||
|
||||
// Deliver to followers
|
||||
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
|
||||
|
||||
console.log(`[Bot Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
|
||||
console.log(`[Bot Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||
|
||||
// Log federation activity
|
||||
await logActivity(
|
||||
@@ -908,16 +887,15 @@ async function federatePost(
|
||||
'post_created',
|
||||
{
|
||||
postId: post.id,
|
||||
federation: {
|
||||
delivered: result.delivered,
|
||||
failed: result.failed,
|
||||
total: followerInboxes.length,
|
||||
swarm: {
|
||||
delivered: swarmResult.delivered,
|
||||
failed: swarmResult.failed,
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Bot Federation] Error federating post:', error);
|
||||
console.error('[Bot Swarm] Error delivering post:', error);
|
||||
|
||||
await logActivity(
|
||||
botId,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Cryptographic Key Generation
|
||||
*
|
||||
* Generates RSA key pairs for signing posts and verifying identity.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate an RSA key pair for signing
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair(
|
||||
'rsa',
|
||||
{
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
},
|
||||
(err, publicKey, privateKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ publicKey, privateKey });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 1. Seed nodes (like node.synapsis.social) provide initial bootstrap
|
||||
* 2. Gossip protocol spreads node/handle information epidemically
|
||||
* 3. Any node can discover the full network without central authority
|
||||
* 4. Direct node-to-node interactions (likes, follows, etc.) bypass ActivityPub
|
||||
* 4. Direct node-to-node interactions (likes, follows, etc.) for instant delivery
|
||||
*
|
||||
* Usage:
|
||||
* - On node startup: call announceToSeeds() to register with the network
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* Swarm Interactions
|
||||
*
|
||||
* Handles direct node-to-node interactions in the swarm network.
|
||||
* This is the "Swarm-first" approach - we try direct swarm communication
|
||||
* first, and fall back to ActivityPub for non-Synapsis nodes.
|
||||
* All interactions are delivered directly via the swarm protocol.
|
||||
*
|
||||
* Supported interactions:
|
||||
* - Likes: Direct like delivery between swarm nodes
|
||||
|
||||
@@ -190,11 +190,9 @@ export async function fetchSwarmTimeline(
|
||||
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
|
||||
|
||||
// Log filtering details for debugging
|
||||
if (!includeNsfw && result.posts.length > 0) {
|
||||
const nsfwPosts = result.posts.filter(p => p.isNsfw);
|
||||
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
|
||||
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`);
|
||||
}
|
||||
const nsfwPosts = result.posts.filter(p => p.isNsfw);
|
||||
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
|
||||
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts fetched, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter (includeNsfw: ${includeNsfw})`);
|
||||
|
||||
sources.push({
|
||||
domain: result.domain,
|
||||
|
||||
Reference in New Issue
Block a user