diff --git a/api/users/:uuid/inbox/index.ts b/api/users/:uuid/inbox/index.ts index 146c4d36..35dc7843 100644 --- a/api/users/:uuid/inbox/index.ts +++ b/api/users/:uuid/inbox/index.ts @@ -1,14 +1,25 @@ import { apiRoute, applyConfig, debugRequest } from "@/api"; import { sentry } from "@/sentry"; import { createRoute } from "@hono/zod-openapi"; -import { getLogger } from "@logtape/logtape"; +import { type Logger, getLogger } from "@logtape/logtape"; import { EntityValidator, RequestParserHandler, SignatureValidator, } from "@versia/federation"; -import type { Entity } from "@versia/federation/types"; +import type { + Entity, + Delete as VersiaDelete, + Follow as VersiaFollow, + FollowAccept as VersiaFollowAccept, + FollowReject as VersiaFollowReject, + LikeExtension as VersiaLikeExtension, + Note as VersiaNote, + User as VersiaUser, +} from "@versia/federation/types"; +import type { SocketAddress } from "bun"; import { eq } from "drizzle-orm"; +import type { Context, TypedResponse } from "hono"; import { matches } from "ip-matching"; import { z } from "zod"; import { type ValidationError, isValidationError } from "zod-validation-error"; @@ -151,47 +162,34 @@ export default apiRoute((app) => ); } - const requestIp = context.env?.ip; + const requestIp = context.env?.ip ?? null; let checkSignature = true; if (config.federation.bridge.enabled) { const token = authorization?.split("Bearer ")[1]; if (token) { - // Request is bridge request - if (token !== config.federation.bridge.token) { - return context.json( - { - error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.", - }, - 401, - ); - } - - if (requestIp?.address) { - if (config.federation.bridge.allowed_ips.length > 0) { - checkSignature = false; - } - - for (const ip of config.federation.bridge.allowed_ips) { - if (matches(ip, requestIp?.address)) { - checkSignature = false; - break; - } - } - } else { - return context.json( - { - error: "Request IP address is not available", - }, - 500, - ); + const bridgeResponse = await handleBridgeRequest( + token, + requestIp, + context, + ); + if (bridgeResponse) { + return bridgeResponse; } + checkSignature = false; } } const sender = await User.resolve(signedBy); + if (!sender) { + return context.json( + { error: `Couldn't resolve sender ${signedBy}` }, + 404, + ); + } + if (sender?.isLocal()) { return context.json( { error: "Cannot send federation requests to local users" }, @@ -202,53 +200,22 @@ export default apiRoute((app) => const hostname = sender?.data.instance?.baseUrl ?? ""; // Check if Origin is defederated - if ( - config.federation.blocked.find( - (blocked) => - blocked.includes(hostname) || hostname.includes(blocked), - ) - ) { - // Pretend to accept request + if (isDefederated(hostname)) { + // Return 201 to not make the sender think there's an error return context.newResponse(null, 201); } // Verify request signature if (checkSignature) { - if (!sender) { - return context.json({ error: "Could not resolve sender" }, 400); - } - - if (config.debug.federation) { - // Log public key - logger.debug`Sender public key: ${sender.data.publicKey}`; - } - - const validator = await SignatureValidator.fromStringKey( - sender.data.publicKey, + const signatureResponse = await verifySignature( + sender, + signature, + nonce, + context, + logger, ); - - const isValid = await validator - .validate( - new Request(context.req.url, { - method: context.req.method, - headers: { - "X-Signature": signature, - "X-Nonce": nonce, - }, - body: await context.req.text(), - }), - ) - .catch((e) => { - logger.error`${e}`; - sentry?.captureException(e); - return false; - }); - - if (!isValid) { - return context.json( - { error: "Signature could not be verified" }, - 401, - ); + if (signatureResponse) { + return signatureResponse; } } @@ -257,237 +224,505 @@ export default apiRoute((app) => try { return await handler.parseBody({ - note: async (note) => { - const account = await User.resolve(note.author); - - if (!account) { - return context.json({ error: "Author not found" }, 404); - } - - const newStatus = await Note.fromVersia( - note, - account, - ).catch((e) => { - logger.error`${e}`; - sentry?.captureException(e); - return null; - }); - - if (!newStatus) { - return context.json( - { error: "Failed to add status" }, - 500, - ); - } - - return context.text("Note created", 201); - }, - follow: async (follow) => { - const account = await User.resolve(follow.author); - - if (!account) { - return context.json({ error: "Author not found" }, 400); - } - - const foundRelationship = - await Relationship.fromOwnerAndSubject(account, user); - - if (foundRelationship.data.following) { - return context.text("Already following", 200); - } - - await foundRelationship.update({ - following: !user.data.isLocked, - requested: user.data.isLocked, - showingReblogs: true, - notifying: true, - languages: [], - }); - - await db.insert(Notifications).values({ - accountId: account.id, - type: user.data.isLocked ? "follow_request" : "follow", - notifiedId: user.id, - }); - - if (!user.data.isLocked) { - await sendFollowAccept(account, user); - } - - return context.text("Follow request sent", 200); - }, - followAccept: async (followAccept) => { - const account = await User.resolve(followAccept.author); - - if (!account) { - return context.json({ error: "Author not found" }, 400); - } - - const foundRelationship = - await Relationship.fromOwnerAndSubject(user, account); - - if (!foundRelationship.data.requested) { - return context.text( - "There is no follow request to accept", - 200, - ); - } - - await foundRelationship.update({ - requested: false, - following: true, - }); - - return context.text("Follow request accepted", 200); - }, - followReject: async (followReject) => { - const account = await User.resolve(followReject.author); - - if (!account) { - return context.json({ error: "Author not found" }, 400); - } - - const foundRelationship = - await Relationship.fromOwnerAndSubject(user, account); - - if (!foundRelationship.data.requested) { - return context.text( - "There is no follow request to reject", - 200, - ); - } - - await foundRelationship.update({ - requested: false, - following: false, - }); - - return context.text("Follow request rejected", 200); - }, - "pub.versia:likes/Like": async (like) => { - const author = await User.resolve(like.author); - - if (!author) { - return context.json({ error: "Author not found" }, 400); - } - - const note = await Note.resolve(like.liked); - - if (!note) { - return context.json({ error: "Note not found" }, 400); - } - - await author.like(note, like.uri); - - return context.text("Like added", 200); - }, - // "delete" is a reserved keyword in JS - delete: async (delete_) => { - // Delete the specified object from database, if it exists and belongs to the user - const toDelete = delete_.deleted; - - switch (delete_.deleted_type) { - case "Note": { - const note = await Note.fromSql( - eq(Notes.uri, toDelete), - eq(Notes.authorId, user.id), - ); - - if (note) { - await note.delete(); - return context.text("Note deleted", 200); - } - - break; - } - case "User": { - const otherUser = await User.resolve(toDelete); - - if (otherUser) { - if (otherUser.id === user.id) { - // Delete own account - await user.delete(); - return context.text("Account deleted", 200); - } - return context.json( - { - error: "Cannot delete other users than self", - }, - 400, - ); - } - - break; - } - case "pub.versia:likes/Like": { - const like = await Like.fromSql( - eq(Likes.uri, toDelete), - eq(Likes.likerId, user.id), - ); - - if (like) { - await like.delete(); - return context.text("Like deleted", 200); - } - - return context.json( - { - error: "Like not found or not owned by user", - }, - 404, - ); - } - default: { - return context.json( - { - error: `Deletetion of object ${toDelete} not implemented`, - }, - 400, - ); - } - } - - return context.json( - { error: "Object not found or not owned by user" }, - 404, - ); - }, - user: async (user) => { - // Refetch user to ensure we have the latest data - const updatedAccount = await User.saveFromRemote(user.uri); - - if (!updatedAccount) { - return context.json( - { error: "Failed to update user" }, - 500, - ); - } - - return context.text("User refreshed", 200); - }, - unknown: () => { - return context.json({ error: "Unknown entity type" }, 400); - }, + note: async (note) => handleNoteRequest(note, context, logger), + follow: async (follow) => + handleFollowRequest(follow, user, context), + followAccept: async (followAccept) => + handleFollowAcceptRequest(followAccept, user, context), + followReject: async (followReject) => + handleFollowRejectRequest(followReject, user, context), + "pub.versia:likes/Like": async (like) => + handleLikeRequest(like, context), + delete: async (delete_) => + handleDeleteRequest(delete_, user, context), + user: async (user) => handleUserRequest(user, context), + unknown: () => + context.json({ error: "Unknown entity type" }, 400), }); } catch (e) { - if (isValidationError(e)) { + return handleError(e as Error, context, logger); + } + }), +); + +/** + * Handles bridge requests. + * @param {string} token - The authorization token. + * @param {SocketAddress | null} requestIp - The request IP address. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response or null if no error. + */ +function handleBridgeRequest( + token: string, + requestIp: SocketAddress | null, + context: Context, +): (Response & TypedResponse<{ error: string }, 401 | 500, "json">) | null { + if (token !== config.federation.bridge.token) { + return context.json( + { + error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.", + }, + 401, + ); + } + + if (requestIp?.address) { + if (config.federation.bridge.allowed_ips.length > 0) { + for (const ip of config.federation.bridge.allowed_ips) { + if (matches(ip, requestIp?.address)) { + return null; + } + } + } + } else { + return context.json( + { + error: "Request IP address is not available", + }, + 500, + ); + } + + return null; +} + +/** + * Checks if the hostname is defederated using glob matching. + * @param {string} hostname - The hostname to check. Can contain glob patterns. + * @returns {boolean} - True if defederated, false otherwise. + */ +function isDefederated(hostname: string): boolean { + const pattern = new Bun.Glob(hostname); + + return ( + config.federation.blocked.find( + (blocked) => pattern.match(blocked) !== null, + ) !== undefined + ); +} + +/** + * Verifies the request signature. + * @param {User} sender - The sender user. + * @param {string} signature - The request signature. + * @param {string} nonce - The request nonce. + * @param {Context} context - Hono request context. + * @param {Logger} logger - LogTape logger. + * @returns {Promise} - The response or null if no error. + */ +async function verifySignature( + sender: User, + signature: string, + nonce: string, + context: Context, + logger: Logger, +): Promise< + (Response & TypedResponse<{ error: string }, 401 | 400, "json">) | null +> { + if (!sender) { + return context.json({ error: "Could not resolve sender" }, 400); + } + + if (config.debug.federation) { + logger.debug`Sender public key: ${sender.data.publicKey}`; + } + + const validator = await SignatureValidator.fromStringKey( + sender.data.publicKey, + ); + + const isValid = await validator + .validate( + new Request(context.req.url, { + method: context.req.method, + headers: { + "X-Signature": signature, + "X-Nonce": nonce, + }, + body: await context.req.text(), + }), + ) + .catch((e) => { + logger.error`${e}`; + sentry?.captureException(e); + return false; + }); + + if (!isValid) { + return context.json({ error: "Signature could not be verified" }, 401); + } + + return null; +} + +/** + * Handles Note entity processing. + * + * @param {VersiaNote} note - Note entity to process. + * @param {Context} context - Hono request context. + * @param {Logger} logger - LogTape logger. + * @returns {Promise} - The response. + */ +async function handleNoteRequest( + note: VersiaNote, + context: Context, + logger: Logger, +): Promise< + Response & + TypedResponse< + | { + error: string; + } + | string, + 404 | 500 | 201, + "json" | "text" + > +> { + const account = await User.resolve(note.author); + + if (!account) { + return context.json({ error: "Author not found" }, 404); + } + + const newStatus = await Note.fromVersia(note, account).catch((e) => { + logger.error`${e}`; + sentry?.captureException(e); + return null; + }); + + if (!newStatus) { + return context.json({ error: "Failed to add status" }, 500); + } + + return context.text("Note created", 201); +} + +/** + * Handles Follow entity processing. + * + * @param {VersiaFollow} follow - Follow entity to process. + * @param {User} user - Owner of this inbox. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleFollowRequest( + follow: VersiaFollow, + user: User, + context: Context, +): Promise< + Response & + TypedResponse< + | { + error: string; + } + | string, + 200 | 400, + "text" | "json" + > +> { + const account = await User.resolve(follow.author); + + if (!account) { + return context.json({ error: "Author not found" }, 400); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + account, + user, + ); + + if (foundRelationship.data.following) { + return context.text("Already following", 200); + } + + await foundRelationship.update({ + following: !user.data.isLocked, + requested: user.data.isLocked, + showingReblogs: true, + notifying: true, + languages: [], + }); + + await db.insert(Notifications).values({ + accountId: account.id, + type: user.data.isLocked ? "follow_request" : "follow", + notifiedId: user.id, + }); + + if (!user.data.isLocked) { + await sendFollowAccept(account, user); + } + + return context.text("Follow request sent", 200); +} + +/** + * Handles FollowAccept entity processing + * + * @param {VersiaFollowAccept} followAccept - FollowAccept entity to process. + * @param {User} user - Owner of this inbox. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleFollowAcceptRequest( + followAccept: VersiaFollowAccept, + user: User, + context: Context, +): Promise< + Response & + TypedResponse<{ error: string } | string, 200 | 400, "text" | "json"> +> { + const account = await User.resolve(followAccept.author); + + if (!account) { + return context.json({ error: "Author not found" }, 400); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + account, + ); + + if (!foundRelationship.data.requested) { + return context.text("There is no follow request to accept", 200); + } + + await foundRelationship.update({ + requested: false, + following: true, + }); + + return context.text("Follow request accepted", 200); +} + +/** + * Handles FollowReject entity processing + * + * @param {VersiaFollowReject} followReject - FollowReject entity to process. + * @param {User} user - Owner of this inbox. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleFollowRejectRequest( + followReject: VersiaFollowReject, + user: User, + context: Context, +): Promise< + Response & + TypedResponse<{ error: string } | string, 200 | 400, "text" | "json"> +> { + const account = await User.resolve(followReject.author); + + if (!account) { + return context.json({ error: "Author not found" }, 400); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + account, + ); + + if (!foundRelationship.data.requested) { + return context.text("There is no follow request to reject", 200); + } + + await foundRelationship.update({ + requested: false, + following: false, + }); + + return context.text("Follow request rejected", 200); +} + +/** + * Handles Like entity processing. + * + * @param {VersiaLikeExtension} like - Like entity to process. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleLikeRequest( + like: VersiaLikeExtension, + context: Context, +): Promise< + Response & + TypedResponse<{ error: string } | string, 200 | 400, "text" | "json"> +> { + const author = await User.resolve(like.author); + + if (!author) { + return context.json({ error: "Author not found" }, 400); + } + + const note = await Note.resolve(like.liked); + + if (!note) { + return context.json({ error: "Note not found" }, 400); + } + + await author.like(note, like.uri); + + return context.text("Like added", 200); +} + +/** + * Handles Delete entity processing. + * + * @param {VersiaDelete} delete_ - Delete entity to process. + * @param {User} user - Owner of this inbox. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleDeleteRequest( + delete_: VersiaDelete, + user: User, + context: Context, +): Promise< + Response & + TypedResponse< + { error: string } | string, + 200 | 400 | 404, + "text" | "json" + > +> { + const toDelete = delete_.deleted; + + switch (delete_.deleted_type) { + case "Note": { + const note = await Note.fromSql( + eq(Notes.uri, toDelete), + eq(Notes.authorId, user.id), + ); + + if (note) { + await note.delete(); + return context.text("Note deleted", 200); + } + + break; + } + case "User": { + const otherUser = await User.resolve(toDelete); + + if (otherUser) { + if (otherUser.id === user.id) { + await user.delete(); + return context.text("Account deleted", 200); + } return context.json( { - error: "Failed to process request", - error_description: (e as ValidationError).message, + error: "Cannot delete other users than self", }, 400, ); } - logger.error`${e}`; - sentry?.captureException(e); + + break; + } + case "pub.versia:likes/Like": { + const like = await Like.fromSql( + eq(Likes.uri, toDelete), + eq(Likes.likerId, user.id), + ); + + if (like) { + await like.delete(); + return context.text("Like deleted", 200); + } + return context.json( { - error: "Failed to process request", - message: (e as Error).message, + error: "Like not found or not owned by user", }, - 500, + 404, ); } - }), -); + default: { + return context.json( + { + error: `Deletion of object ${toDelete} not implemented`, + }, + 400, + ); + } + } + + return context.json( + { error: "Object not found or not owned by user" }, + 404, + ); +} + +/** + * Handles User entity processing (profile edits). + * + * @param {VersiaUser} user - User entity to process. + * @param {Context} context - Hono request context. + * @returns {Promise} - The response. + */ +async function handleUserRequest( + user: VersiaUser, + context: Context, +): Promise< + Response & + TypedResponse<{ error: string } | string, 200 | 500, "text" | "json"> +> { + const updatedAccount = await User.saveFromRemote(user.uri); + + if (!updatedAccount) { + return context.json({ error: "Failed to update user" }, 500); + } + + return context.text("User refreshed", 200); +} + +/** + * Processes Errors into the appropriate HTTP response. + * + * @param {Error} e - The error object. + * @param {Context} context - Hono request context. + * @param {any} logger - LogTape logger. + * @returns {Response} - The error response. + */ +function handleError( + e: Error, + context: Context, + logger: Logger, +): + | (Response & + TypedResponse< + { + error: string; + error_description: string; + }, + 400, + "json" + >) + | (Response & + TypedResponse< + { + error: string; + message: string; + }, + 500, + "json" + >) { + if (isValidationError(e)) { + return context.json( + { + error: "Failed to process request", + error_description: (e as ValidationError).message, + }, + 400, + ); + } + logger.error`${e}`; + sentry?.captureException(e); + return context.json( + { + error: "Failed to process request", + message: (e as Error).message, + }, + 500, + ); +}