-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
32 changed files
with
1,744 additions
and
722 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
.peertube-repl-history |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.