From 7da7febd00107f4bea02c918e557c6f5a16a0581 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 9 Apr 2024 19:24:07 -1000 Subject: [PATCH] Add status federation --- database/entities/Status.ts | 242 +++++++++++----------------- server/api/api/v1/statuses/index.ts | 8 +- 2 files changed, 104 insertions(+), 146 deletions(-) diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 48579723..f41bfcdf 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -160,6 +160,10 @@ export const parseTextMentions = async (text: string) => { }); }; +/** + * Creates a new status and saves it to the database. + * @returns A promise that resolves with the new status. + */ export const createNewStatus = async ( author: User, content: Lysand.ContentFormat, @@ -253,161 +257,111 @@ export const createNewStatus = async ( return status; }; -/** - * Creates a new status and saves it to the database. - * @param data The data for the new status. - * @returns A promise that resolves with the new status. - */ -export const createNewStatus2 = async (data: { - account: User; - application: Application | null; - content: string; - visibility: APIStatus["visibility"]; - sensitive: boolean; - spoiler_text: string; - emojis?: Emoji[]; - content_type?: string; - uri?: string; - mentions?: UserWithRelations[]; - media_attachments?: string[]; - reply?: { - status: Status; - user: User; - }; - quote?: Status; -}) => { - // Get people mentioned in the content (match @username or @username@domain.com mentions) - const mentionedPeople = - data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; +export const federateStatus = async (status: StatusWithRelations) => { + const toFederateTo = await getUsersToFederateTo(status); - let mentions = data.mentions || []; + for (const user of toFederateTo) { + // TODO: Add queue system + const request = await statusToInboxRequest(status, user); - // Parse emojis - const emojis = await parseEmojis(data.content); + // Send request + const response = await fetch(request); - data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; - - // Get list of mentioned users - if (mentions.length === 0) { - mentions = await client.user.findMany({ - where: { - OR: mentionedPeople.map((person) => ({ - username: person.split("@")[1], - instance: { - base_url: person.split("@")[2], - }, - })), - }, - include: userRelations, - }); - } - - let formattedContent = ""; - - // Get HTML version of content - if (data.content_type === "text/markdown") { - formattedContent = linkifyHtml( - await sanitizeHtml(await parse(data.content)), - ); - } else if (data.content_type === "text/x.misskeymarkdown") { - // Parse as MFM - } else { - // Parse as plaintext - formattedContent = linkifyStr(data.content); - - // Split by newline and add

tags - formattedContent = formattedContent - .split("\n") - .map((line) => `

${line}

`) - .join("\n"); - } - - // Turn each @username or @username@instance mention into an anchor link - for (const mention of mentions) { - const matches = data.content.match( - new RegExp( - `@${mention.username}(@${mention.instance?.base_url})?`, - "g", - ), - ); - - if (!matches) continue; - - for (const match of matches) { - formattedContent = formattedContent.replace( - new RegExp( - `@${mention.username}(@${mention.instance?.base_url})?`, - "g", - ), - `${match}`, + if (!response.ok) { + throw new Error( + `Failed to federate status ${status.id} to ${user.uri}`, ); } } +}; - const status = await client.status.create({ - data: { - authorId: data.account.id, - applicationId: data.application?.id, - content: formattedContent, - contentSource: data.content, - contentType: data.content_type, - visibility: data.visibility, - sensitive: data.sensitive, - spoilerText: data.spoiler_text, - emojis: { - connect: data.emojis.map((emoji) => { - return { - id: emoji.id, - }; - }), - }, - attachments: data.media_attachments - ? { - connect: data.media_attachments.map((attachment) => { - return { - id: attachment, - }; - }), - } - : undefined, - inReplyToPostId: data.reply?.status.id, - quotingPostId: data.quote?.id, - instanceId: data.account.instanceId || undefined, - isReblog: false, - uri: data.uri || null, - mentions: { - connect: mentions.map((mention) => { - return { - id: mention.id, - }; - }), - }, - }, - include: statusAndUserRelations, - }); +export const statusToInboxRequest = async ( + status: StatusWithRelations, + user: User, +): Promise => { + const output = statusToLysand(status); - // Create notification - if (status.inReplyToPost) { - await client.notification.create({ - data: { - notifiedId: status.inReplyToPost.authorId, - accountId: status.authorId, - type: "mention", - statusId: status.id, - }, - }); + if (!user.instanceId || !user.endpoints.inbox) { + throw new Error("User has no inbox or is a local user"); } - // Add to search index - await addStausToMeilisearch(status); + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Uint8Array.from(atob(status.author.privateKey ?? ""), (c) => + c.charCodeAt(0), + ), + "Ed25519", + false, + ["sign"], + ); - return status; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(output)), + ); + + const userInbox = new URL(user.endpoints.inbox); + + const date = new Date(); + + const signature = await crypto.subtle.sign( + "Ed25519", + privateKey, + new TextEncoder().encode( + `(request-target): post ${userInbox.pathname}\n` + + `host: ${userInbox.host}\n` + + `date: ${date.toISOString()}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`, + ), + ); + + const signatureBase64 = btoa( + String.fromCharCode(...new Uint8Array(signature)), + ); + + return new Request(userInbox, { + method: "POST", + headers: { + "Content-Type": "application/json", + Date: date.toISOString(), + Origin: config.http.base_url, + Signature: `keyId="${status.author.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + }, + body: JSON.stringify(output), + }); +}; + +export const getUsersToFederateTo = async (status: StatusWithRelations) => { + return await client.user.findMany({ + where: { + OR: [ + ["public", "unlisted", "private"].includes(status.visibility) + ? { + relationships: { + some: { + subjectId: status.authorId, + following: true, + }, + }, + instanceId: { + not: null, + }, + } + : {}, + // Mentioned users + { + id: { + in: status.mentions.map((m) => m.id), + }, + instanceId: { + not: null, + }, + }, + ], + }, + }); }; export const editStatus = async ( diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index de554010..c423ca9e 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -5,7 +5,11 @@ import { parse } from "marked"; import { client } from "~database/datasource"; import { getFromToken } from "~database/entities/Application"; import type { StatusWithRelations } from "~database/entities/Status"; -import { createNewStatus, statusToAPI } from "~database/entities/Status"; +import { + createNewStatus, + federateStatus, + statusToAPI, +} from "~database/entities/Status"; import type { UserWithRelations } from "~database/entities/User"; import { statusAndUserRelations } from "~database/entities/relations"; import type { APIStatus } from "~types/entities/status"; @@ -233,7 +237,7 @@ export default apiRoute<{ quote ?? undefined, ); - // TODO: add database jobs to deliver the post + await federateStatus(newStatus); return jsonResponse(await statusToAPI(newStatus, user)); });