From cf295a596a468a5aba0a2d6ec41e4ab828b5d5e1 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 9 Apr 2024 22:07:03 -1000 Subject: [PATCH] Add ability to accept and reject remote follows if account is locked --- database/entities/User.ts | 48 +++ .../follow_requests/[account_id]/authorize.ts | 7 + .../v1/follow_requests/[account_id]/reject.ts | 7 + server/api/users/[uuid]/inbox/index.ts | 46 +- server/api/users/[uuid]/inbox/index3.ts | 403 ------------------ 5 files changed, 92 insertions(+), 419 deletions(-) delete mode 100644 server/api/users/[uuid]/inbox/index3.ts diff --git a/database/entities/User.ts b/database/entities/User.ts index eb97303a..fe7eaf97 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -131,6 +131,44 @@ export const followRequestUser = async ( return relationship; }; +export const sendFollowAccept = async (follower: User, followee: User) => { + // TODO: Make database job + const request = await objectToInboxRequest( + followAcceptToLysand(follower, followee), + followee, + follower, + ); + + // Send request + const response = await fetch(request); + + if (!response.ok) { + console.error(await response.text()); + throw new Error( + `Failed to federate follow accept from ${followee.id} to ${follower.uri}`, + ); + } +}; + +export const sendFollowReject = async (follower: User, followee: User) => { + // TODO: Make database job + const request = await objectToInboxRequest( + followRejectToLysand(follower, followee), + followee, + follower, + ); + + // Send request + const response = await fetch(request); + + if (!response.ok) { + console.error(await response.text()); + throw new Error( + `Failed to federate follow reject from ${followee.id} to ${follower.uri}`, + ); + } +}; + export const resolveUser = async (uri: string) => { // Check if user not already in database const foundUser = await client.user.findUnique({ @@ -659,3 +697,13 @@ export const followAcceptToLysand = ( uri: new URL(`/follows/${id}`, config.http.base_url).toString(), }; }; + +export const followRejectToLysand = ( + follower: User, + followee: User, +): Lysand.FollowReject => { + return { + ...followAcceptToLysand(follower, followee), + type: "FollowReject", + }; +}; diff --git a/server/api/api/v1/follow_requests/[account_id]/authorize.ts b/server/api/api/v1/follow_requests/[account_id]/authorize.ts index 1ebcf4d4..a66885d5 100644 --- a/server/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/server/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -5,6 +5,7 @@ import { checkForBidirectionalRelationships, relationshipToAPI, } from "~database/entities/Relationship"; +import { sendFollowAccept } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ @@ -71,5 +72,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!relationship) return errorResponse("Relationship not found", 404); + // Check if accepting remote follow + if (account.instanceId) { + // Federate follow accept + await sendFollowAccept(account, user); + } + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/follow_requests/[account_id]/reject.ts b/server/api/api/v1/follow_requests/[account_id]/reject.ts index f04ab469..8ddc12b7 100644 --- a/server/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/server/api/api/v1/follow_requests/[account_id]/reject.ts @@ -5,6 +5,7 @@ import { checkForBidirectionalRelationships, relationshipToAPI, } from "~database/entities/Relationship"; +import { sendFollowReject } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ @@ -59,5 +60,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!relationship) return errorResponse("Relationship not found", 404); + // Check if rejecting remote follow + if (account.instanceId) { + // Federate follow reject + await sendFollowReject(account, user); + } + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 49234cbb..25a66b2c 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -9,6 +9,7 @@ import { followAcceptToLysand, getRelationshipToOtherUser, resolveUser, + sendFollowAccept, } from "~database/entities/User"; import { objectToInboxRequest } from "~database/entities/Federation"; @@ -194,22 +195,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { 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) { - console.error(await response.text()); - throw new Error( - `Failed to federate follow accept from ${user.id} to ${account.uri}`, - ); - } + await sendFollowAccept(account, user); } return response("Follow request sent", 200); @@ -248,6 +234,34 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return response("Follow request accepted", 200); } + case "FollowReject": { + const followReject = body as Lysand.FollowReject; + + const account = await resolveUser(followReject.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const relationship = await getRelationshipToOtherUser( + user, + account, + ); + + if (!relationship.requested) { + return response("There is no follow request to reject", 200); + } + + await client.relationship.update({ + where: { id: relationship.id }, + data: { + requested: false, + following: false, + }, + }); + + return response("Follow request rejected", 200); + } default: { return errorResponse("Unknown object type", 400); } diff --git a/server/api/users/[uuid]/inbox/index3.ts b/server/api/users/[uuid]/inbox/index3.ts deleted file mode 100644 index 90531ac9..00000000 --- a/server/api/users/[uuid]/inbox/index3.ts +++ /dev/null @@ -1,403 +0,0 @@ -// TODO: Refactor into smaller packages -import { apiRoute, applyConfig } from "@api"; -import { getBestContentType } from "@content_types"; -import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; -import { parseEmojis } from "~database/entities/Emoji"; -import { createLike, deleteLike } from "~database/entities/Like"; -import { createFromObject } from "~database/entities/Object"; -import { createNewStatus, fetchFromRemote } from "~database/entities/Status"; -import { parseMentionsUris } from "~database/entities/User"; -import { - statusAndUserRelations, - userRelations, -} from "~database/entities/relations"; -import type { - Announce, - Like, - LysandAction, - LysandPublication, - Patch, - Undo, -} from "~types/lysand/Object"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid/inbox", -}); - -/** - * ActivityPub user inbox endpoint - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const username = matchedRoute.params.username; - - const config = await extraData.configManager.getConfig(); - - /* try { - if ( - config.activitypub.reject_activities.includes( - new URL(req.headers.get("Origin") ?? "").hostname, - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( - "Origin", - )}`, - ); - console.error(e); - } */ - - // Process request body - const body = (await req.json()) as LysandPublication | LysandAction; - - const author = await client.user.findUnique({ - where: { - username, - }, - include: userRelations, - }); - - if (!author) { - // TODO: Add new author to database - return errorResponse("Author not found", 404); - } - - // Verify HTTP signature - /* if (config.activitypub.authorized_fetch) { - // Check if date is older than 30 seconds - const origin = req.headers.get("Origin"); - - if (!origin) { - return errorResponse("Origin header is required", 401); - } - - const date = req.headers.get("Date"); - - if (!date) { - return errorResponse("Date header is required", 401); - } - - if (new Date(date).getTime() < Date.now() - 30000) { - return errorResponse("Date is too old (max 30 seconds)", 401); - } - - const signatureHeader = req.headers.get("Signature"); - - if (!signatureHeader) { - return errorResponse("Signature header is required", 401); - } - - const signature = signatureHeader - .split("signature=")[1] - .replace(/"/g, ""); - - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(await req.text()), - ); - - const expectedSignedString = - `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + - `host: ${req.url}\n` + - `date: ${date}\n` + - `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; - - // author.public_key is base64 encoded raw public key - const publicKey = await crypto.subtle.importKey( - "spki", - Buffer.from(author.publicKey, "base64"), - "Ed25519", - false, - ["verify"], - ); - - // Check if signed string is valid - const isValid = await crypto.subtle.verify( - "Ed25519", - publicKey, - Buffer.from(signature, "base64"), - new TextEncoder().encode(expectedSignedString), - ); - - if (!isValid) { - return errorResponse("Invalid signature", 401); - } - } */ - - // Get the object's ActivityPub type - const type = body.type; - - switch (type) { - case "Note": { - // Store the object in the LysandObject table - await createFromObject(body); - - const content = getBestContentType(body.contents); - - const emojis = await parseEmojis(content?.content || ""); - - const newStatus = await createNewStatus(author); - - const newStatus = await createNewStatus({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - sensitive: body.is_sensitive, - uri: body.uri, - emojis: emojis, - mentions: await parseMentionsUris(body.mentions), - }); - - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - newStatus.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } - - // Same for quotes - if (body.quotes.length > 0) { - newStatus.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } - - await client.status.update({ - where: { - id: newStatus.id, - }, - data: { - inReplyToPostId: newStatus.inReplyToPostId, - quotingPostId: newStatus.quotingPostId, - }, - }); - - break; - } - case "Patch": { - const patch = body as Patch; - // Store the object in the LysandObject table - await createFromObject(patch); - - // Edit the status - - const content = getBestContentType(patch.contents); - - const emojis = await parseEmojis(content?.content || ""); - - const status = await client.status.findUnique({ - where: { - uri: patch.patched_id, - }, - include: statusAndUserRelations, - }); - - if (!status) { - return errorResponse("Status not found", 404); - } - - status.content = content?.content || ""; - status.contentType = content?.content_type || "text/plain"; - status.spoilerText = patch.subject || ""; - status.sensitive = patch.is_sensitive; - status.emojis = emojis; - - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - status.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } - - // Same for quotes - if (body.quotes.length > 0) { - status.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } - - await client.status.update({ - where: { - id: status.id, - }, - data: { - content: status.content, - contentType: status.contentType, - spoilerText: status.spoilerText, - sensitive: status.sensitive, - emojis: { - connect: status.emojis.map((emoji) => ({ - id: emoji.id, - })), - }, - inReplyToPostId: status.inReplyToPostId, - quotingPostId: status.quotingPostId, - }, - }); - break; - } - case "Like": { - const like = body as Like; - // Store the object in the LysandObject table - await createFromObject(body); - - const likedStatus = await client.status.findUnique({ - where: { - uri: like.object, - }, - include: statusAndUserRelations, - }); - - if (!likedStatus) { - return errorResponse("Status not found", 404); - } - - await createLike(author, likedStatus); - - break; - } - case "Dislike": { - // Store the object in the LysandObject table - await createFromObject(body); - - return jsonResponse({ - info: "Dislikes are not supported by this software", - }); - } - case "Follow": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowAccept": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowReject": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "Announce": { - const announce = body as Announce; - // Store the object in the LysandObject table - await createFromObject(body); - - const rebloggedStatus = await client.status.findUnique({ - where: { - uri: announce.object, - }, - include: statusAndUserRelations, - }); - - if (!rebloggedStatus) { - return errorResponse("Status not found", 404); - } - - // Create new reblog - await client.status.create({ - data: { - authorId: author.id, - reblogId: rebloggedStatus.id, - isReblog: true, - uri: body.uri, - visibility: rebloggedStatus.visibility, - sensitive: false, - }, - include: statusAndUserRelations, - }); - - // Create notification - await client.notification.create({ - data: { - accountId: author.id, - notifiedId: rebloggedStatus.authorId, - type: "reblog", - statusId: rebloggedStatus.id, - }, - }); - break; - } - case "Undo": { - const undo = body as Undo; - // Store the object in the LysandObject table - await createFromObject(body); - - const object = await client.lysandObject.findUnique({ - where: { - uri: undo.object, - }, - }); - - if (!object) { - return errorResponse("Object not found", 404); - } - - switch (object.type) { - case "Like": { - const status = await client.status.findUnique({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - - if (!status) { - return errorResponse("Status not found", 404); - } - - await deleteLike(author, status); - break; - } - case "Announce": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - case "Note": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - default: { - return errorResponse("Invalid object type", 400); - } - } - break; - } - case "Extension": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - default: { - return errorResponse("Invalid type", 400); - } - } - - return jsonResponse({}); -});