wildebeest/backend/src/activitypub/activities/handle.ts

361 wiersze
11 KiB
TypeScript

import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
import * as objects from 'wildebeest/backend/src/activitypub/objects'
import * as accept from 'wildebeest/backend/src/activitypub/activities/accept'
import { addObjectInInbox } from 'wildebeest/backend/src/activitypub/actors/inbox'
import {
sendMentionNotification,
sendLikeNotification,
sendFollowNotification,
createNotification,
insertFollowNotification,
sendReblogNotification,
} from 'wildebeest/backend/src/mastodon/notification'
import { type APObject, updateObject } from 'wildebeest/backend/src/activitypub/objects'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver'
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { createReblog } from 'wildebeest/backend/src/mastodon/reblog'
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
import { originalActorIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
function extractID(domain: string, s: string | URL): string {
return s.toString().replace(`https://${domain}/ap/users/`, '')
}
export function makeGetObjectAsId(activity: Activity) {
return () => {
let url: any = null
if (activity.object.id !== undefined) {
url = activity.object.id
}
if (typeof activity.object === 'string') {
url = activity.object
}
if (activity.object instanceof URL) {
// This is used for testing only.
return activity.object as URL
}
if (url === null) {
throw new Error('unknown value: ' + JSON.stringify(activity.object))
}
try {
return new URL(url)
} catch (err) {
console.warn('invalid URL: ' + url)
throw err
}
}
}
export function makeGetActorAsId(activity: Activity) {
return () => {
let url: any = null
if (activity.actor.id !== undefined) {
url = activity.actor.id
}
if (typeof activity.actor === 'string') {
url = activity.actor
}
if (activity.actor instanceof URL) {
// This is used for testing only.
return activity.actor as URL
}
if (url === null) {
throw new Error('unknown value: ' + JSON.stringify(activity.actor))
}
try {
return new URL(url)
} catch (err) {
console.warn('invalid URL: ' + url)
throw err
}
}
}
export async function handle(
domain: string,
activity: Activity,
db: D1Database,
userKEK: string,
adminEmail: string,
vapidKeys: JWK
) {
// The `object` field of the activity is required to be an object, with an
// `id` and a `type` field.
const requireComplexObject = () => {
if (typeof activity.object !== 'object') {
throw new Error('`activity.object` must be of type object')
}
}
const getObjectAsId = makeGetObjectAsId(activity)
const getActorAsId = makeGetActorAsId(activity)
switch (activity.type) {
case 'Update': {
requireComplexObject()
const actorId = getActorAsId()
const objectId = getObjectAsId()
if (!['Note', 'Person', 'Service'].includes(activity.object.type)) {
console.warn('unsupported Update for Object type: ' + activity.object.type)
return
}
// check current object
const object = await objects.getObjectBy(db, 'original_object_id', objectId.toString())
if (object === null) {
throw new Error(`object ${objectId} does not exist`)
}
if (actorId.toString() !== object[originalActorIdSymbol]) {
throw new Error('actorid mismatch when updating object')
}
const updated = await updateObject(db, activity.object, object.id)
if (!updated) {
throw new Error('could not update object in database')
}
break
}
// https://www.w3.org/TR/activitypub/#create-activity-inbox
case 'Create': {
requireComplexObject()
const actorId = getActorAsId()
// FIXME: download any attachment Objects
let recipients: Array<string> = []
let target = PUBLIC_GROUP
if (Array.isArray(activity.to) && activity.to.length > 0) {
recipients = [...recipients, ...activity.to]
if (activity.to.length !== 1) {
console.warn("multiple `Activity.to` isn't supported")
}
target = activity.to[0]
}
if (Array.isArray(activity.cc) && activity.cc.length > 0) {
recipients = [...recipients, ...activity.cc]
}
const objectId = getObjectAsId()
const res = await cacheObject(domain, activity.object, db, actorId, objectId)
if (res === null) {
break
}
if (!res.created) {
// Object already existed in our database. Probably a duplicated
// message
break
}
const obj = res.object
const actor = await actors.getAndCache(actorId, db)
// This note is actually a reply to another one, record it in the replies
// table.
if (obj.type === 'Note' && obj.inReplyTo) {
const inReplyToObjectId = new URL(obj.inReplyTo)
let inReplyToObject = await objects.getObjectByOriginalId(db, inReplyToObjectId)
if (inReplyToObject === null) {
const remoteObject = await objects.get(inReplyToObjectId)
const res = await objects.cacheObject(domain, db, remoteObject, actorId, inReplyToObjectId, false)
inReplyToObject = res.object
}
await insertReply(db, actor, obj, inReplyToObject)
}
const fromActor = await actors.getAndCache(getActorAsId(), db)
// Add the object in the originating actor's outbox, allowing other
// actors on this instance to see the note in their timelines.
await addObjectInOutbox(db, fromActor, obj, activity.published, target)
for (let i = 0, len = recipients.length; i < len; i++) {
const url = new URL(recipients[i])
if (url.hostname !== domain) {
console.warn('recipients is not for this instance')
continue
}
const handle = parseHandle(extractID(domain, recipients[i]))
if (handle.domain !== null && handle.domain !== domain) {
console.warn('activity not for current instance')
continue
}
const person = await actors.getActorById(db, actorURL(domain, handle.localPart))
if (person === null) {
console.warn(`person ${recipients[i]} not found`)
continue
}
// FIXME: check if the actor mentions the person
const notifId = await createNotification(db, 'mention', person, fromActor, obj)
await Promise.all([
await addObjectInInbox(db, person, obj),
await sendMentionNotification(db, fromActor, person, notifId, adminEmail, vapidKeys),
])
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept
case 'Accept': {
requireComplexObject()
const actorId = getActorAsId()
const actor = await actors.getActorById(db, activity.object.actor)
if (actor !== null) {
const follower = await actors.getAndCache(new URL(actorId), db)
await acceptFollowing(db, actor, follower)
} else {
console.warn(`actor ${activity.object.actor} not found`)
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow
case 'Follow': {
const objectId = getObjectAsId()
const actorId = getActorAsId()
const receiver = await actors.getActorById(db, objectId)
if (receiver !== null) {
const originalActor = await actors.getAndCache(new URL(actorId), db)
const receiverAcct = `${receiver.preferredUsername}@${domain}`
await addFollowing(db, originalActor, receiver, receiverAcct)
// Automatically send the Accept reply
await acceptFollowing(db, originalActor, receiver)
const reply = accept.create(receiver, activity)
const signingKey = await getSigningKey(userKEK, db, receiver)
await deliverToActor(signingKey, receiver, originalActor, reply, domain)
// Notify the user
const notifId = await insertFollowNotification(db, receiver, originalActor)
await sendFollowNotification(db, originalActor, receiver, notifId, adminEmail, vapidKeys)
} else {
console.warn(`actor ${objectId} not found`)
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce
case 'Announce': {
const actorId = getActorAsId()
const objectId = getObjectAsId()
let obj: any = null
const localObject = await objects.getObjectById(db, objectId)
if (localObject === null) {
try {
// Object doesn't exists locally, we'll need to download it.
const remoteObject = await objects.get<Note>(objectId)
const res = await cacheObject(domain, remoteObject, db, actorId, objectId)
if (res === null) {
break
}
obj = res.object
} catch (err: any) {
console.warn(`failed to retrieve object ${objectId}: ${err.message}`)
break
}
} else {
// Object already exists locally, we can just use it.
obj = localObject
}
const fromActor = await actors.getAndCache(actorId, db)
// notify the user
const targetActor = await actors.getActorById(db, new URL(obj[originalActorIdSymbol]))
if (targetActor === null) {
console.warn('object actor not found')
break
}
const notifId = await createNotification(db, 'reblog', targetActor, fromActor, obj)
await Promise.all([
createReblog(db, fromActor, obj),
sendReblogNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys),
])
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like
case 'Like': {
const actorId = getActorAsId()
const objectId = getObjectAsId()
const obj = await objects.getObjectById(db, objectId)
if (obj === null || !obj[originalActorIdSymbol]) {
console.warn('unknown object')
break
}
const fromActor = await actors.getAndCache(actorId, db)
const targetActor = await actors.getActorById(db, new URL(obj[originalActorIdSymbol]))
if (targetActor === null) {
console.warn('object actor not found')
break
}
const [notifId] = await Promise.all([
// Notify the user
createNotification(db, 'favourite', targetActor, fromActor, obj),
// Store the like for counting
insertLike(db, fromActor, obj),
])
await sendLikeNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys)
break
}
default:
console.warn(`Unsupported activity: ${activity.type}`)
}
}
async function cacheObject(
domain: string,
obj: APObject,
db: D1Database,
originalActorId: URL,
originalObjectId: URL
): Promise<{ created: boolean; object: APObject } | null> {
switch (obj.type) {
case 'Note': {
return objects.cacheObject(domain, db, obj, originalActorId, originalObjectId, false)
}
default: {
console.warn(`Unsupported Create object: ${obj.type}`)
return null
}
}
}