wildebeest/backend/src/activitypub/actors/index.ts

316 wiersze
7.9 KiB
TypeScript

import { defaultImages } from 'wildebeest/config/accounts'
import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops'
import { type APObject, sanitizeContent, getTextContent } from '../objects'
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
import { type Database } from 'wildebeest/backend/src/database'
import { Buffer } from 'buffer'
const PERSON = 'Person'
const isTesting = typeof jest !== 'undefined'
export const emailSymbol = Symbol()
export function actorURL(domain: string, id: string): URL {
return new URL(`/ap/users/${id}`, 'https://' + domain)
}
// https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
export interface Actor extends APObject {
inbox: URL
outbox: URL
following: URL
followers: URL
alsoKnownAs?: string
[emailSymbol]: string
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
export interface Person extends Actor {
publicKey: {
id: string
owner: URL
publicKeyPem: string
}
}
export async function get(url: string | URL): Promise<Actor> {
const headers = {
accept: 'application/activity+json',
}
const res = await fetch(url.toString(), { headers })
if (!res.ok) {
throw new Error(`${url} returned: ${res.status}`)
}
const data = await res.json<any>()
const actor: Actor = { ...data }
actor.id = new URL(actor.id)
if (actor.summary) {
actor.summary = await sanitizeContent(actor.summary)
if (actor.summary.length > 500) {
actor.summary = actor.summary.substring(0, 500)
}
}
if (actor.name) {
actor.name = await getTextContent(actor.name)
if (actor.name.length > 30) {
actor.name = actor.name.substring(0, 30)
}
}
if (actor.preferredUsername) {
actor.preferredUsername = await getTextContent(actor.preferredUsername)
if (actor.preferredUsername.length > 30) {
actor.preferredUsername = actor.preferredUsername.substring(0, 30)
}
}
// This is mostly for testing where for convenience not all values
// are provided.
// TODO: eventually clean that to better match production.
if (actor.inbox !== undefined) {
actor.inbox = new URL(actor.inbox)
}
if (actor.following !== undefined) {
actor.following = new URL(actor.following)
}
if (actor.followers !== undefined) {
actor.followers = new URL(actor.followers)
}
if (actor.outbox !== undefined) {
actor.outbox = new URL(actor.outbox)
}
return actor
}
// Get and cache the Actor locally
export async function getAndCache(url: URL, db: Database): Promise<Actor> {
{
const actor = await getActorById(db, url)
if (actor !== null) {
return actor
}
}
const actor = await get(url)
if (!actor.type || !actor.id) {
throw new Error('missing fields on Actor')
}
const properties = actor
const sql = `
INSERT INTO actors (id, type, properties)
VALUES (?, ?, ?)
`
const { success, error } = await db
.prepare(sql)
.bind(actor.id.toString(), actor.type, JSON.stringify(properties))
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
// Add peer
{
const domain = actor.id.host
await addPeer(db, domain)
}
return actor
}
export async function getPersonByEmail(db: Database, email: string): Promise<Person | null> {
const stmt = db.prepare('SELECT * FROM actors WHERE email=? AND type=?').bind(email, PERSON)
const { results } = await stmt.all()
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return personFromRow(row)
}
type PersonProperties = {
name?: string
summary?: string
icon?: { url: string }
image?: { url: string }
preferredUsername?: string
inbox?: string
outbox?: string
following?: string
followers?: string
}
// Create a local user
export async function createPerson(
domain: string,
db: Database,
userKEK: string,
email: string,
properties: PersonProperties = {},
admin: boolean = false
): Promise<Person> {
const userKeyPair = await generateUserKey(userKEK)
let privkey, salt
// Since D1 and better-sqlite3 behaviors don't exactly match, presumable
// because Buffer support is different in Node/Worker. We have to transform
// the values depending on the platform.
if (isTesting || db.client === 'neon') {
privkey = Buffer.from(userKeyPair.wrappedPrivKey)
salt = Buffer.from(userKeyPair.salt)
} else {
privkey = [...new Uint8Array(userKeyPair.wrappedPrivKey)]
salt = [...new Uint8Array(userKeyPair.salt)]
}
if (properties.preferredUsername === undefined) {
const parts = email.split('@')
properties.preferredUsername = parts[0]
}
if (properties.preferredUsername !== undefined && typeof properties.preferredUsername !== 'string') {
throw new Error(
`preferredUsername should be a string, received ${JSON.stringify(properties.preferredUsername)} instead`
)
}
const id = actorURL(domain, properties.preferredUsername).toString()
if (properties.inbox === undefined) {
properties.inbox = id + '/inbox'
}
if (properties.outbox === undefined) {
properties.outbox = id + '/outbox'
}
if (properties.following === undefined) {
properties.following = id + '/following'
}
if (properties.followers === undefined) {
properties.followers = id + '/followers'
}
const row = await db
.prepare(
`
INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties, is_admin)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`
)
.bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties), admin ? 1 : null)
.first()
return personFromRow(row)
}
export async function updateActorProperty(db: Database, actorId: URL, key: string, value: string) {
const { success, error } = await db
.prepare(`UPDATE actors SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`)
.bind(value, actorId.toString())
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function setActorAlias(db: Database, actorId: URL, alias: URL) {
const { success, error } = await db
.prepare(`UPDATE actors SET properties=json_set(properties, '$.alsoKnownAs', json_array(?)) WHERE id=?`)
.bind(alias.toString(), actorId.toString())
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function getActorById(db: Database, id: URL): Promise<Actor | null> {
const stmt = db.prepare('SELECT * FROM actors WHERE id=?').bind(id.toString())
const { results } = await stmt.all()
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return personFromRow(row)
}
export function personFromRow(row: any): Person {
const properties = JSON.parse(row.properties) as PersonProperties
const icon = properties.icon ?? {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.avatar),
id: new URL(row.id + '#icon'),
}
const image = properties.image ?? {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.header),
id: new URL(row.id + '#image'),
}
const preferredUsername = properties.preferredUsername
const name = properties.name ?? preferredUsername
let publicKey = null
if (row.pubkey !== null) {
publicKey = {
id: row.id + '#main-key',
owner: row.id,
publicKeyPem: row.pubkey,
}
}
const id = new URL(row.id)
let domain = id.hostname
if (row.original_actor_id) {
domain = new URL(row.original_actor_id).hostname
}
// Old local actors weren't created with inbox/outbox/etc properties, so add
// them if missing.
{
if (properties.inbox === undefined) {
properties.inbox = id + '/inbox'
}
if (properties.outbox === undefined) {
properties.outbox = id + '/outbox'
}
if (properties.following === undefined) {
properties.following = id + '/following'
}
if (properties.followers === undefined) {
properties.followers = id + '/followers'
}
}
return {
// Hidden values
[emailSymbol]: row.email,
...properties,
name,
icon,
image,
preferredUsername,
discoverable: true,
publicKey,
type: PERSON,
id,
published: new Date(row.cdate).toISOString(),
url: new URL('@' + preferredUsername, 'https://' + domain),
} as unknown as Person
}