refactor(federation): ♻️ Refactor user inbox API to reduce complexity

This commit is contained in:
Jesse Wierzbinski 2024-10-28 13:13:50 +01:00
parent 7638a094f4
commit d06301ed72
No known key found for this signature in database

View file

@ -1,14 +1,25 @@
import { apiRoute, applyConfig, debugRequest } from "@/api"; import { apiRoute, applyConfig, debugRequest } from "@/api";
import { sentry } from "@/sentry"; import { sentry } from "@/sentry";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape"; import { type Logger, getLogger } from "@logtape/logtape";
import { import {
EntityValidator, EntityValidator,
RequestParserHandler, RequestParserHandler,
SignatureValidator, SignatureValidator,
} from "@versia/federation"; } 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 { eq } from "drizzle-orm";
import type { Context, TypedResponse } from "hono";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { z } from "zod"; import { z } from "zod";
import { type ValidationError, isValidationError } from "zod-validation-error"; 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; let checkSignature = true;
if (config.federation.bridge.enabled) { if (config.federation.bridge.enabled) {
const token = authorization?.split("Bearer ")[1]; const token = authorization?.split("Bearer ")[1];
if (token) { if (token) {
// Request is bridge request const bridgeResponse = await handleBridgeRequest(
if (token !== config.federation.bridge.token) { token,
return context.json( requestIp,
{ context,
error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
},
401,
); );
if (bridgeResponse) {
return bridgeResponse;
} }
if (requestIp?.address) {
if (config.federation.bridge.allowed_ips.length > 0) {
checkSignature = false; 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 sender = await User.resolve(signedBy); const sender = await User.resolve(signedBy);
if (!sender) {
return context.json(
{ error: `Couldn't resolve sender ${signedBy}` },
404,
);
}
if (sender?.isLocal()) { if (sender?.isLocal()) {
return context.json( return context.json(
{ error: "Cannot send federation requests to local users" }, { error: "Cannot send federation requests to local users" },
@ -202,24 +200,130 @@ export default apiRoute((app) =>
const hostname = sender?.data.instance?.baseUrl ?? ""; const hostname = sender?.data.instance?.baseUrl ?? "";
// Check if Origin is defederated // Check if Origin is defederated
if ( if (isDefederated(hostname)) {
config.federation.blocked.find( // Return 201 to not make the sender think there's an error
(blocked) =>
blocked.includes(hostname) || hostname.includes(blocked),
)
) {
// Pretend to accept request
return context.newResponse(null, 201); return context.newResponse(null, 201);
} }
// Verify request signature // Verify request signature
if (checkSignature) { if (checkSignature) {
const signatureResponse = await verifySignature(
sender,
signature,
nonce,
context,
logger,
);
if (signatureResponse) {
return signatureResponse;
}
}
const validator = new EntityValidator();
const handler = new RequestParserHandler(body, validator);
try {
return await handler.parseBody<Response>({
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) {
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<Response | null>} - 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<Response | null>} - 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) { if (!sender) {
return context.json({ error: "Could not resolve sender" }, 400); return context.json({ error: "Could not resolve sender" }, 400);
} }
if (config.debug.federation) { if (config.debug.federation) {
// Log public key
logger.debug`Sender public key: ${sender.data.publicKey}`; logger.debug`Sender public key: ${sender.data.publicKey}`;
} }
@ -245,52 +349,87 @@ export default apiRoute((app) =>
}); });
if (!isValid) { if (!isValid) {
return context.json( return context.json({ error: "Signature could not be verified" }, 401);
{ error: "Signature could not be verified" },
401,
);
}
} }
const validator = new EntityValidator(); return null;
const handler = new RequestParserHandler(body, validator); }
try { /**
return await handler.parseBody<Response>({ * Handles Note entity processing.
note: async (note) => { *
* @param {VersiaNote} note - Note entity to process.
* @param {Context} context - Hono request context.
* @param {Logger} logger - LogTape logger.
* @returns {Promise<Response>} - 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); const account = await User.resolve(note.author);
if (!account) { if (!account) {
return context.json({ error: "Author not found" }, 404); return context.json({ error: "Author not found" }, 404);
} }
const newStatus = await Note.fromVersia( const newStatus = await Note.fromVersia(note, account).catch((e) => {
note,
account,
).catch((e) => {
logger.error`${e}`; logger.error`${e}`;
sentry?.captureException(e); sentry?.captureException(e);
return null; return null;
}); });
if (!newStatus) { if (!newStatus) {
return context.json( return context.json({ error: "Failed to add status" }, 500);
{ error: "Failed to add status" },
500,
);
} }
return context.text("Note created", 201); return context.text("Note created", 201);
}, }
follow: async (follow) => {
/**
* 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<Response>} - 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); const account = await User.resolve(follow.author);
if (!account) { if (!account) {
return context.json({ error: "Author not found" }, 400); return context.json({ error: "Author not found" }, 400);
} }
const foundRelationship = const foundRelationship = await Relationship.fromOwnerAndSubject(
await Relationship.fromOwnerAndSubject(account, user); account,
user,
);
if (foundRelationship.data.following) { if (foundRelationship.data.following) {
return context.text("Already following", 200); return context.text("Already following", 200);
@ -315,22 +454,37 @@ export default apiRoute((app) =>
} }
return context.text("Follow request sent", 200); return context.text("Follow request sent", 200);
}, }
followAccept: async (followAccept) => {
/**
* 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<Response>} - 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); const account = await User.resolve(followAccept.author);
if (!account) { if (!account) {
return context.json({ error: "Author not found" }, 400); return context.json({ error: "Author not found" }, 400);
} }
const foundRelationship = const foundRelationship = await Relationship.fromOwnerAndSubject(
await Relationship.fromOwnerAndSubject(user, account); user,
account,
);
if (!foundRelationship.data.requested) { if (!foundRelationship.data.requested) {
return context.text( return context.text("There is no follow request to accept", 200);
"There is no follow request to accept",
200,
);
} }
await foundRelationship.update({ await foundRelationship.update({
@ -339,22 +493,37 @@ export default apiRoute((app) =>
}); });
return context.text("Follow request accepted", 200); return context.text("Follow request accepted", 200);
}, }
followReject: async (followReject) => {
/**
* 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<Response>} - 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); const account = await User.resolve(followReject.author);
if (!account) { if (!account) {
return context.json({ error: "Author not found" }, 400); return context.json({ error: "Author not found" }, 400);
} }
const foundRelationship = const foundRelationship = await Relationship.fromOwnerAndSubject(
await Relationship.fromOwnerAndSubject(user, account); user,
account,
);
if (!foundRelationship.data.requested) { if (!foundRelationship.data.requested) {
return context.text( return context.text("There is no follow request to reject", 200);
"There is no follow request to reject",
200,
);
} }
await foundRelationship.update({ await foundRelationship.update({
@ -363,8 +532,22 @@ export default apiRoute((app) =>
}); });
return context.text("Follow request rejected", 200); return context.text("Follow request rejected", 200);
}, }
"pub.versia:likes/Like": async (like) => {
/**
* Handles Like entity processing.
*
* @param {VersiaLikeExtension} like - Like entity to process.
* @param {Context} context - Hono request context.
* @returns {Promise<Response>} - 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); const author = await User.resolve(like.author);
if (!author) { if (!author) {
@ -380,10 +563,28 @@ export default apiRoute((app) =>
await author.like(note, like.uri); await author.like(note, like.uri);
return context.text("Like added", 200); 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 * 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<Response>} - 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; const toDelete = delete_.deleted;
switch (delete_.deleted_type) { switch (delete_.deleted_type) {
@ -405,7 +606,6 @@ export default apiRoute((app) =>
if (otherUser) { if (otherUser) {
if (otherUser.id === user.id) { if (otherUser.id === user.id) {
// Delete own account
await user.delete(); await user.delete();
return context.text("Account deleted", 200); return context.text("Account deleted", 200);
} }
@ -440,7 +640,7 @@ export default apiRoute((app) =>
default: { default: {
return context.json( return context.json(
{ {
error: `Deletetion of object ${toDelete} not implemented`, error: `Deletion of object ${toDelete} not implemented`,
}, },
400, 400,
); );
@ -451,25 +651,62 @@ export default apiRoute((app) =>
{ error: "Object not found or not owned by user" }, { error: "Object not found or not owned by user" },
404, 404,
); );
}, }
user: async (user) => {
// Refetch user to ensure we have the latest data /**
* Handles User entity processing (profile edits).
*
* @param {VersiaUser} user - User entity to process.
* @param {Context} context - Hono request context.
* @returns {Promise<Response>} - 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); const updatedAccount = await User.saveFromRemote(user.uri);
if (!updatedAccount) { if (!updatedAccount) {
return context.json( return context.json({ error: "Failed to update user" }, 500);
{ error: "Failed to update user" },
500,
);
} }
return context.text("User refreshed", 200); 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;
}, },
unknown: () => { 400,
return context.json({ error: "Unknown entity type" }, 400); "json"
>)
| (Response &
TypedResponse<
{
error: string;
message: string;
}, },
}); 500,
} catch (e) { "json"
>) {
if (isValidationError(e)) { if (isValidationError(e)) {
return context.json( return context.json(
{ {
@ -488,6 +725,4 @@ export default apiRoute((app) =>
}, },
500, 500,
); );
} }
}),
);