2022-12-05 20:14:56 +00:00
|
|
|
import type { UUID } from 'wildebeest/backend/src/types'
|
2023-02-02 15:47:05 +00:00
|
|
|
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
|
2022-12-05 20:14:56 +00:00
|
|
|
|
2023-02-02 14:28:17 +00:00
|
|
|
export const originalActorIdSymbol = Symbol()
|
|
|
|
export const originalObjectIdSymbol = Symbol()
|
|
|
|
export const mastodonIdSymbol = Symbol()
|
|
|
|
|
2022-12-05 20:14:56 +00:00
|
|
|
// https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
2023-01-24 13:03:55 +00:00
|
|
|
export interface APObject {
|
2022-12-05 20:14:56 +00:00
|
|
|
type: string
|
2023-01-18 10:39:47 +00:00
|
|
|
// ObjectId, URL used for federation. Called `uri` in Mastodon APIs.
|
2023-02-03 10:13:46 +00:00
|
|
|
// https://www.w3.org/TR/activitypub/#obj-id
|
2022-12-05 20:14:56 +00:00
|
|
|
id: URL
|
2023-01-18 10:39:47 +00:00
|
|
|
// Link to the HTML representation of the object
|
2022-12-05 20:14:56 +00:00
|
|
|
url: URL
|
|
|
|
published?: string
|
2023-01-24 13:03:55 +00:00
|
|
|
icon?: APObject
|
|
|
|
image?: APObject
|
2022-12-05 20:14:56 +00:00
|
|
|
summary?: string
|
|
|
|
name?: string
|
|
|
|
mediaType?: string
|
|
|
|
content?: string
|
|
|
|
inReplyTo?: string
|
|
|
|
|
|
|
|
// Extension
|
|
|
|
preferredUsername?: string
|
|
|
|
// Internal
|
2023-02-02 14:28:17 +00:00
|
|
|
[originalActorIdSymbol]?: string
|
|
|
|
[originalObjectIdSymbol]?: string
|
|
|
|
[mastodonIdSymbol]?: UUID
|
2022-12-05 20:14:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
|
2023-01-24 13:03:55 +00:00
|
|
|
export interface Document extends APObject {}
|
2022-12-05 20:14:56 +00:00
|
|
|
|
|
|
|
export function uri(domain: string, id: string): URL {
|
|
|
|
return new URL('/ap/o/' + id, 'https://' + domain)
|
|
|
|
}
|
|
|
|
|
2023-01-24 13:03:55 +00:00
|
|
|
export async function createObject<Type extends APObject>(
|
2022-12-05 20:14:56 +00:00
|
|
|
domain: string,
|
|
|
|
db: D1Database,
|
|
|
|
type: string,
|
|
|
|
properties: any,
|
|
|
|
originalActorId: URL,
|
|
|
|
local: boolean
|
2023-01-17 12:00:07 +00:00
|
|
|
): Promise<Type> {
|
2022-12-05 20:14:56 +00:00
|
|
|
const uuid = crypto.randomUUID()
|
|
|
|
const apId = uri(domain, uuid).toString()
|
2023-01-17 12:00:07 +00:00
|
|
|
const sanitizedProperties = await sanitizeObjectProperties(properties)
|
2022-12-05 20:14:56 +00:00
|
|
|
|
|
|
|
const row: any = await db
|
|
|
|
.prepare(
|
|
|
|
'INSERT INTO objects(id, type, properties, original_actor_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?) RETURNING *'
|
|
|
|
)
|
2023-01-17 12:00:07 +00:00
|
|
|
.bind(apId, type, JSON.stringify(sanitizedProperties), originalActorId.toString(), local ? 1 : 0, uuid)
|
2022-12-05 20:14:56 +00:00
|
|
|
.first()
|
|
|
|
|
|
|
|
return {
|
2023-01-17 12:00:07 +00:00
|
|
|
...sanitizedProperties,
|
2022-12-05 20:14:56 +00:00
|
|
|
type,
|
|
|
|
id: new URL(row.id),
|
|
|
|
published: new Date(row.cdate).toISOString(),
|
2023-02-02 14:28:17 +00:00
|
|
|
|
|
|
|
[mastodonIdSymbol]: row.mastodon_id,
|
|
|
|
[originalActorIdSymbol]: row.original_actor_id,
|
2023-01-17 12:00:07 +00:00
|
|
|
} as Type
|
2022-12-05 20:14:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function get<T>(url: URL): Promise<T> {
|
|
|
|
const headers = {
|
|
|
|
accept: 'application/activity+json',
|
|
|
|
}
|
|
|
|
const res = await fetch(url, { headers })
|
|
|
|
if (!res.ok) {
|
|
|
|
throw new Error(`${url} returned: ${res.status}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
return res.json<T>()
|
|
|
|
}
|
|
|
|
|
2023-01-06 14:40:20 +00:00
|
|
|
type CacheObjectRes = {
|
|
|
|
created: boolean
|
2023-01-24 13:03:55 +00:00
|
|
|
object: APObject
|
2023-01-06 14:40:20 +00:00
|
|
|
}
|
|
|
|
|
2022-12-05 20:14:56 +00:00
|
|
|
export async function cacheObject(
|
|
|
|
domain: string,
|
|
|
|
db: D1Database,
|
2023-01-17 12:00:07 +00:00
|
|
|
properties: unknown,
|
2022-12-05 20:14:56 +00:00
|
|
|
originalActorId: URL,
|
|
|
|
originalObjectId: URL,
|
|
|
|
local: boolean
|
2023-01-06 14:40:20 +00:00
|
|
|
): Promise<CacheObjectRes> {
|
2023-01-17 12:00:07 +00:00
|
|
|
const sanitizedProperties = await sanitizeObjectProperties(properties)
|
|
|
|
|
2022-12-05 20:14:56 +00:00
|
|
|
const cachedObject = await getObjectBy(db, 'original_object_id', originalObjectId.toString())
|
|
|
|
if (cachedObject !== null) {
|
2023-01-06 14:40:20 +00:00
|
|
|
return {
|
|
|
|
created: false,
|
|
|
|
object: cachedObject,
|
|
|
|
}
|
2022-12-05 20:14:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const uuid = crypto.randomUUID()
|
|
|
|
const apId = uri(domain, uuid).toString()
|
|
|
|
|
|
|
|
const row: any = await db
|
|
|
|
.prepare(
|
|
|
|
'INSERT INTO objects(id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING *'
|
|
|
|
)
|
|
|
|
.bind(
|
|
|
|
apId,
|
2023-01-17 12:00:07 +00:00
|
|
|
sanitizedProperties.type,
|
|
|
|
JSON.stringify(sanitizedProperties),
|
2022-12-05 20:14:56 +00:00
|
|
|
originalActorId.toString(),
|
|
|
|
originalObjectId.toString(),
|
|
|
|
local ? 1 : 0,
|
|
|
|
uuid
|
|
|
|
)
|
|
|
|
.first()
|
|
|
|
|
2023-02-02 15:47:05 +00:00
|
|
|
// Add peer
|
|
|
|
{
|
|
|
|
const domain = originalObjectId.host
|
|
|
|
await addPeer(db, domain)
|
|
|
|
}
|
|
|
|
|
2022-12-05 20:14:56 +00:00
|
|
|
{
|
|
|
|
const properties = JSON.parse(row.properties)
|
2023-01-06 14:40:20 +00:00
|
|
|
const object = {
|
2022-12-05 20:14:56 +00:00
|
|
|
published: new Date(row.cdate).toISOString(),
|
|
|
|
...properties,
|
|
|
|
|
|
|
|
type: row.type,
|
|
|
|
id: new URL(row.id),
|
2023-02-02 14:28:17 +00:00
|
|
|
|
|
|
|
[mastodonIdSymbol]: row.mastodon_id,
|
|
|
|
[originalActorIdSymbol]: row.original_actor_id,
|
|
|
|
[originalObjectIdSymbol]: row.original_object_id,
|
2023-01-24 13:03:55 +00:00
|
|
|
} as APObject
|
2023-01-06 14:40:20 +00:00
|
|
|
|
|
|
|
return { object, created: true }
|
2022-12-05 20:14:56 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function updateObject(db: D1Database, properties: any, id: URL): Promise<boolean> {
|
2023-01-06 09:06:16 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
2022-12-05 20:14:56 +00:00
|
|
|
const res: any = await db
|
|
|
|
.prepare('UPDATE objects SET properties = ? WHERE id = ?')
|
|
|
|
.bind(JSON.stringify(properties), id.toString())
|
|
|
|
.run()
|
|
|
|
|
|
|
|
// TODO: D1 doesn't return changes at the moment
|
|
|
|
// return res.changes === 1
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2023-02-09 14:40:28 +00:00
|
|
|
export async function updateObjectProperty(db: D1Database, obj: APObject, key: string, value: string) {
|
|
|
|
const { success, error } = await db
|
|
|
|
.prepare(`UPDATE objects SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`)
|
|
|
|
.bind(value, obj.id.toString())
|
|
|
|
.run()
|
|
|
|
if (!success) {
|
|
|
|
throw new Error('SQL error: ' + error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-24 13:03:55 +00:00
|
|
|
export async function getObjectById(db: D1Database, id: string | URL): Promise<APObject | null> {
|
2022-12-05 20:14:56 +00:00
|
|
|
return getObjectBy(db, 'id', id.toString())
|
|
|
|
}
|
|
|
|
|
2023-01-24 13:03:55 +00:00
|
|
|
export async function getObjectByOriginalId(db: D1Database, id: string | URL): Promise<APObject | null> {
|
2022-12-05 20:14:56 +00:00
|
|
|
return getObjectBy(db, 'original_object_id', id.toString())
|
|
|
|
}
|
|
|
|
|
2023-01-24 13:03:55 +00:00
|
|
|
export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise<APObject | null> {
|
2022-12-05 20:14:56 +00:00
|
|
|
return getObjectBy(db, 'mastodon_id', id)
|
|
|
|
}
|
|
|
|
|
2023-01-11 15:45:07 +00:00
|
|
|
export async function getObjectBy(db: D1Database, key: string, value: string) {
|
2022-12-05 20:14:56 +00:00
|
|
|
const query = `
|
|
|
|
SELECT *
|
|
|
|
FROM objects
|
|
|
|
WHERE objects.${key}=?
|
|
|
|
`
|
|
|
|
const { results, success, error } = await db.prepare(query).bind(value).all()
|
|
|
|
if (!success) {
|
|
|
|
throw new Error('SQL error: ' + error)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!results || results.length === 0) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
const result: any = results[0]
|
|
|
|
const properties = JSON.parse(result.properties)
|
|
|
|
|
|
|
|
return {
|
|
|
|
published: new Date(result.cdate).toISOString(),
|
|
|
|
...properties,
|
|
|
|
|
|
|
|
type: result.type,
|
|
|
|
id: new URL(result.id),
|
2023-02-02 14:28:17 +00:00
|
|
|
|
|
|
|
[mastodonIdSymbol]: result.mastodon_id,
|
|
|
|
[originalActorIdSymbol]: result.original_actor_id,
|
|
|
|
[originalObjectIdSymbol]: result.original_object_id,
|
2023-01-24 13:03:55 +00:00
|
|
|
} as APObject
|
2022-12-05 20:14:56 +00:00
|
|
|
}
|
2023-01-17 12:00:07 +00:00
|
|
|
|
|
|
|
/** Is the given `value` an ActivityPub Object? */
|
2023-01-24 13:03:55 +00:00
|
|
|
export function isAPObject(value: unknown): value is APObject {
|
2023-01-17 12:00:07 +00:00
|
|
|
return value !== null && typeof value === 'object'
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Sanitizes the ActivityPub Object `properties` prior to being stored in the DB. */
|
2023-01-24 13:03:55 +00:00
|
|
|
export async function sanitizeObjectProperties(properties: unknown): Promise<APObject> {
|
|
|
|
if (!isAPObject(properties)) {
|
2023-01-17 12:00:07 +00:00
|
|
|
throw new Error('Invalid object properties. Expected an object but got ' + JSON.stringify(properties))
|
|
|
|
}
|
2023-01-24 13:03:55 +00:00
|
|
|
const sanitized: APObject = {
|
2023-01-17 12:00:07 +00:00
|
|
|
...properties,
|
|
|
|
}
|
|
|
|
if ('content' in properties) {
|
|
|
|
sanitized.content = await sanitizeContent(properties.content as string)
|
|
|
|
}
|
|
|
|
if ('name' in properties) {
|
2023-02-10 11:52:21 +00:00
|
|
|
sanitized.name = await getTextContent(properties.name as string)
|
2023-01-17 12:00:07 +00:00
|
|
|
}
|
|
|
|
return sanitized
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sanitizes the given string as ActivityPub Object content.
|
|
|
|
*
|
|
|
|
* This sanitization follows that of Mastodon
|
|
|
|
* - convert all elements to `<p>` unless they are recognized as one of `<p>`, `<span>`, `<br>` or `<a>`.
|
|
|
|
* - remove all CSS classes that are not micro-formats or semantic.
|
|
|
|
*
|
|
|
|
* See https://docs.joinmastodon.org/spec/activitypub/#sanitization
|
|
|
|
*/
|
|
|
|
export async function sanitizeContent(unsafeContent: string): Promise<string> {
|
2023-02-02 16:46:53 +00:00
|
|
|
return await getContentRewriter().transform(new Response(unsafeContent)).text()
|
2023-01-17 12:00:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-02-10 11:52:21 +00:00
|
|
|
* This method removes all HTML elements from the string leaving only the text content.
|
2023-01-17 12:00:07 +00:00
|
|
|
*/
|
2023-02-10 11:52:21 +00:00
|
|
|
export async function getTextContent(unsafeName: string): Promise<string> {
|
|
|
|
const rawContent = getTextContentRewriter().transform(new Response(unsafeName))
|
|
|
|
const text = await rawContent.text()
|
|
|
|
return text.trim()
|
2023-01-17 12:00:07 +00:00
|
|
|
}
|
|
|
|
|
2023-02-02 16:46:53 +00:00
|
|
|
function getContentRewriter() {
|
|
|
|
const contentRewriter = new HTMLRewriter()
|
|
|
|
contentRewriter.on('*', {
|
|
|
|
element(el) {
|
|
|
|
if (!['p', 'span', 'br', 'a'].includes(el.tagName)) {
|
|
|
|
el.tagName = 'p'
|
|
|
|
}
|
|
|
|
|
|
|
|
if (el.hasAttribute('class')) {
|
|
|
|
const classes = el.getAttribute('class')!.split(/\s+/)
|
|
|
|
const sanitizedClasses = classes.filter((c) =>
|
|
|
|
/^(h|p|u|dt|e)-|^mention$|^hashtag$|^ellipsis$|^invisible$/.test(c)
|
|
|
|
)
|
|
|
|
el.setAttribute('class', sanitizedClasses.join(' '))
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return contentRewriter
|
|
|
|
}
|
2023-01-17 12:00:07 +00:00
|
|
|
|
2023-02-10 11:52:21 +00:00
|
|
|
function getTextContentRewriter() {
|
|
|
|
const textContentRewriter = new HTMLRewriter()
|
|
|
|
textContentRewriter.on('*', {
|
2023-02-02 16:46:53 +00:00
|
|
|
element(el) {
|
|
|
|
el.removeAndKeepContent()
|
2023-02-10 11:52:21 +00:00
|
|
|
if (['p', 'br'].includes(el.tagName)) {
|
|
|
|
el.after(' ')
|
|
|
|
}
|
2023-02-02 16:46:53 +00:00
|
|
|
},
|
|
|
|
})
|
2023-02-10 11:52:21 +00:00
|
|
|
return textContentRewriter
|
2023-02-02 16:46:53 +00:00
|
|
|
}
|
2023-02-08 17:30:00 +00:00
|
|
|
|
|
|
|
// TODO: eventually use SQLite's `ON DELETE CASCADE` but requires writing the DB
|
|
|
|
// schema directly into D1, which D1 disallows at the moment.
|
|
|
|
// Some context at: https://stackoverflow.com/questions/13150075/add-on-delete-cascade-behavior-to-an-sqlite3-table-after-it-has-been-created
|
|
|
|
export async function deleteObject<T extends APObject>(db: D1Database, note: T) {
|
|
|
|
const nodeId = note.id.toString()
|
|
|
|
const batch = [
|
|
|
|
db.prepare('DELETE FROM outbox_objects WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM inbox_objects WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM actor_notifications WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM actor_favourites WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM actor_reblogs WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM actor_replies WHERE object_id=?1 OR in_reply_to_object_id=?1').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM idempotency_keys WHERE object_id=?').bind(nodeId),
|
|
|
|
db.prepare('DELETE FROM objects WHERE id=?').bind(nodeId),
|
|
|
|
]
|
|
|
|
|
|
|
|
const res = await db.batch(batch)
|
|
|
|
|
|
|
|
for (let i = 0, len = res.length; i < len; i++) {
|
|
|
|
if (!res[i].success) {
|
|
|
|
throw new Error('SQL error: ' + res[i].error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|