ActivityPub 去中心化社交网络协议
ActivityPub 协议示例,用于联邦化社交网络、Mastodon 兼容性和去中心化社交媒体
💻 ActivityPub Hello World typescript
🟢 simple
⭐⭐⭐
基础 ActivityPub 实现,包含参与者、对象、活动和联邦化基础
⏱️ 30 min
🏷️ activitypub, federated, social, mastodon
Prerequisites:
JavaScript/TypeScript, HTTP protocols, JSON-LD, Web standards
// ActivityPub Hello World - Basic Federated Social Network
// Simple implementation of ActivityPub protocol concepts
interface ActivityPubActor {
'@context': ['https://www.w3.org/ns/activitystreams']
id: string
type: 'Person'
name: string
preferredUsername: string
summary?: string
inbox: string
outbox: string
followers: string
following: string
liked: string
publicKey: {
id: string
owner: string
publicKeyPem: string
}
icon?: {
type: 'Image'
url: string
}
image?: {
type: 'Image'
url: string
}
}
interface ActivityPubObject {
'@context': ['https://www.w3.org/ns/activitystreams']
id: string
type: 'Note' | 'Article' | 'Image' | 'Video'
attributedTo: string
content: string
published: string
to: string[]
cc: string[]
url?: string
attachment?: any[]
tag?: any[]
}
interface Activity {
'@context': ['https://www.w3.org/ns/activitystreams']
id: string
type: 'Create' | 'Update' | 'Delete' | 'Follow' | 'Like' | 'Announce' | 'Undo'
actor: string
object: ActivityPubObject | string | Activity
target?: string
to: string[]
cc: string[]
published: string
}
class ActivityPubHelloWorld {
private actors: Map<string, ActivityPubActor> = new Map()
private activities: Map<string, Activity> = new Map()
private objects: Map<string, ActivityPubObject> = new Map()
private followers: Map<string, Set<string>> = new Map()
private following: Map<string, Set<string>> = new Map()
constructor(private domain: string) {
console.log(`ActivityPub server for ${domain} initialized`)
}
// Generate unique ID
private generateId(type: string): string {
return `https://${this.domain}/${type}/${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
// Generate key pair for actor
private async generateKeyPair(): Promise<{ publicKeyPem: string; privateKeyPem: string }> {
// In a real implementation, use a proper crypto library
// This is a simplified example
return {
publicKeyPem: `-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA${this.generateId('key')}\\n-----END PUBLIC KEY-----`,
privateKeyPem: `-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC${this.generateId('key')}\\n-----END PRIVATE KEY-----`
}
}
// Create a new actor (user)
async createActor(username: string, name: string, summary?: string): Promise<ActivityPubActor> {
const actorId = `https://${this.domain}/users/${username}`
// Check if actor already exists
if (this.actors.has(actorId)) {
throw new Error('Actor already exists')
}
// Generate key pair
const { publicKeyPem } = await this.generateKeyPair()
const actor: ActivityPubActor = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: actorId,
type: 'Person',
name,
preferredUsername: username,
summary,
inbox: `${actorId}/inbox`,
outbox: `${actorId}/outbox`,
followers: `${actorId}/followers`,
following: `${actorId}/following`,
liked: `${actorId}/liked`,
publicKey: {
id: `${actorId}#main-key`,
owner: actorId,
publicKeyPem
},
icon: {
type: 'Image',
url: `https://${this.domain}/avatars/${username}.png`
}
}
// Store actor
this.actors.set(actorId, actor)
this.followers.set(actorId, new Set())
this.following.set(actorId, new Set())
console.log(`Created actor: ${actorId}`)
return actor
}
// Create and publish a note
async createNote(actorId: string, content: string, to: string[] = ['https://www.w3.org/ns/activitystreams#Public']): Promise<Activity> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Create the note object
const noteId = this.generateId('objects/note')
const note: ActivityPubObject = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: noteId,
type: 'Note',
attributedTo: actorId,
content,
published: new Date().toISOString(),
to,
cc: [actor.followers]
}
// Create the Create activity
const activityId = this.generateId('activities/create')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activityId,
type: 'Create',
actor: actorId,
object: note,
to,
cc: [actor.followers],
published: new Date().toISOString()
}
// Store objects and activity
this.objects.set(noteId, note)
this.activities.set(activityId, activity)
// Deliver to followers
await this.deliverToFollowers(activityId, actorId)
console.log(`Created note: ${noteId}`)
return activity
}
// Follow another actor
async followActor(actorId: string, targetActorId: string): Promise<Activity> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Create Follow activity
const activityId = this.generateId('activities/follow')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activityId,
type: 'Follow',
actor: actorId,
object: targetActorId,
to: [targetActorId],
published: new Date().toISOString()
}
// Store activity
this.activities.set(activityId, activity)
// Update following relationship
const followingSet = this.following.get(actorId)
if (followingSet) {
followingSet.add(targetActorId)
}
// Deliver to target actor
await this.deliverToActor(activityId, targetActorId)
console.log(`${actorId} followed ${targetActorId}`)
return activity
}
// Like an object
async likeObject(actorId: string, objectId: string): Promise<Activity> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Create Like activity
const activityId = this.generateId('activities/like')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activityId,
type: 'Like',
actor: actorId,
object: objectId,
to: [objectId],
published: new Date().toISOString()
}
// Store activity
this.activities.set(activityId, activity)
// Deliver to object owner
const object = this.objects.get(objectId)
if (object) {
await this.deliverToActor(activityId, object.attributedTo)
}
console.log(`${actorId} liked ${objectId}`)
return activity
}
// Announce (boost/retweet) an object
async announceObject(actorId: string, objectId: string): Promise<Activity> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Create Announce activity
const activityId = this.generateId('activities/announce')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activityId,
type: 'Announce',
actor: actorId,
object: objectId,
to: [actor.followers, 'https://www.w3.org/ns/activitystreams#Public'],
published: new Date().toISOString()
}
// Store activity
this.activities.set(activityId, activity)
// Deliver to followers
await this.deliverToFollowers(activityId, actorId)
console.log(`${actorId} announced ${objectId}`)
return activity
}
// Get actor's outbox
async getOutbox(actorId: string): Promise<Activity[]> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Get all activities by this actor
const actorActivities = Array.from(this.activities.values())
.filter(activity => activity.actor === actorId)
.sort((a, b) => new Date(b.published).getTime() - new Date(a.published).getTime())
return actorActivities
}
// Get actor's inbox
async getInbox(actorId: string): Promise<Activity[]> {
const actor = this.actors.get(actorId)
if (!actor) {
throw new Error('Actor not found')
}
// Get activities addressed to this actor
const addressedActivities = Array.from(this.activities.values())
.filter(activity =>
activity.to.includes(actorId) ||
activity.cc.includes(actorId) ||
activity.object === actorId
)
.sort((a, b) => new Date(b.published).getTime() - new Date(a.published).getTime())
return addressedActivities
}
// Accept a follow request
async acceptFollow(actorId: string, followActivityId: string): Promise<Activity> {
const followActivity = this.activities.get(followActivityId)
if (!followActivity || followActivity.type !== 'Follow') {
throw new Error('Invalid follow activity')
}
const followerId = followActivity.actor as string
// Create Accept activity
const activityId = this.generateId('activities/accept')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activityId,
type: 'Accept',
actor: actorId,
object: followActivity,
to: [followerId],
published: new Date().toISOString()
}
// Store activity
this.activities.set(activityId, activity)
// Update followers relationship
const followersSet = this.followers.get(actorId)
if (followersSet) {
followersSet.add(followerId)
}
// Deliver acceptance to follower
await this.deliverToActor(activityId, followerId)
console.log(`${actorId} accepted follow from ${followerId}`)
return activity
}
// Undo an activity
async undoActivity(actorId: string, activityId: string): Promise<Activity> {
const originalActivity = this.activities.get(activityId)
if (!originalActivity) {
throw new Error('Activity not found')
}
// Create Undo activity
const undoActivityId = this.generateId('activities/undo')
const activity: Activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: undoActivityId,
type: 'Undo',
actor: actorId,
object: originalActivity,
to: originalActivity.to,
cc: originalActivity.cc,
published: new Date().toISOString()
}
// Store activity
this.activities.set(undoActivityId, activity)
// Handle specific undo types
if (originalActivity.type === 'Follow') {
const targetId = originalActivity.object as string
const followingSet = this.following.get(actorId)
if (followingSet) {
followingSet.delete(targetId)
}
}
// Deliver to original recipients
await this.deliverToFollowers(undoActivityId, actorId)
console.log(`${actorId} undone activity: ${activityId}`)
return activity
}
// Server endpoints (simplified HTTP server example)
handleWebfinger(resource: string): any {
// Handle WebFinger discovery
if (resource.startsWith('acct:')) {
const [, address] = resource.split(':')
const [username, domain] = address.split('@')
if (domain === this.domain) {
const actorId = `https://${this.domain}/users/${username}`
return {
subject: `acct:${username}@${domain}`,
links: [{
rel: 'self',
type: 'application/activity+json',
href: actorId
}]
}
}
}
return null
}
handleActorRequest(actorId: string): ActivityPubActor | null {
return this.actors.get(actorId) || null
}
handleInboxRequest(actorId: string, activity: Activity): Promise<void> {
// Store incoming activity
this.activities.set(activity.id, activity)
// Handle activity types
switch (activity.type) {
case 'Follow':
// Automatically accept follows for demo
return this.acceptFollow(actorId, activity.id)
case 'Like':
case 'Announce':
// Store likes and announces
console.log(`Received ${activity.type} from ${activity.actor}`)
break
case 'Create':
// Handle new content creation
if (typeof activity.object === 'object') {
const object = activity.object as ActivityPubObject
this.objects.set(object.id, object)
}
break
default:
console.log(`Received ${activity.type} activity`)
}
return Promise.resolve()
}
// Federation delivery (simplified)
private async deliverToFollowers(activityId: string, actorId: string): Promise<void> {
const followersSet = this.followers.get(actorId)
if (!followersSet) return
const activity = this.activities.get(activityId)
if (!activity) return
// Deliver to each follower
for (const followerId of followersSet) {
await this.deliverToActor(activityId, followerId)
}
}
private async deliverToActor(activityId: string, targetActorId: string): Promise<void> {
// In a real implementation, this would:
// 1. Sign the activity with the sender's private key
// 2. Discover the target actor's inbox
// 3. Send HTTP POST request to the inbox
// 4. Handle retries and delivery failures
console.log(`Delivering activity ${activityId} to actor ${targetActorId}`)
// For demo purposes, we'll just log it
const activity = this.activities.get(activityId)
if (activity) {
console.log('Activity payload:', JSON.stringify(activity, null, 2))
}
}
// Get statistics
getStatistics() {
return {
actors: this.actors.size,
activities: this.activities.size,
objects: this.objects.size,
totalFollows: Array.from(this.following.values()).reduce((sum, set) => sum + set.size, 0),
totalFollowers: Array.from(this.followers.values()).reduce((sum, set) => sum + set.size, 0)
}
}
}
// Example usage and server setup
/*
import express from 'express'
const app = express()
const activityPub = new ActivityPubHelloWorld('example.com')
app.use(express.json({ type: 'application/activity+json' }))
app.use(express.json({ type: 'application/ld+json' }))
// WebFinger endpoint
app.get('/.well-known/webfinger', (req, res) => {
const resource = req.query.resource as string
const webfinger = activityPub.handleWebfinger(resource)
if (webfinger) {
res.json(webfinger)
} else {
res.status(404).json({ error: 'Not found' })
}
})
// Actor profile endpoint
app.get('/users/:username', (req, res) => {
const actorId = `https://example.com/users/${req.params.username}`
const actor = activityPub.handleActorRequest(actorId)
if (actor) {
res.json(actor)
} else {
res.status(404).json({ error: 'Actor not found' })
}
})
// Actor inbox
app.post('/users/:username/inbox', async (req, res) => {
const actorId = `https://example.com/users/${req.params.username}`
try {
await activityPub.handleInboxRequest(actorId, req.body)
res.status(200).send()
} catch (error) {
res.status(400).json({ error: error.message })
}
})
// Actor outbox
app.get('/users/:username/outbox', async (req, res) => {
const actorId = `https://example.com/users/${req.params.username}`
try {
const activities = await activityPub.getOutbox(actorId)
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${actorId}/outbox`,
type: 'OrderedCollection',
totalItems: activities.length,
orderedItems: activities
})
} catch (error) {
res.status(400).json({ error: error.message })
}
})
// Server endpoints
app.get('/server/stats', (req, res) => {
res.json(activityPub.getStatistics())
})
// Create new actor
app.post('/server/actors', async (req, res) => {
try {
const { username, name, summary } = req.body
const actor = await activityPub.createActor(username, name, summary)
res.status(201).json(actor)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
// Create a note
app.post('/server/note', async (req, res) => {
try {
const { actorId, content, to } = req.body
const activity = await activityPub.createNote(actorId, content, to)
res.status(201).json(activity)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
// Follow another actor
app.post('/server/follow', async (req, res) => {
try {
const { actorId, targetActorId } = req.body
const activity = await activityPub.followActor(actorId, targetActorId)
res.status(201).json(activity)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
app.listen(3000, () => {
console.log('ActivityPub server running on port 3000')
})
*/
// Client example for interacting with ActivityPub
class ActivityPubClient {
constructor(private domain: string) {}
async discoverActor(username: string): Promise<ActivityPubActor | null> {
try {
// WebFinger discovery
const webfingerResponse = await fetch(
`https://${this.domain}/.well-known/webfinger?resource=acct:${username}@${this.domain}`
)
if (!webfingerResponse.ok) {
return null
}
const webfinger = await webfingerResponse.json()
const selfLink = webfinger.links.find((link: any) => link.rel === 'self')
if (!selfLink) {
return null
}
// Fetch actor profile
const actorResponse = await fetch(selfLink.href, {
headers: {
'Accept': 'application/activity+json'
}
})
if (!actorResponse.ok) {
return null
}
return await actorResponse.json()
} catch (error) {
console.error('Error discovering actor:', error)
return null
}
}
async sendFollow(actorId: string, targetActorId: string, privateKeyPem: string): Promise<void> {
const followActivity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${this.domain}/activities/follow/${Date.now()}`,
type: 'Follow',
actor: actorId,
object: targetActorId
}
await this.deliverActivity(followActivity, privateKeyPem)
}
async createNote(actorId: string, content: string, privateKeyPem: string): Promise<void> {
const noteId = `https://${this.domain}/objects/note/${Date.now()}`
const activityId = `https://${this.domain}/activities/create/${Date.now()}`
const createActivity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: activityId,
type: 'Create',
actor: actorId,
object: {
id: noteId,
type: 'Note',
attributedTo: actorId,
content,
to: ['https://www.w3.org/ns/activitystreams#Public']
}
}
await this.deliverActivity(createActivity, privateKeyPem)
}
private async deliverActivity(activity: any, privateKeyPem: string): Promise<void> {
// In a real implementation:
// 1. Sign the HTTP Signature using privateKeyPem
// 2. Send to target's inbox
// 3. Handle delivery status
console.log('Delivering activity:', JSON.stringify(activity, null, 2))
}
}
// Usage example:
const server = new ActivityPubHelloWorld('example.com')
const client = new ActivityPubClient('mastodon.social')
// Create some actors
const alice = await server.createActor('alice', 'Alice Smith', 'Developer and tech enthusiast')
const bob = await server.createActor('bob', 'Bob Johnson', 'Designer')
// Create a note
const note = await server.createNote(alice.id, 'Hello federated world! #ActivityPub')
// Follow another user
const follow = await server.followActor(bob.id, alice.id)
// Accept the follow
const accept = await server.acceptFollow(alice.id, follow.id)
// Like the note
const like = await server.likeObject(bob.id, note.id)
// Get statistics
console.log(server.getStatistics())
export default ActivityPubHelloWorld
💻 Mastodon 兼容层 typescript
🟡 intermediate
⭐⭐⭐⭐
构建 Mastodon 兼容的实例,完整的 ActivityPub 支持、时间线和联邦化
⏱️ 40 min
🏷️ activitypub, mastodon, federated, social
Prerequisites:
ActivityPub basics, Mastodon API, HTTP protocols, Web standards
// Mastodon Compatibility Layer - ActivityPub Implementation
// Full Mastodon API compatibility with federation support
interface MastodonStatus {
id: string
created_at: string
in_reply_to_id?: string
in_reply_to_account_id?: string
sensitive: boolean
spoiler_text?: string
visibility: 'public' | 'unlisted' | 'private' | 'direct'
language?: string
uri: string
url: string
replies_count: number
reblogs_count: number
favourites_count: number
reblogged?: boolean
favourited?: boolean
muted?: boolean
bookmarked?: boolean
content: string
reblog?: MastodonStatus
application?: {
name: string
website?: string
}
account: MastodonAccount
media_attachments?: MastodonMedia[]
mentions?: MastodonMention[]
tags?: MastodonTag[]
emojis?: MastodonEmoji[]
card?: MastodonCard
poll?: MastodonPoll
}
interface MastodonAccount {
id: string
username: string
acct: string
display_name: string
locked: boolean
created_at: string
followers_count: number
following_count: number
statuses_count: number
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
emojis: MastodonEmoji[]
moved?: boolean
fields: Array<{
name: string
value: string
verified_at?: string
}>
bot: boolean
discoverable?: boolean
}
interface MastodonMedia {
id: string
type: 'image' | 'video' | 'gifv' | 'audio'
url: string
preview_url: string
remote_url?: string
preview_remote_url?: string
text_url?: string
meta: {
original: {
width: number
height: number
size: string
aspect: number
}
small: {
width: number
height: number
size: string
aspect: number
}
}
description?: string
blurhash?: string
}
interface MastodonMention {
id: string
username: string
url: string
acct: string
}
interface MastodonTag {
name: string
url: string
history?: Array<{
day: string
uses: string
accounts: string
}>
}
interface MastodonEmoji {
shortcode: string
url: string
static_url: string
visible_in_picker: boolean
category?: string
}
interface MastodonCard {
url: string
title: string
description: string
image?: string
type: 'link' | 'photo' | 'video' | 'rich'
author_name?: string
author_url?: string
provider_name?: string
provider_url?: string
html?: string
width?: number
height?: number
image?: string
embed_url?: string
blurhash?: string
}
interface MastodonPoll {
id: string
expires_at: string
expired: boolean
multiple: boolean
votes_count: number
voted: boolean
options: Array<{
title: string
votes_count: number
}>
emojis: MastodonEmoji[]
}
interface MastodonTimeline {
id: string
type: 'home' | 'local' | 'federated' | 'public'
items: MastodonStatus[]
}
class MastodonCompatibilityLayer {
private activityPub: ActivityPubHelloWorld
private statusMap = new Map<string, MastodonStatus>()
private accountMap = new Map<string, MastodonAccount>()
private timelines = new Map<string, MastodonTimeline>()
private notifications = new Map<string, MastodonNotification[]>()
constructor(private instanceDomain: string) {
this.activityPub = new ActivityPubHelloWorld(instanceDomain)
}
// Convert ActivityPub to Mastodon format
private async convertActivityToStatus(activity: Activity): Promise<MastodonStatus> {
const object = typeof activity.object === 'string'
? await this.activityPub['objects'].get(activity.object)
: activity.object as ActivityPubObject
if (!object) {
throw new Error('Object not found')
}
const actor = await this.activityPub['actors'].get(activity.actor)
if (!actor) {
throw new Error('Actor not found')
}
// Handle boosts/reblogs
if (activity.type === 'Announce') {
const originalStatus = await this.convertObjectToStatus(object as ActivityPubObject)
return {
id: activity.id,
created_at: activity.published,
reblog: originalStatus,
account: await this.convertActorToAccount(actor),
reblogged: true,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
sensitive: false,
visibility: 'public',
language: 'en',
uri: activity.id,
url: activity.id,
content: `Hello from ActivityPub!`,
note: `Status note`,
emojis: [],
media_attachments: [],
mentions: [],
tags: []
}
}
// Handle likes
if (activity.type === 'Like') {
throw new Error('Like activities cannot be converted to status')
}
return await this.convertObjectToStatus(object)
}
private async convertObjectToStatus(object: ActivityPubObject): Promise<MastodonStatus> {
const actor = await this.activityPub['actors'].get(object.attributedTo)
if (!actor) {
throw new Error('Actor not found')
}
// Extract content and mentions
const { content, mentions, tags } = this.parseContent(object.content)
// Determine visibility
const visibility = this.determineVisibility(object.to, object.cc)
// Convert attachments
const mediaAttachments = object.attachment ?
object.attachment.map(this.convertMediaAttachment) : []
return {
id: object.id,
created_at: object.published,
in_reply_to_id: this.getInReplyToId(object),
sensitive: false,
spoiler_text: '',
visibility,
language: 'en',
uri: object.url || object.id,
url: object.url || object.id,
replies_count: 0, // Would need to count replies
reblogs_count: 0, // Would need to count announces
favourites_count: 0, // Would need to count likes
content,
account: await this.convertActorToAccount(actor),
emojis: [],
media_attachments: mediaAttachments,
mentions,
tags: tags.map(tag => ({
name: tag.name,
url: `https://${this.instanceDomain}/tags/${tag.name}`
}))
}
}
private async convertActorToAccount(actor: ActivityPubActor): Promise<MastodonAccount> {
return {
id: actor.id,
username: actor.preferredUsername,
acct: `${actor.preferredUsername}@${new URL(actor.id).hostname}`,
display_name: actor.name,
locked: false,
created_at: new Date().toISOString(), // Would need to store creation time
followers_count: this.activityPub['followers'].get(actor.id)?.size || 0,
following_count: this.activityPub['following'].get(actor.id)?.size || 0,
statuses_count: this.activityPub['activities'].values()
.filter(a => a.actor === actor.id && a.type === 'Create').length,
note: actor.summary || '',
url: actor.id,
avatar: actor.icon?.url || '',
avatar_static: actor.icon?.url || '',
header: actor.image?.url || '',
header_static: actor.image?.url || '',
emojis: [],
bot: false,
discoverable: true,
fields: []
}
}
private convertMediaAttachment(attachment: any): MastodonMedia {
return {
id: attachment.id,
type: attachment.mediaType || 'image',
url: attachment.url,
preview_url: attachment.url,
description: attachment.name,
meta: {
original: {
width: attachment.width || 800,
height: attachment.height || 600,
size: 'unknown',
aspect: (attachment.width || 800) / (attachment.height || 600)
},
small: {
width: 100,
height: 75,
size: 'unknown',
aspect: 4/3
}
}
}
}
private parseContent(content: string): {
content: string
mentions: MastodonMention[]
tags: MastodonTag[]
} {
// Extract mentions from content
const mentionRegex = /<a href="([^"]+)"[^>]*>@([^<]+)<\/a>/g
const mentions: MastodonMention[] = []
let mentionMatch
while ((mentionMatch = mentionRegex.exec(content)) !== null) {
const url = mentionMatch[1]
const username = mentionMatch[2]
mentions.push({
id: url,
username,
url,
acct: username
})
}
// Extract hashtags from content
const hashtagRegex = /<a href="([^"]+)"[^>]*>#([^<]+)<\/a>/g
const tags: MastodonTag[] = []
let hashtagMatch
while ((hashtagMatch = hashtagRegex.exec(content)) !== null) {
const url = hashtagMatch[1]
const name = hashtagMatch[2]
tags.push({
name,
url
})
}
// Convert to plain text (simplified)
const plainContent = content
.replace(/<a href="[^"]+"[^>]*>@([^<]+)<\/a>/g, '@$1')
.replace(/<a href="[^"]+"[^>]*>#([^<]+)<\/a>/g, '#$1')
.replace(/<[^>]*>/g, '')
return {
content: plainContent,
mentions,
tags
}
}
private determineVisibility(to: string[], cc?: string[]): MastodonStatus['visibility'] {
if (to?.includes('https://www.w3.org/ns/activitystreams#Public')) {
return 'public'
}
if (cc?.includes('https://www.w3.org/ns/activitystreams#Public')) {
return 'unlisted'
}
return 'private'
}
private getInReplyToId(object: ActivityPubObject): string | undefined {
// This would need to be stored when creating replies
return undefined
}
// Mastodon API compatibility methods
// GET /api/v1/statuses/:id
async getStatus(id: string): Promise<MastodonStatus> {
// Check if we have this status cached
let status = this.statusMap.get(id)
if (!status) {
// Try to find the activity that created this object
const activity = Array.from(this.activityPub['activities'].values())
.find(a => a.type === 'Create' &&
typeof a.object === 'object' &&
(a.object as ActivityPubObject).id === id)
if (activity) {
status = await this.convertActivityToStatus(activity)
this.statusMap.set(id, status)
}
}
if (!status) {
throw new Error('Status not found')
}
return status
}
// POST /api/v1/statuses
async createStatus(params: {
status: string
in_reply_to_id?: string
sensitive?: boolean
spoiler_text?: string
visibility?: MastodonStatus['visibility']
media_ids?: string[]
language?: string
}, actorId: string): Promise<MastodonStatus> {
// Convert Mastodon parameters to ActivityPub
const to = this.visibilityToRecipients(params.visibility || 'public')
const content = this.addActivityPubFormatting(params.status)
// Handle reply
if (params.in_reply_to_id) {
const replyToStatus = await this.getStatus(params.in_reply_to_id)
to.push(replyToStatus.account.acct)
}
// Create the note
const activity = await this.activityPub.createNote(actorId, content, to)
// Convert to Mastodon format
const status = await this.convertActivityToStatus(activity)
// Add additional Mastodon-specific fields
if (params.spoiler_text) {
status.spoiler_text = params.spoiler_text
}
this.statusMap.set(status.id, status)
return status
}
// DELETE /api/v1/statuses/:id
async deleteStatus(id: string): Promise<MastodonStatus> {
// Find the Create activity for this status
const createActivity = Array.from(this.activityPub['activities'].values())
.find(a => a.type === 'Create' &&
typeof a.object === 'object' &&
(a.object as ActivityPubObject).id === id)
if (!createActivity) {
throw new Error('Status not found')
}
// Create Delete activity
const deleteActivity = await this.activityPub['undoActivity'](createActivity.actor, createActivity.id)
// Remove from cache
this.statusMap.delete(id)
// Return the deleted status
return await this.getStatus(id)
}
// POST /api/v1/statuses/:id/reblog
async reblogStatus(id: string, actorId: string): Promise<MastodonStatus> {
const activity = await this.activityPub.announceObject(actorId, id)
const status = await this.convertActivityToStatus(activity)
// Update reblogged status
const originalStatus = await this.getStatus(id)
originalStatus.reblogs_count++
this.statusMap.set(status.id, status)
this.statusMap.set(originalStatus.id, originalStatus)
return status
}
// POST /api/v1/statuses/:id/favourite
async favouriteStatus(id: string, actorId: string): Promise<MastodonStatus> {
const activity = await this.activityPub.likeObject(actorId, id)
const status = await this.getStatus(id)
// Update favourite count
status.favourites_count++
status.favourited = true
this.statusMap.set(status.id, status)
return status
}
// GET /api/v1/accounts/:id
async getAccount(id: string): Promise<MastodonAccount> {
let account = this.accountMap.get(id)
if (!account) {
const actor = this.activityPub['actors'].get(id)
if (actor) {
account = await this.convertActorToAccount(actor)
this.accountMap.set(id, account)
}
}
if (!account) {
throw new Error('Account not found')
}
return account
}
// GET /api/v1/accounts/verify_credentials
async verifyCredentials(actorId: string): Promise<MastodonAccount> {
return await this.getAccount(actorId)
}
// GET /api/v1/timelines/home
async getHomeTimeline(actorId: string, options: {
max_id?: string
since_id?: string
limit?: number
} = {}): Promise<MastodonStatus[]> {
const inbox = await this.activityPub.getInbox(actorId)
// Convert activities to statuses
const statuses: MastodonStatus[] = []
for (const activity of inbox) {
try {
if (activity.type === 'Create') {
const status = await this.convertActivityToStatus(activity)
if (!status.reblog) { // Don't include boosts in home timeline
statuses.push(status)
}
} else if (activity.type === 'Announce') {
const status = await this.convertActivityToStatus(activity)
if (status.reblog) {
statuses.push(status)
}
}
} catch (error) {
console.warn('Error converting activity to status:', error)
}
}
// Apply pagination filters
let filteredStatuses = statuses.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (options.max_id) {
const maxStatus = await this.getStatus(options.max_id)
const maxDate = new Date(maxStatus.created_at)
filteredStatuses = filteredStatuses.filter(s =>
new Date(s.created_at) < maxDate
)
}
if (options.since_id) {
const sinceStatus = await this.getStatus(options.since_id)
const sinceDate = new Date(sinceStatus.created_at)
filteredStatuses = filteredStatuses.filter(s =>
new Date(s.created_at) > sinceDate
)
}
if (options.limit) {
filteredStatuses = filteredStatuses.slice(0, options.limit)
}
return filteredStatuses
}
// GET /api/v1/timelines/public
async getPublicTimeline(options: {
local?: boolean
remote?: boolean
only_media?: boolean
max_id?: string
since_id?: string
limit?: number
} = {}): Promise<MastodonStatus[]> {
// Get all public activities
const allActivities = Array.from(this.activityPub['activities'].values())
.filter(activity => activity.to?.includes('https://www.w3.org/ns/activitystreams#Public'))
const statuses: MastodonStatus[] = []
for (const activity of allActivities) {
try {
if (activity.type === 'Create') {
const status = await this.convertActivityToStatus(activity)
// Apply filters
if (options.local && !status.account.acct.endsWith(`@${this.instanceDomain}`)) {
continue
}
if (options.remote && status.account.acct.endsWith(`@${this.instanceDomain}`)) {
continue
}
if (options.only_media && status.media_attachments.length === 0) {
continue
}
statuses.push(status)
}
} catch (error) {
console.warn('Error converting activity to status:', error)
}
}
// Sort and paginate
let filteredStatuses = statuses.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (options.limit) {
filteredStatuses = filteredStatuses.slice(0, options.limit)
}
return filteredStatuses
}
// POST /api/v1/accounts/:id/follow
async followAccount(actorId: string, targetAccountId: string): Promise<MastodonRelationship> {
const activity = await this.activityPub.followActor(actorId, targetAccountId)
return {
id: targetAccountId,
following: true,
showing_reblogs: true,
notifying: false,
blocked: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false
}
}
// POST /api/v1/accounts/:id/unfollow
async unfollowAccount(actorId: string, targetAccountId: string): Promise<MastodonRelationship> {
// Find the follow activity
const followActivity = Array.from(this.activityPub['activities'].values())
.find(a => a.type === 'Follow' && a.actor === actorId && a.object === targetAccountId)
if (followActivity) {
await this.activityPub.undoActivity(actorId, followActivity.id)
}
return {
id: targetAccountId,
following: false,
showing_reblogs: true,
notifying: false,
blocked: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false
}
}
// GET /api/v1/accounts/:id/followers
async getFollowers(id: string, options: { limit?: number } = {}): Promise<MastodonAccount[]> {
const followersSet = this.activityPub['followers'].get(id) || new Set()
const accounts: MastodonAccount[] = []
for (const followerId of followersSet) {
const account = await this.getAccount(followerId)
accounts.push(account)
}
// Sort by relationship creation time (would need to store this)
if (options.limit) {
return accounts.slice(0, options.limit)
}
return accounts
}
// GET /api/v1/accounts/:id/following
async getFollowing(id: string, options: { limit?: number } = {}): Promise<MastodonAccount[]> {
const followingSet = this.activityPub['following'].get(id) || new Set()
const accounts: MastodonAccount[] = []
for (const followingId of followingSet) {
const account = await this.getAccount(followingId)
accounts.push(account)
}
if (options.limit) {
return accounts.slice(0, options.limit)
}
return accounts
}
// Utility methods
private visibilityToRecipients(visibility: string): string[] {
switch (visibility) {
case 'public':
return ['https://www.w3.org/ns/activitystreams#Public']
case 'unlisted':
return []
case 'private':
return [] // Would need to add followers
case 'direct':
return [] // Would need to add specific recipients
default:
return ['https://www.w3.org/ns/activitystreams#Public']
}
}
private addActivityPubFormatting(text: string): string {
// Convert hashtags to ActivityPub format
let formatted = text.replace(/#(\w+)/g, '<a href="https://\' + this.instanceDomain + '/tags/$1" rel="tag">#$1</a>')
// Convert mentions to ActivityPub format (simplified)
formatted = formatted.replace(/@([\w.-]+@[\w.-]+)/g, (match, acct) => {
const [username, domain] = acct.split('@')
return `<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention">${match}</a></span>`
})
// Convert line breaks
formatted = formatted.replace(/\n/g, '<br>')
return formatted
}
// Server setup for Mastodon API
createServer() {
const express = require('express')
const app = express()
app.use(express.json())
// Status endpoints
app.get('/api/v1/statuses/:id', async (req, res) => {
try {
const status = await this.getStatus(req.params.id)
res.json(status)
} catch (error) {
res.status(404).json({ error: error.message })
}
})
app.post('/api/v1/statuses', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const status = await this.createStatus(req.body, actorId)
res.status(201).json(status)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
app.delete('/api/v1/statuses/:id', async (req, res) => {
try {
const status = await this.deleteStatus(req.params.id)
res.json(status)
} catch (error) {
res.status(404).json({ error: error.message })
}
})
app.post('/api/v1/statuses/:id/reblog', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const status = await this.reblogStatus(req.params.id, actorId)
res.json(status)
} catch (error) {
res.status(422).json({ error: error.message })
}
})
app.post('/api/v1/statuses/:id/favourite', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const status = await this.favouriteStatus(req.params.id, actorId)
res.json(status)
} catch (error) {
res.status(422).json({ error: error.message })
}
})
// Account endpoints
app.get('/api/v1/accounts/:id', async (req, res) => {
try {
const account = await this.getAccount(req.params.id)
res.json(account)
} catch (error) {
res.status(404).json({ error: error.message })
}
})
app.get('/api/v1/accounts/verify_credentials', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const account = await this.verifyCredentials(actorId)
res.json(account)
} catch (error) {
res.status(401).json({ error: 'Unauthorized' })
}
})
app.post('/api/v1/accounts/:id/follow', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const relationship = await this.followAccount(actorId, req.params.id)
res.json(relationship)
} catch (error) {
res.status(422).json({ error: error.message })
}
})
app.post('/api/v1/accounts/:id/unfollow', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const relationship = await this.unfollowAccount(actorId, req.params.id)
res.json(relationship)
} catch (error) {
res.status(422).json({ error: error.message })
}
})
app.get('/api/v1/accounts/:id/followers', async (req, res) => {
try {
const accounts = await this.getFollowers(req.params.id, req.query)
res.json(accounts)
} catch (error) {
res.status(404).json({ error: error.message })
}
})
app.get('/api/v1/accounts/:id/following', async (req, res) => {
try {
const accounts = await this.getFollowing(req.params.id, req.query)
res.json(accounts)
} catch (error) {
res.status(404).json({ error: error.message })
}
})
// Timeline endpoints
app.get('/api/v1/timelines/home', async (req, res) => {
try {
const actorId = req.headers['x-actor-id'] as string
const timeline = await this.getHomeTimeline(actorId, req.query)
res.json(timeline)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
app.get('/api/v1/timelines/public', async (req, res) => {
try {
const timeline = await this.getPublicTimeline(req.query)
res.json(timeline)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
return app
}
}
// Additional interfaces
interface MastodonNotification {
id: string
type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll' | 'follow_request'
created_at: string
account: MastodonAccount
status?: MastodonStatus
}
interface MastodonRelationship {
id: string
following: boolean
showing_reblogs: boolean
notifying: boolean
blocked: boolean
muting: boolean
muting_notifications: boolean
requested: boolean
domain_blocking: boolean
}
// Usage example:
/*
const mastodon = new MastodonCompatibilityLayer('mastodon.example.com')
const app = mastodon.createServer()
app.listen(3000, () => {
console.log('Mastodon-compatible server running on port 3000')
})
// Create an actor
await mastodon.activityPub.createActor('alice', 'Alice Smith', 'Developer')
// Create a post
const status = await mastodon.createStatus({
status: 'Hello from our Mastodon-compatible instance! #ActivityPub',
visibility: 'public'
}, 'https://mastodon.example.com/users/alice')
*/
export default MastodonCompatibilityLayer
💻 联邦化路由器和中继 typescript
🔴 complex
⭐⭐⭐⭐⭐
高级联邦化路由器,包含中继服务、内容过滤和跨实例通信
⏱️ 50 min
🏷️ activitypub, federation, relay, distributed
Prerequisites:
Advanced ActivityPub, HTTP protocols, Content filtering, Distributed systems
// ActivityPub Federation Router and Relay
// Advanced federation with content filtering, relays, and cross-instance communication
interface FederationInstance {
domain: string
software: string
version: string
openRegistrations: boolean
approvalRequired: boolean
inviteOnly: boolean
users: number
statusCount: number
lastActive: Date
adminEmail?: string
contactAccount?: string
rules: FederationRule[]
}
interface FederationRule {
id: string
type: 'content' | 'user' | 'instance' | 'interaction'
action: 'allow' | 'block' | 'filter' | 'report'
conditions: {
content?: string[]
users?: string[]
instances?: string[]
interactionTypes?: string[]
severity?: 'low' | 'medium' | 'high'
}
actions: {
blockDelivery?: boolean
quarantineContent?: boolean
notifyModerators?: boolean
addWarning?: boolean
rateLimit?: number
}
}
interface FederationStats {
totalInstances: number
activeInstances: number
totalUsers: number
totalPosts: number
messagesDelivered: number
messagesReceived: number
deliveryFailures: number
blockedInstances: number
topInstances: Array<{
domain: string
messages: number
lastActivity: Date
}>
}
interface RelaySubscription {
id: string
domain: string
subscribed: boolean
filters: string[]
priority: number
createdAt: Date
lastSync: Date
}
interface ContentFilter {
id: string
name: string
type: 'keyword' | 'regex' | 'ml' | 'domain' | 'language'
pattern: string
action: 'block' | 'filter' | 'flag' | 'transform'
enabled: boolean
severity: number
falsePositiveRate: number
lastUpdated: Date
}
interface FederationMessage {
id: string
type: Activity['type']
actor: string
object: any
target?: string
to: string[]
cc: string[]
origin: string
timestamp: Date
retries: number
delivered: boolean
blocked: boolean
processed: boolean
filterResults: FilterResult[]
}
interface FilterResult {
filterId: string
filterName: string
matched: boolean
score: number
action: string
reason?: string
}
class ActivityPubFederationRouter {
private instances: Map<string, FederationInstance> = new Map()
private blockedInstances: Set<string> = new Set()
private contentFilters: Map<string, ContentFilter> = new Map()
private relaySubscriptions: Map<string, RelaySubscription> = new Map()
private messageQueue: FederationMessage[] = []
private stats: FederationStats
private deliveryQueue: Map<string, FederationMessage[]> = new Map()
constructor(
private localDomain: string,
private options: {
enableRelay?: boolean
enableContentFiltering?: boolean
enableRateLimiting?: boolean
maxRetries?: number
batchSize?: number
relayEndpoints?: string[]
defaultFilters?: ContentFilter[]
} = {}
) {
this.stats = {
totalInstances: 0,
activeInstances: 0,
totalUsers: 0,
totalPosts: 0,
messagesDelivered: 0,
messagesReceived: 0,
deliveryFailures: 0,
blockedInstances: 0,
topInstances: []
}
this.setupDefaultFilters()
this.startBackgroundWorkers()
}
// Setup default content filters
private setupDefaultFilters(): void {
const defaultFilters: ContentFilter[] = [
{
id: 'spam-keywords',
name: 'Spam Keywords',
type: 'keyword',
pattern: 'viagra,casino,lottery,free money,click here',
action: 'block',
enabled: true,
severity: 8,
falsePositiveRate: 0.02,
lastUpdated: new Date()
},
{
id: 'harmful-content',
name: 'Harmful Content',
type: 'regex',
pattern: '\b(hate|violence|threat|abuse)\b',
action: 'filter',
enabled: true,
severity: 9,
falsePositiveRate: 0.05,
lastUpdated: new Date()
},
{
id: 'spam-patterns',
name: 'Spam Patterns',
type: 'regex',
pattern: '([a-zA-Z])\1{4,}|\b(\w+)\2{4,}',
action: 'filter',
enabled: true,
severity: 7,
falsePositiveRate: 0.1,
lastUpdated: new Date()
},
{
id: 'spam-domains',
name: 'Known Spam Domains',
type: 'domain',
pattern: 'spam.example,fake-site.example',
action: 'block',
enabled: true,
severity: 10,
falsePositiveRate: 0.01,
lastUpdated: new Date()
}
]
for (const filter of defaultFilters) {
this.contentFilters.set(filter.id, filter)
}
}
// Discover and register a new instance
async discoverInstance(domain: string): Promise<FederationInstance> {
console.log(`Discovering instance: ${domain}`)
try {
// Check if instance already exists
if (this.instances.has(domain)) {
return this.instances.get(domain)!
}
// Fetch instance information
const instanceInfo = await this.fetchInstanceInfo(domain)
// Create instance record
const instance: FederationInstance = {
domain,
software: instanceInfo.software,
version: instanceInfo.version,
openRegistrations: instanceInfo.registrations?.open || false,
approvalRequired: instanceInfo.registrations?.approval_required || false,
inviteOnly: instanceInfo.registrations?.approval_required || false,
users: instanceInfo.usage?.users?.total_count || 0,
statusCount: instanceInfo.usage?.local_posts || 0,
lastActive: new Date(),
adminEmail: instanceInfo.contact?.email,
contactAccount: instanceInfo.contact?.account,
rules: this.generateInstanceRules(instanceInfo)
}
// Store instance
this.instances.set(domain, instance)
this.stats.totalInstances++
this.stats.activeInstances++
// Check for federation rules
await this.checkFederationRules(instance)
console.log(`Discovered instance: ${domain} (${instance.software} ${instance.version})`)
return instance
} catch (error) {
console.error(`Failed to discover instance ${domain}:`, error)
throw error
}
}
// Fetch instance information via NodeInfo
private async fetchInstanceInfo(domain: string): Promise<any> {
try {
// Try NodeInfo 2.0 first
const nodeInfo2Response = await fetch(`https://${domain}/.well-known/nodeinfo`)
if (nodeInfo2Response.ok) {
const nodeInfo2 = await nodeInfo2Response.json()
if (nodeInfo2.links && nodeInfo2.links.length > 0) {
const nodeInfoUrl = nodeInfo2.links[0].href
const response = await fetch(nodeInfoUrl)
if (response.ok) {
return await response.json()
}
}
}
// Fallback to NodeInfo 1.0
const nodeInfo1Response = await fetch(`https://${domain}/.well-known/nodeinfo`)
if (nodeInfo1Response.ok) {
return await nodeInfo1Response.json()
}
throw new Error('NodeInfo not found')
} catch (error) {
console.warn(`Failed to fetch NodeInfo for ${domain}:`, error)
return {
software: 'unknown',
version: 'unknown',
registrations: { open: false }
}
}
}
// Generate instance rules based on instance info
private generateInstanceRules(instanceInfo: any): FederationRule[] {
const rules: FederationRule[] = []
// Rule for closed instances
if (!instanceInfo.registrations?.open) {
rules.push({
id: `closed-instance-${instanceInfo.domain}`,
type: 'instance',
action: 'filter',
conditions: {
instances: [instanceInfo.domain]
},
actions: {
blockDelivery: false,
quarantineContent: true,
notifyModerators: true
}
})
}
// Rule for instances with many users (potential spam)
if (instanceInfo.usage?.users?.total_count > 1000000) {
rules.push({
id: `high-activity-instance-${instanceInfo.domain}`,
type: 'instance',
action: 'filter',
conditions: {
instances: [instanceInfo.domain]
},
actions: {
rateLimit: 10,
quarantineContent: false
}
})
}
return rules
}
// Check federation rules against instance
private async checkFederationRules(instance: FederationInstance): Promise<void> {
for (const rule of this.globalFederationRules) {
if (this.evaluateRule(rule, instance)) {
await this.applyFederationRule(rule, instance)
}
}
}
// Evaluate federation rule
private evaluateRule(rule: FederationRule, instance: FederationInstance): boolean {
const conditions = rule.conditions
if (conditions.instances && conditions.instances.includes(instance.domain)) {
return true
}
if (conditions.software && conditions.software.includes(instance.software)) {
return true
}
if (conditions.users) {
if (conditions.users.min && instance.users < conditions.users.min) return false
if (conditions.users.max && instance.users > conditions.users.max) return false
}
return false
}
// Apply federation rule
private async applyFederationRule(rule: FederationRule, instance: FederationInstance): Promise<void> {
if (rule.actions.block) {
this.blockInstance(instance.domain)
}
if (rule.actions.quarantine) {
// Add to quarantine list
console.log(`Quarantined instance: ${instance.domain}`)
}
if (rule.actions.notifyAdmins) {
// Notify administrators
console.log(`Notifying admins about instance: ${instance.domain}`)
}
}
// Block an instance
blockInstance(domain: string): void {
this.blockedInstances.add(domain)
this.stats.blockedInstances++
this.stats.activeInstances--
console.log(`Blocked instance: ${domain}`)
}
// Unblock an instance
unblockInstance(domain: string): void {
if (this.blockedInstances.delete(domain)) {
this.stats.blockedInstances--
if (this.instances.has(domain)) {
this.stats.activeInstances++
}
console.log(`Unblocked instance: ${domain}`)
}
}
// Process incoming federation message
async processMessage(message: Partial<FederationMessage>): Promise<{
success: boolean
blocked: boolean
filtered: boolean
results: FilterResult[]
}> {
const federationMessage: FederationMessage = {
id: message.id || this.generateMessageId(),
type: message.type || 'Create',
actor: message.actor || '',
object: message.object,
target: message.target,
to: message.to || [],
cc: message.cc || [],
origin: message.origin || new URL(message.actor || '').hostname,
timestamp: message.timestamp || new Date(),
retries: message.retries || 0,
delivered: false,
blocked: false,
processed: false,
filterResults: []
}
this.stats.messagesReceived++
// Check if origin is blocked
if (this.blockedInstances.has(federationMessage.origin)) {
federationMessage.blocked = true
return {
success: false,
blocked: true,
filtered: false,
results: [{
filterId: 'blocked-instance',
filterName: 'Blocked Instance',
matched: true,
score: 100,
action: 'block',
reason: `Instance ${federationMessage.origin} is blocked`
}]
}
}
// Apply content filters
const filterResults = await this.applyContentFilters(federationMessage)
federationMessage.filterResults = filterResults
const shouldBlock = filterResults.some(result => result.action === 'block')
const shouldFilter = filterResults.some(result => result.action === 'filter')
federationMessage.blocked = shouldBlock
federationMessage.processed = true
if (!shouldBlock && !shouldFilter) {
// Queue for delivery
await this.queueForDelivery(federationMessage)
}
return {
success: !shouldBlock && !shouldFilter,
blocked: shouldBlock,
filtered: shouldFilter,
results: filterResults
}
}
// Apply content filters to message
private async applyContentFilters(message: FederationMessage): Promise<FilterResult[]> {
const results: FilterResult[] = []
for (const [filterId, filter] of this.contentFilters) {
if (!filter.enabled) continue
const result = await this.applyContentFilter(filter, message)
results.push(result)
if (result.matched && result.action === 'block') {
// Early exit on blocking filter
break
}
}
return results
}
// Apply individual content filter
private async applyContentFilter(filter: ContentFilter, message: FederationMessage): Promise<FilterResult> {
const content = this.extractContentForFiltering(message)
let matched = false
let score = 0
switch (filter.type) {
case 'keyword':
matched = this.checkKeywordFilter(filter.pattern, content)
score = matched ? filter.severity : 0
break
case 'regex':
matched = this.checkRegexFilter(filter.pattern, content)
score = matched ? filter.severity : 0
break
case 'domain':
matched = this.checkDomainFilter(filter.pattern, message.actor)
score = matched ? filter.severity : 0
break
case 'language':
matched = this.checkLanguageFilter(filter.pattern, content)
score = matched ? filter.severity : 0
break
case 'ml':
// Machine learning filter (simplified)
matched = await this.checkMLFilter(content)
score = matched ? filter.severity : 0
break
}
return {
filterId,
filterName: filter.name,
matched,
score,
action: matched ? filter.action : 'allow',
reason: matched ? `Matched ${filter.type} filter` : undefined
}
}
// Extract content from message for filtering
private extractContentForFiltering(message: FederationMessage): string {
let content = ''
if (typeof message.object === 'object') {
const obj = message.object as any
content += obj.content || ''
content += obj.name || ''
}
content += message.type || ''
content += message.actor || ''
return content.toLowerCase()
}
// Check keyword filter
private checkKeywordFilter(pattern: string, content: string): boolean {
const keywords = pattern.split(',')
return keywords.some(keyword => content.includes(keyword.trim().toLowerCase()))
}
// Check regex filter
private checkRegexFilter(pattern: string, content: string): boolean {
try {
const regex = new RegExp(pattern, 'i')
return regex.test(content)
} catch (error) {
console.warn(`Invalid regex pattern: ${pattern}`, error)
return false
}
}
// Check domain filter
private checkDomainFilter(pattern: string, actorUrl: string): boolean {
const domains = pattern.split(',')
try {
const actorDomain = new URL(actorUrl).hostname
return domains.some(domain => actorDomain === domain.trim().toLowerCase())
} catch (error) {
return false
}
}
// Check language filter
private checkLanguageFilter(pattern: string, content: string): boolean {
// Simplified language detection
// In a real implementation, use a proper language detection library
const suspiciousPatterns = ['\u4e00-\u9fff', '\u0600-\u06ff'] // Chinese, Arabic
return suspiciousPatterns.some(pattern => {
const regex = new RegExp(pattern)
return regex.test(content)
})
}
// Check ML filter
private async checkMLFilter(content: string): Promise<boolean> {
// Simplified ML-based spam detection
const spamIndicators = [
/[a-zA-Z]\1{4,}/, // Repeated characters
/\b(buy now|click here|free money)\b/i, // Spam phrases
content.length < 10, // Very short content
content.length > 10000 // Very long content
]
return spamIndicators.some(indicator => indicator)
}
// Queue message for delivery
private async queueForDelivery(message: FederationMessage): Promise<void> {
// Add to delivery queues for each recipient
const recipients = [...message.to, ...message.cc]
for (const recipient of recipients) {
if (!this.deliveryQueue.has(recipient)) {
this.deliveryQueue.set(recipient, [])
}
this.deliveryQueue.get(recipient)!.push(message)
}
// Also queue for relay subscriptions
await this.queueForRelay(message)
}
// Queue for relay services
private async queueForRelay(message: FederationMessage): Promise<void> {
if (!this.options.enableRelay) return
for (const [relayId, subscription] of this.relaySubscriptions) {
if (!subscription.subscribed) continue
// Check if message matches relay filters
if (this.matchesRelayFilters(message, subscription.filters)) {
if (!this.deliveryQueue.has(relayId)) {
this.deliveryQueue.set(relayId, [])
}
this.deliveryQueue.get(relayId)!.push(message)
}
}
}
// Check if message matches relay filters
private matchesRelayFilters(message: FederationMessage, filters: string[]): boolean {
if (filters.length === 0) return true
const content = this.extractContentForFiltering(message)
return filters.some(filter => {
try {
return new RegExp(filter, 'i').test(content)
} catch (error) {
return false
}
})
}
// Start background workers
private startBackgroundWorkers(): void {
// Message delivery worker
setInterval(() => {
this.processDeliveryQueue()
}, 5000) // Process every 5 seconds
// Instance discovery worker
setInterval(() => {
this.discoverInstances()
}, 60000) // Check for new instances every minute
// Statistics update worker
setInterval(() => {
this.updateStatistics()
}, 30000) // Update stats every 30 seconds
// Health check worker
setInterval(() => {
this.performHealthChecks()
}, 300000) // Health check every 5 minutes
}
// Process delivery queue
private async processDeliveryQueue(): Promise<void> {
const batchSize = this.options.batchSize || 10
for (const [recipient, messages] of this.deliveryQueue.entries()) {
if (messages.length === 0) continue
// Take a batch of messages
const batch = messages.slice(0, batchSize)
const remaining = messages.slice(batchSize)
// Update queue
this.deliveryQueue.set(recipient, remaining)
// Process batch
await this.deliverBatch(recipient, batch)
}
}
// Deliver batch of messages
private async deliverBatch(recipient: string, messages: FederationMessage[]): Promise<void> {
for (const message of messages) {
try {
await this.deliverMessage(message)
message.delivered = true
this.stats.messagesDelivered++
} catch (error) {
console.error(`Delivery failed for ${message.id} to ${recipient}:`, error)
message.retries++
if (message.retries < (this.options.maxRetries || 3)) {
// Re-queue for retry
if (!this.deliveryQueue.has(recipient)) {
this.deliveryQueue.set(recipient, [])
}
this.deliveryQueue.get(recipient)!.push(message)
} else {
this.stats.deliveryFailures++
}
}
}
}
// Deliver individual message
private async deliverMessage(message: FederationMessage): Promise<void> {
// In a real implementation, this would:
// 1. Sign the HTTP Signature
// 2. Send HTTP POST to recipient's inbox
// 3. Handle authentication and rate limiting
console.log(`Delivering message ${message.id} to ${message.actor}`)
// Simulate delivery time
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000))
}
// Discover new instances
private async discoverInstances(): Promise<void> {
// In a real implementation, this would:
// 1. Check known instance directories
// 2. Parse WebFinger responses
// 3. Analyze federation traffic patterns
const knownInstances = [
'mastodon.social',
'mas.to',
'social.uba.garden',
'mathstodon.xyz',
'hachyderm.net'
]
for (const domain of knownInstances) {
if (!this.instances.has(domain)) {
try {
await this.discoverInstance(domain)
} catch (error) {
console.warn(`Failed to discover ${domain}:`, error)
}
}
}
}
// Update federation statistics
private updateStatistics(): void {
this.stats.totalUsers = Array.from(this.instances.values())
.reduce((sum, instance) => sum + instance.users, 0)
this.stats.totalPosts = Array.from(this.instances.values())
.reduce((sum, instance) => sum + instance.statusCount, 0)
// Update top instances
const instanceStats = Array.from(this.instances.entries())
.map(([domain, instance]) => ({
domain,
messages: this.stats.messagesDelivered,
lastActivity: instance.lastActive
}))
.sort((a, b) => b.messages - a.messages)
.slice(0, 10)
this.stats.topInstances = instanceStats
}
// Perform health checks on known instances
private async performHealthChecks(): Promise<void> {
for (const [domain, instance] of this.instances) {
try {
// Check if instance is responding
const response = await fetch(`https://${domain}/.well-known/nodeinfo`)
const isHealthy = response.ok
if (isHealthy && !this.blockedInstances.has(domain)) {
instance.lastActive = new Date()
} else if (!isHealthy && !this.blockedInstances.has(domain)) {
// Instance appears to be down
console.warn(`Instance ${domain} appears to be down`)
}
} catch (error) {
console.warn(`Health check failed for ${domain}:`, error)
}
}
}
// Generate unique message ID
private generateMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// Get federation statistics
getStatistics(): FederationStats {
return { ...this.stats }
}
// Get instance information
getInstance(domain: string): FederationInstance | undefined {
return this.instances.get(domain)
}
// Get all instances
getAllInstances(): FederationInstance[] {
return Array.from(this.instances.values())
}
// Get blocked instances
getBlockedInstances(): string[] {
return Array.from(this.blockedInstances)
}
// Add content filter
addContentFilter(filter: ContentFilter): void {
this.contentFilters.set(filter.id, filter)
console.log(`Added content filter: ${filter.name}`)
}
// Remove content filter
removeContentFilter(filterId: string): void {
this.contentFilters.delete(filterId)
console.log(`Removed content filter: ${filterId}`)
}
// Subscribe to relay
async subscribeToRelay(relayId: string, filters: string[] = []): Promise<RelaySubscription> {
const subscription: RelaySubscription = {
id: this.generateMessageId(),
domain: relayId,
subscribed: true,
filters,
priority: 1,
createdAt: new Date(),
lastSync: new Date()
}
this.relaySubscriptions.set(relayId, subscription)
console.log(`Subscribed to relay: ${relayId}`)
return subscription
}
// Unsubscribe from relay
unsubscribeFromRelay(relayId: string): void {
const subscription = this.relaySubscriptions.get(relayId)
if (subscription) {
subscription.subscribed = false
console.log(`Unsubscribed from relay: ${relayId}`)
}
}
// Global federation rules
private globalFederationRules: FederationRule[] = [
{
id: 'spam-instances',
name: 'Known Spam Instances',
type: 'instance',
action: 'block',
conditions: {
instances: [
'spam.example.com',
'fake.social.example.com'
]
},
actions: {
blockDelivery: true,
notifyModerators: true,
addWarning: true
}
},
{
id: 'closed-instances',
name: 'Closed Registration Instances',
type: 'instance',
action: 'filter',
conditions: {
instances: [] // Will be populated dynamically
},
actions: {
blockDelivery: false,
quarantineContent: true,
notifyModerators: false
}
},
{
id: 'high-activity-instances',
name: 'High Activity Filter',
type: 'instance',
action: 'filter',
conditions: {
users: { max: 1000000 }
},
actions: {
rateLimit: 50,
blockDelivery: false
}
}
]
}
// Usage example:
/*
const router = new ActivityPubFederationRouter('mastodon.example.com', {
enableRelay: true,
enableContentFiltering: true,
enableRateLimiting: true,
maxRetries: 3,
batchSize: 10,
relayEndpoints: ['relay.fediverse.network', 'relay.example.com']
})
// Discover some instances
await router.discoverInstance('mastodon.social')
await router.discoverInstance('mas.to')
// Add custom content filter
router.addContentFilter({
id: 'custom-spam',
name: 'Custom Spam Filter',
type: 'regex',
pattern: '\b(wow|amazing|incredible)\b',
action: 'filter',
enabled: true,
severity: 3,
falsePositiveRate: 0.1,
lastUpdated: new Date()
})
// Process incoming message
const result = await router.processMessage({
type: 'Create',
actor: 'https://example.com/users/alice',
object: {
type: 'Note',
content: 'Hello federated world!'
},
to: ['https://mastodon.social/users/bob'],
cc: [],
origin: 'example.com'
})
console.log('Processing result:', result)
// Get statistics
const stats = router.getStatistics()
console.log('Federation statistics:', stats)
// Subscribe to relay
await router.subscribeToRelay('relay.fediverse.network', ['public', 'unlisted'])
*/
export default ActivityPubFederationRouter