241 lines
6.2 KiB
TypeScript
241 lines
6.2 KiB
TypeScript
/**
|
|
* 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>');
|
|
}
|