Skip to content

Commit

Permalink
feat: activitypub rewrite
Browse files Browse the repository at this point in the history
  • Loading branch information
Frando committed Nov 10, 2023
1 parent 56820b4 commit 559734e
Show file tree
Hide file tree
Showing 32 changed files with 1,744 additions and 722 deletions.
1 change: 1 addition & 0 deletions packages/repco-activitypub/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.peertube-repl-history
5 changes: 5 additions & 0 deletions packages/repco-activitypub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# repco-activitypub

a minimal implementation of an activitypub server

only used to follow other actors, no publishing happens
16 changes: 0 additions & 16 deletions packages/repco-activitypub/bin.js

This file was deleted.

26 changes: 26 additions & 0 deletions packages/repco-activitypub/bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Dotenv from 'dotenv'
import express from 'express'
import { PrismaClient } from 'repco-prisma'
import { initActivityPub } from './src/server.js'

Dotenv.config()
Dotenv.config({ path: '../../.env' })

const prisma = new PrismaClient()
const app = express()

initActivityPub(prisma, app, {
prefix: '/ap',
api: {
prefix: '/api/ap',
auth: async (_req) => {
// todo: authentication
return true
},
},
})

const port = process.env.PORT || 8765
app.listen(port, () => {
console.log('listening on http://localhost:' + port)
})
29 changes: 0 additions & 29 deletions packages/repco-activitypub/contexts/attachements.json

This file was deleted.

5 changes: 0 additions & 5 deletions packages/repco-activitypub/contexts/context.json

This file was deleted.

25 changes: 12 additions & 13 deletions packages/repco-activitypub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"maintainers": [
"repco contributors"
],
"main": "./src/server.js",
"main": "./dist/src/server.js",
"type": "module",
"scripts": {
"build": "tsc --build",
Expand All @@ -17,20 +17,19 @@
"test": "brittle dist/test/*.js"
},
"dependencies": {
"activitypub-express": "repco-org/activitypub-express#fix_federation",
"connect-history-api-fallback": "^2.0.0",
"dotenv": "^16.0.1",
"@digitalbazaar/http-digest-header": "^2.0.0",
"activitypub-http-signatures": "^2.0.1",
"body-parser": "^1.18.3",
"express": "^4.18.1",
"mongodb": "^6.0.0",
"pino-http": "^8.3.3"
"express-async-errors": "^3.1.1",
"pino-http": "^8.3.3",
"repco-common": "*",
"repco-prisma": "*",
"undici": "^5.27.2",
"zod": "^3.19",
"zod-error": "^1.5.0"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/morgan": "^1.9.5",
"@urql/core": "^3.0.3",
"brittle": "^2.4.0",
"http-terminator": "^3.2.0",
"nodemon": "^2.0.19"
"@types/express": "^4.17.13"
}
}
11 changes: 11 additions & 0 deletions packages/repco-activitypub/peertube-repl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Repl from 'repl'
import { PeertubeClient } from './dist/src/util/peertube.js'

const pt = new PeertubeClient()
await pt.login()
console.log('Welcome to the PeerTube repl!')
console.log('Use fetch(path, init) to perform API requests')
const repl = Repl.start()
repl.setupHistory('.peertube-repl-history', () => {})
repl.context['pt'] = pt
repl.context['fetch'] = pt.fetch.bind(pt)
213 changes: 213 additions & 0 deletions packages/repco-activitypub/src/ap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import * as schema from './ap/schema.js'
import { randomBytes } from 'crypto'
import { EventEmitter } from 'events'
import { PrismaClient } from 'repco-prisma'
import { generateRsaKeypairPem, Keypair } from './ap/crypto.js'
import { fetchAp } from './ap/fetch.js'
import { ApiError } from './error.js'

const CTX = '@context'

export const CONTEXTS = {
activitystreams: 'https://www.w3.org/ns/activitystreams',
security: 'https://w3id.org/security/v1',
}

const extractId = (object: schema.ObjectId): string =>
typeof object === 'string' ? object : object.id

type LocalActorDb = {
name: string
keypair: Keypair
}

export type LocalActor = LocalActorDb & {
actor: ReturnType<typeof actorRecord>
}

export class ActivityPub extends EventEmitter {
public db: PrismaClient
public domain: string
public baseUrl: URL

constructor(db: PrismaClient, baseUrl: string | URL) {
super()
this.db = db
this.baseUrl = new URL(baseUrl)
this.domain = this.baseUrl.host
}

async getOrCreateActor(name: string) {
try {
return await this.getActor(name)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
return await this.createActor(name)
} else {
throw err
}
}
}

async getActor(name: string): Promise<LocalActor> {
const data = await this.db.apLocalActor.findUnique({
where: { name },
})
if (!data) throw new ApiError(404, 'Actor unknown: ' + name)
const keypair = data.keypair as Keypair
const actor = actorRecord(this.baseUrl, data.name, keypair.publicKeyPem)
return {
name: data.name,
keypair,
actor,
}
}

async createActor(name: string) {
const keypair = generateRsaKeypairPem()
const data = { name, keypair }
await this.db.apLocalActor.create({ data })
}

async listActors() {
const data = await this.db.apLocalActor.findMany()
return data.map((row) => row.name)
}

async followRemoteActor(localName: string, remoteHandle: string) {
const local = await this.getActor(localName)
const remote = await fetchActorFromWebfinger(remoteHandle)

const guid = randomBytes(16).toString('hex')
const message = {
[CTX]: CONTEXTS.activitystreams,
id: `${local.actor.id}/follows/${guid}`,
type: 'Follow',
actor: local.actor.id,
object: remote.id,
}
await this.send(local, remote, message)
await this.db.apFollowedActors.create({
data: {
localName,
remoteId: remote.id,
},
})
return message
}

async send(from: LocalActor, target: schema.Actor, data: any) {
if (!target || !target.id) {
throw new ApiError(
400,
'cannot send message: invalid actor object ' + JSON.stringify(target),
)
}
const inbox = target.sharedInbox || target.inbox
if (!inbox) {
throw new ApiError(400, 'missing inbox in actor object for ' + target.id)
}
const res = await fetchAp(inbox, {
data,
method: 'POST',
sign: {
privateKeyPem: from.keypair.privateKeyPem,
publicKeyId: from.actor.publicKey.id,
},
})
return res
}

async postInbox(input: any) {
const activity = schema.activity.parse(input)
const objectId = extractId(activity.object)
const data = {
id: activity.id,
actorId: activity.actor,
type: activity.type,
details: activity as any,
objectId: objectId,
receivedAt: new Date(),
}
await this.db.apMessages.create({ data })
this.emit('update', activity.actor, activity)
}

async getWebfingerRecord(name: string) {
// this throws if the actor does not exist
await this.getActor(name)
const url = this.baseUrl + '/u/' + name
return webfingerRecord(url, this.domain, name)
}

async getActorRecord(name: string) {
const actor = await this.getActor(name)
return actorRecord(this.baseUrl, actor.name, actor.keypair.publicKeyPem)
}
}

function webfingerRecord(url: URL | string, domain: string, username: string) {
return {
subject: `acct:${username}@${domain}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: url.toString(),
},
],
}
}

function actorRecord(url: URL | string, name: string, publicKeyPem: string) {
return {
[CTX]: [CONTEXTS.activitystreams, CONTEXTS.security],
id: `${url}/u/${name}`,
type: 'Person',
preferredUsername: `${name}`,
inbox: `${url}/inbox`,
sharedInbox: `${url}/inbox`,
outbox: `${url}/u/${name}/outbox`,
followers: `${url}/u/${name}/followers`,
publicKey: {
id: `${url}/u/${name}#main-key`,
owner: `${url}/u/${name}`,
publicKeyPem,
},
}
}

async function fetchActorFromWebfinger(handle: string): Promise<schema.Actor> {
const webfinger = await fetchWebfinger(handle)

const link = webfinger.links.find((link) => link.rel === 'self')
if (!link || !link.href) throw new ApiError(400, 'No self link in Webfinger')

const actor = await fetchAp(link.href)
return schema.actor.parse(actor)
}

async function fetchWebfinger(handle: string): Promise<schema.Webfinger> {
const url = parseHandle(handle)
const data = await fetchAp(url)
return schema.webfinger.parse(data)
}

function parseHandle(handle: string): string {
const match = handle.match(/^@?([^@]+)@(.+)$/)
if (!match) {
throw new ApiError(400, 'Invalid handle format')
}
const username = match[1]
let domain = match[2]
let url = domain
if (domain.startsWith('http://')) {
domain = domain.replace('http://', '')
} else if (domain.startsWith('https://')) {
domain = domain.replace('https://', '')
} else {
url = 'https://' + domain
}
url += `/.well-known/webfinger?resource=acct:${username}@${domain}`
return url
}
Loading

0 comments on commit 559734e

Please sign in to comment.