From f56e4f623a5ac40477eee18bed15b174f392aaad Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 9 Apr 2024 19:51:00 -1000 Subject: [PATCH] Add following --- database/entities/Federation.ts | 58 +++++++++ database/entities/Status.ts | 65 +--------- database/entities/User.ts | 149 +++++++++++++++------- server/api/api/v1/accounts/[id]/follow.ts | 28 ++-- server/api/users/[uuid]/inbox/index.ts | 71 ++++++++++- 5 files changed, 242 insertions(+), 129 deletions(-) create mode 100644 database/entities/Federation.ts diff --git a/database/entities/Federation.ts b/database/entities/Federation.ts new file mode 100644 index 00000000..351fb2c5 --- /dev/null +++ b/database/entities/Federation.ts @@ -0,0 +1,58 @@ +import type { User } from "@prisma/client"; +import type * as Lysand from "lysand-types"; +import { config } from "config-manager"; + +export const objectToInboxRequest = async ( + object: Lysand.Entity, + author: User, + userToSendTo: User, +): Promise => { + if (!userToSendTo.instanceId || !userToSendTo.endpoints.inbox) { + throw new Error("User has no inbox or is a local user"); + } + + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Uint8Array.from(atob(author.privateKey ?? ""), (c) => c.charCodeAt(0)), + "Ed25519", + false, + ["sign"], + ); + + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(object)), + ); + + const userInbox = new URL(userToSendTo.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="${author.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + }, + body: JSON.stringify(object), + }); +}; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index f41bfcdf..0a6f7fe2 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -25,6 +25,7 @@ import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; import type { UserWithRelations } from "./User"; import { resolveUser, parseMentionsUris, userToAPI } from "./User"; import { statusAndUserRelations, userRelations } from "./relations"; +import { objectToInboxRequest } from "./Federation"; const statusRelations = Prisma.validator()({ include: statusAndUserRelations, @@ -262,7 +263,11 @@ export const federateStatus = async (status: StatusWithRelations) => { for (const user of toFederateTo) { // TODO: Add queue system - const request = await statusToInboxRequest(status, user); + const request = await objectToInboxRequest( + statusToLysand(status), + status.author, + user, + ); // Send request const response = await fetch(request); @@ -275,64 +280,6 @@ export const federateStatus = async (status: StatusWithRelations) => { } }; -export const statusToInboxRequest = async ( - status: StatusWithRelations, - user: User, -): Promise => { - const output = statusToLysand(status); - - if (!user.instanceId || !user.endpoints.inbox) { - throw new Error("User has no inbox or is a local user"); - } - - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Uint8Array.from(atob(status.author.privateKey ?? ""), (c) => - c.charCodeAt(0), - ), - "Ed25519", - false, - ["sign"], - ); - - 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: { diff --git a/database/entities/User.ts b/database/entities/User.ts index 661ac1e6..ba854eee 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -12,6 +12,7 @@ import { addInstanceIfNotExists } from "./Instance"; import { userRelations } from "./relations"; import { createNewRelationship } from "./Relationship"; import { getBestContentType, urlToContentFormat } from "@content_types"; +import { objectToInboxRequest } from "./Federation"; export interface AuthData { user: UserWithRelations | null; @@ -60,40 +61,6 @@ export const getFromRequest = async (req: Request): Promise => { return { user: await retrieveUserFromToken(token), token }; }; -export const followUser = async ( - follower: User, - followee: User, - relationshipId: string, - reblogs = false, - notify = false, - languages: string[] = [], -) => { - const relationship = await client.relationship.update({ - where: { id: relationshipId }, - data: { - following: true, - showingReblogs: reblogs, - notifying: notify, - languages: languages, - }, - }); - - if (follower.instanceId === followee.instanceId) { - // Notify the user that their post has been favourited - await client.notification.create({ - data: { - accountId: follower.id, - type: "follow", - notifiedId: followee.id, - }, - }); - } else { - // TODO: Add database jobs for federating this - } - - return relationship; -}; - export const followRequestUser = async ( follower: User, followee: User, @@ -102,27 +69,54 @@ export const followRequestUser = async ( notify = false, languages: string[] = [], ) => { + const isRemote = follower.instanceId !== followee.instanceId; + const relationship = await client.relationship.update({ where: { id: relationshipId }, data: { - requested: true, + following: isRemote ? false : !followee.isLocked, + requested: isRemote ? true : followee.isLocked, showingReblogs: reblogs, notifying: notify, languages: languages, }, }); - if (follower.instanceId === followee.instanceId) { - // Notify the user that their post has been favourited - await client.notification.create({ - data: { - accountId: follower.id, - type: "follow_request", - notifiedId: followee.id, - }, - }); + if (isRemote) { + // Federate + // TODO: Make database job + const request = await objectToInboxRequest( + followRequestToLysand(follower, followee), + follower, + followee, + ); + + // Send request + const response = await fetch(request); + + if (!response.ok) { + throw new Error( + `Failed to federate follow request from ${follower.id} to ${followee.uri}`, + ); + } } else { - // TODO: Add database jobs for federating this + if (followee.isLocked) { + await client.notification.create({ + data: { + accountId: follower.id, + type: "follow_request", + notifiedId: followee.id, + }, + }); + } else { + await client.notification.create({ + data: { + accountId: follower.id, + type: "follow", + notifiedId: followee.id, + }, + }); + } } return relationship; @@ -588,3 +582,68 @@ export const userToLysand = (user: UserWithRelations): Lysand.User => { }, }; }; + +export const followRequestToLysand = ( + follower: User, + followee: User, +): Lysand.Follow => { + if (follower.instanceId) { + throw new Error("Follower must be a local user"); + } + + if (!followee.instanceId) { + throw new Error("Followee must be a remote user"); + } + + if (!followee.uri) { + throw new Error("Followee must have a URI in database"); + } + + const id = crypto.randomUUID(); + + return { + type: "Follow", + id: id, + author: new URL( + `/users/${follower.id}`, + config.http.base_url, + ).toString(), + followee: followee.uri, + created_at: new Date().toISOString(), + uri: new URL(`/follows/${id}`, config.http.base_url).toString(), + }; +}; + +export const followAcceptToLysand = ( + follower: User, + followee: User, +): Lysand.FollowAccept => { + if (follower.instanceId) { + throw new Error("Follower must be a local user"); + } + + if (!followee.instanceId) { + throw new Error("Followee must be a remote user"); + } + + if (!followee.uri) { + throw new Error("Followee must have a URI in database"); + } + + const id = crypto.randomUUID(); + + return { + type: "FollowAccept", + id: id, + author: new URL( + `/users/${followee.id}`, + config.http.base_url, + ).toString(), + created_at: new Date().toISOString(), + follower: new URL( + `/users/${follower.id}`, + config.http.base_url, + ).toString(), + uri: new URL(`/follows/${id}`, config.http.base_url).toString(), + }; +}; diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index 0d275d61..87f5416a 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -7,7 +7,6 @@ import { } from "~database/entities/Relationship"; import { followRequestUser, - followUser, getRelationshipToOtherUser, } from "~database/entities/User"; @@ -58,25 +57,14 @@ export default apiRoute<{ let relationship = await getRelationshipToOtherUser(self, user); if (!relationship.following) { - if (user.isLocked) { - relationship = await followRequestUser( - self, - user, - relationship.id, - reblogs, - notify, - languages, - ); - } else { - relationship = await followUser( - self, - user, - relationship.id, - reblogs, - notify, - languages, - ); - } + relationship = await followRequestUser( + self, + user, + relationship.id, + reblogs, + notify, + languages, + ); } return jsonResponse(relationshipToAPI(relationship)); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 4927538a..9b7c9d41 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -5,6 +5,12 @@ import { userRelations } from "~database/entities/relations"; import type * as Lysand from "lysand-types"; import { createNewStatus } from "~database/entities/Status"; import type { APIStatus } from "~types/entities/status"; +import { + followAcceptToLysand, + getRelationshipToOtherUser, + resolveUser, +} from "~database/entities/User"; +import { objectToInboxRequest } from "~database/entities/Federation"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -120,11 +126,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { case "Note": { const note = body as Lysand.Note; - const account = await client.user.findUnique({ - where: { - uri: note.author, - }, - }); + const account = await resolveUser(note.author); if (!account) { return errorResponse("Author not found", 400); @@ -153,6 +155,65 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return response("Note created", 201); } + case "Follow": { + const follow = body as Lysand.Follow; + + const account = await resolveUser(follow.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const relationship = await getRelationshipToOtherUser( + account, + user, + ); + + // Check if already following + if (relationship.following) { + return response("Already following", 200); + } + + await client.relationship.update({ + where: { id: relationship.id }, + data: { + following: !user.isLocked, + requested: user.isLocked, + showingReblogs: true, + notifying: true, + languages: [], + }, + }); + + await client.notification.create({ + data: { + accountId: account.id, + type: user.isLocked ? "follow_request" : "follow", + notifiedId: user.id, + }, + }); + + if (!user.isLocked) { + // Federate FollowAccept + // TODO: Make database job + const request = await objectToInboxRequest( + followAcceptToLysand(account, user), + user, + account, + ); + + // Send request + const response = await fetch(request); + + if (!response.ok) { + throw new Error( + `Failed to federate follow accept from ${user.id} to ${account.uri}`, + ); + } + } + + return response("Follow request sent", 200); + } default: { return errorResponse("Unknown object type", 400); }