Start VERY basic ActivityPub implementation - user endpoint and webfinger acct: support
All checks were successful
continuous-integration/drone Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Garrett Mills 2023-11-06 22:44:06 -06:00
parent 41cd00a413
commit 258abeb13a
6 changed files with 237 additions and 1 deletions

View File

@ -0,0 +1,41 @@
import {Controller, ErrorWithContext, http, HTTPStatus, Injectable, json} from '@extollo/lib'
import {User} from '../../../models/User.model'
@Injectable()
export class Webfinger extends Controller {
async getUser() {
const username = this.request.safe('username').string()
// @ts-ignore
const user = await User.query<User>()
.where('username', '=', username)
.first()
if ( !user ) {
return http(HTTPStatus.NOT_FOUND)
}
return json(await user.toPub())
}
async getWebfinger() {
const resource = this.request.safe('resource').string()
if ( !resource.startsWith('acct:') || !resource.endsWith('@garrettmills.dev') ) { // fixme
throw new ErrorWithContext('Invalid webfinger resource query', { resource })
}
const username = resource.slice('acct:'.length, -('@garrettmills.dev'.length)) // fixme
// @ts-ignore
const user = await User.query<User>()
.where('username', '=', username)
.first()
if ( !user ) {
return http(HTTPStatus.NOT_FOUND)
}
return json(await user.toWebfinger())
}
}

View File

@ -0,0 +1,10 @@
import {Route} from '@extollo/lib'
import {Webfinger} from '../controllers/pub/Webfinger.controller'
Route.get('/.well-known/webfinger')
.calls<Webfinger>(Webfinger, w => w.getWebfinger)
Route.group('/pub', () => {
Route.get('/:username')
.calls<Webfinger>(Webfinger, w => w.getUser)
})

View File

@ -0,0 +1,52 @@
import {Injectable, Migration, Inject, DatabaseService, FieldType} from '@extollo/lib'
/**
* CreatePubCertificatesTableMigration
* ----------------------------------
* Put some description here.
*/
@Injectable()
export default class CreatePubCertificatesTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
/**
* Apply the migration.
*/
async up(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('pub_certificates')
table.primaryKey('certificate_id')
table.column('reltype')
.type(FieldType.varchar)
table.column('relid')
.type(FieldType.bigint)
table.column('pubkey')
.type(FieldType.text)
table.column('privkey')
.type(FieldType.text)
table.index('pub_cert_rel')
.field('reltype')
.field('relid')
await schema.commit(table)
}
/**
* Undo the migration.
*/
async down(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('pub_certificates')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@ -1,5 +1,77 @@
import {ORMUser} from '@extollo/lib'
import {Maybe, ModelBuilder, ORMUser, Related} from '@extollo/lib'
import {Pub} from '../../pub/types'
import {Certificate} from './pub/Certificate.model'
import * as child_process from 'child_process'
export class User extends ORMUser {
get pubUrl(): string {
return `https://garrettmills.dev/pub/${this.username}`
}
async toWebfinger(): Promise<Pub.Webfinger> {
return {
subject: `acct:${this.username}@garrettmills.dev`, // fixme
links: [
{
rel: 'self',
type: 'application/activity+json',
href: this.pubUrl,
},
],
}
}
async toPub(): Promise<Pub.Actor> {
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
id: this.pubUrl,
type: 'Person',
preferredUsername: this.username,
inbox: `${this.pubUrl}/inbox`,
publicKey: {
id: `${this.pubUrl}#main-key`,
owner: this.pubUrl,
publicKeyPem: await this.getCertificate().then(c => c.pubkey),
},
}
}
certificate(): Promise<Maybe<Certificate>> {
return Certificate.query<Certificate>()
.where('reltype', '=', 'user')
.where('relid', '=', this.userId)
.first()
}
async getCertificate(): Promise<Certificate> {
const existing = await this.certificate()
if ( existing ) {
return existing
}
const certificate = this.make<Certificate>(Certificate)
certificate.reltype = 'user'
certificate.relid = this.userId
certificate.privkey = await this.exec('openssl genrsa -out - 2048')
certificate.pubkey = await this.exec(`echo "${certificate.privkey}" | openssl rsa -outform PEM -pubout -out -`)
await certificate.save()
return certificate
}
/** Dirty, dirty, dirty. @fixme */
private async exec(cmd: string): Promise<string> {
return new Promise<string>((res, rej) => {
child_process.exec(cmd, (err, stdout, stderr) => {
if ( err ) {
return rej(err)
}
res(stdout.trim())
})
})
}
}

View File

@ -0,0 +1,22 @@
import {Injectable, Model, Field, FieldType} from '@extollo/lib'
@Injectable()
export class Certificate extends Model<Certificate> {
protected static table = 'pub_certificates'
protected static key = 'certificate_id'
@Field(FieldType.serial, 'certificate_id')
public certificateId?: number
@Field(FieldType.varchar)
public reltype: 'user' = 'user'
@Field(FieldType.bigint)
public relid!: number
@Field(FieldType.text)
public pubkey!: string
@Field(FieldType.text)
public privkey!: string
}

39
src/pub/types/index.ts Normal file
View File

@ -0,0 +1,39 @@
export namespace Pub {
const ACCEPT_TYPE = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
export interface Config {
uri: string,
}
export interface Object {
id: string,
type: string, // FIXME
}
export interface Actor extends Object {
["@context"]: [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
type: 'Person',
preferredUsername: string,
inbox: string,
publicKey: {
id: string,
owner: string,
publicKeyPem: string,
},
}
export interface Link {
rel: string,
type: string,
href: string,
}
export interface Webfinger {
subject: string,
links: Link[],
}
}