🎯 Exemples recommandés
Balanced sample collections from various categories for you to explore
Protocole ActivityPub de Réseau Social Décentralisé
Exemples du protocole ActivityPub pour les réseaux sociaux fédérés, compatibilité Mastodon et médias sociaux décentralisés
💻 ActivityPub Hello World typescript
🟢 simple
⭐⭐⭐
Implémentation ActivityPub de base avec acteurs, objets, activités et fondamentaux de fédération
⏱️ 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
💻 Couche de Compatibilité Mastodon typescript
🟡 intermediate
⭐⭐⭐⭐
Créer des instances compatibles Mastodon avec support ActivityPub complet, timelines et fédération
⏱️ 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
💻 Routeur de Fédération et Relay typescript
🔴 complex
⭐⭐⭐⭐⭐
Routeur de fédération avancé avec services de relay, filtrage de contenu et communication inter-instances
⏱️ 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