refactor(federation): ♻️ Queue all incoming inbox processing events

This commit is contained in:
Jesse Wierzbinski 2024-11-24 21:35:59 +01:00
parent 26f1407efe
commit b320ddf3ae
No known key found for this signature in database
4 changed files with 330 additions and 296 deletions

View file

@ -1,11 +1,9 @@
import { apiRoute, applyConfig } from "@/api"; import { apiRoute, applyConfig } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape";
import type { Entity } from "@versia/federation/types"; import type { Entity } from "@versia/federation/types";
import { Instance, User } from "@versia/kit/db";
import { z } from "zod"; import { z } from "zod";
import { InboxProcessor } from "~/classes/inbox/processor";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
import { InboxJobType, inboxQueue, inboxWorker } from "~/worker";
export const meta = applyConfig({ export const meta = applyConfig({
auth: { auth: {
@ -105,83 +103,25 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const {
"x-signature": signature,
"x-nonce": nonce,
"x-signed-by": signedBy,
authorization,
} = context.req.valid("header");
const logger = getLogger(["federation", "inbox"]);
const body: Entity = await context.req.valid("json"); const body: Entity = await context.req.valid("json");
if (authorization) { const result = await inboxQueue.add(InboxJobType.ProcessEntity, {
const processor = new InboxProcessor( data: body,
context, headers: context.req.valid("header"),
body, request: {
null, body: await context.req.text(),
{ method: context.req.method,
signature, url: context.req.url,
nonce,
authorization,
},
logger,
);
return await processor.process();
}
// If not potentially from bridge, check for required headers
if (!(signature && nonce && signedBy)) {
return context.json(
{
error: "Missing required headers: x-signature, x-nonce, or x-signed-by",
},
400,
);
}
const sender = await User.resolve(signedBy);
if (!(sender || signedBy.startsWith("instance "))) {
return context.json(
{ error: `Couldn't resolve sender URI ${signedBy}` },
404,
);
}
if (sender?.isLocal()) {
return context.json(
{
error: "Cannot process federation requests from local users",
},
400,
);
}
const remoteInstance = sender
? await Instance.fromUser(sender)
: await Instance.resolveFromHost(signedBy.split(" ")[1]);
if (!remoteInstance) {
return context.json(
{ error: "Could not resolve the remote instance." },
500,
);
}
const processor = new InboxProcessor(
context,
body,
remoteInstance,
{
signature,
nonce,
authorization,
}, },
logger, ip: context.env.ip ?? null,
); });
return await processor.process(); return new Promise<Response>((resolve) => {
inboxWorker.on("completed", (job) => {
if (job.id === result.id) {
resolve(job.returnvalue);
}
});
});
}), }),
); );

View file

@ -8,7 +8,7 @@ import {
Relationship, Relationship,
User, User,
} from "@versia/kit/db"; } from "@versia/kit/db";
import type { Context } from "hono"; import type { SocketAddress } from "bun";
import { ValidationError } from "zod-validation-error"; import { ValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/packages/config-manager/index.ts";
import { InboxProcessor } from "./processor.ts"; import { InboxProcessor } from "./processor.ts";
@ -75,7 +75,11 @@ mock.module("~/packages/config-manager/index.ts", () => ({
})); }));
describe("InboxProcessor", () => { describe("InboxProcessor", () => {
let mockContext: Context; let mockRequest: {
url: string;
method: string;
body: string;
};
let mockBody: Entity; let mockBody: Entity;
let mockSenderInstance: Instance; let mockSenderInstance: Instance;
let mockHeaders: { let mockHeaders: {
@ -90,18 +94,11 @@ describe("InboxProcessor", () => {
mock.restore(); mock.restore();
// Setup basic mock context // Setup basic mock context
mockContext = { mockRequest = {
json: jest.fn(), url: "https://test.com",
text: jest.fn(), method: "POST",
req: { body: "test-body",
url: "https://test.com", };
method: "POST",
text: jest.fn().mockResolvedValue("test-body"),
},
env: {
ip: { address: "127.0.0.1" },
},
} as unknown as Context;
// Setup basic mock sender // Setup basic mock sender
mockSenderInstance = { mockSenderInstance = {
@ -124,10 +121,14 @@ describe("InboxProcessor", () => {
// Create processor instance // Create processor instance
processor = new InboxProcessor( processor = new InboxProcessor(
mockContext, mockRequest,
mockBody, mockBody,
mockSenderInstance, mockSenderInstance,
mockHeaders, mockHeaders,
undefined,
{
address: "127.0.0.1",
} as SocketAddress,
); );
}); });
@ -197,28 +198,27 @@ describe("InboxProcessor", () => {
User.resolve = jest.fn().mockResolvedValue(mockAuthor); User.resolve = jest.fn().mockResolvedValue(mockAuthor);
Note.fromVersia = jest.fn().mockResolvedValue(true); Note.fromVersia = jest.fn().mockResolvedValue(true);
mockContext.text = jest.fn().mockReturnValue({ status: 201 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockNote as VersiaNote; processor["body"] = mockNote as VersiaNote;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processNote"](); const result = await processor["processNote"]();
expect(User.resolve).toHaveBeenCalledWith("test-author"); expect(User.resolve).toHaveBeenCalledWith("test-author");
expect(Note.fromVersia).toHaveBeenCalledWith(mockNote, mockAuthor); expect(Note.fromVersia).toHaveBeenCalledWith(mockNote, mockAuthor);
expect(mockContext.text).toHaveBeenCalledWith("Note created", 201); expect(result).toEqual(
new Response("Note created", { status: 201 }),
);
}); });
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
mockContext.json = jest.fn().mockReturnValue({ status: 404 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processNote"](); const result = await processor["processNote"]();
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ error: "Author not found" }, Response.json({ error: "Author not found" }, { status: 404 }),
404,
); );
}); });
}); });
@ -248,7 +248,6 @@ describe("InboxProcessor", () => {
.fn() .fn()
.mockResolvedValue(mockRelationship); .mockResolvedValue(mockRelationship);
Notification.insert = jest.fn(); Notification.insert = jest.fn();
mockContext.text = jest.fn().mockReturnValue({ status: 200 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockFollow as unknown as Entity; processor["body"] = mockFollow as unknown as Entity;
@ -266,14 +265,12 @@ describe("InboxProcessor", () => {
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
mockContext.json = jest.fn().mockReturnValue({ status: 404 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processFollowRequest"](); const result = await processor["processFollowRequest"]();
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ error: "Author not found" }, Response.json({ error: "Author not found" }, { status: 404 }),
404,
); );
}); });
}); });
@ -289,15 +286,14 @@ describe("InboxProcessor", () => {
}; };
Note.fromSql = jest.fn().mockResolvedValue(mockNote); Note.fromSql = jest.fn().mockResolvedValue(mockNote);
mockContext.text = jest.fn().mockReturnValue({ status: 200 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockDelete as unknown as Entity; processor["body"] = mockDelete as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processDelete"](); const result = await processor["processDelete"]();
expect(mockNote.delete).toHaveBeenCalled(); expect(mockNote.delete).toHaveBeenCalled();
expect(mockContext.text).toHaveBeenCalledWith("Note deleted", 200); expect(await result.text()).toBe("Note deleted");
}); });
test("returns 404 when note not found", async () => { test("returns 404 when note not found", async () => {
@ -307,16 +303,19 @@ describe("InboxProcessor", () => {
}; };
Note.fromSql = jest.fn().mockResolvedValue(null); Note.fromSql = jest.fn().mockResolvedValue(null);
mockContext.json = jest.fn().mockReturnValue({ status: 404 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockDelete as unknown as Entity; processor["body"] = mockDelete as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processDelete"](); const result = await processor["processDelete"]();
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ error: "Note to delete not found or not owned by sender" }, Response.json(
404, {
error: "Note to delete not found or not owned by sender",
},
{ status: 404 },
),
); );
}); });
}); });
@ -335,27 +334,26 @@ describe("InboxProcessor", () => {
User.resolve = jest.fn().mockResolvedValue(mockAuthor); User.resolve = jest.fn().mockResolvedValue(mockAuthor);
Note.resolve = jest.fn().mockResolvedValue(mockNote); Note.resolve = jest.fn().mockResolvedValue(mockNote);
mockContext.text = jest.fn().mockReturnValue({ status: 200 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockLike as unknown as Entity; processor["body"] = mockLike as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processLikeRequest"](); const result = await processor["processLikeRequest"]();
expect(mockAuthor.like).toHaveBeenCalledWith(mockNote, "test-uri"); expect(mockAuthor.like).toHaveBeenCalledWith(mockNote, "test-uri");
expect(mockContext.text).toHaveBeenCalledWith("Like created", 200); expect(result).toEqual(
new Response("Like created", { status: 200 }),
);
}); });
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
mockContext.json = jest.fn().mockReturnValue({ status: 404 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processLikeRequest"](); const result = await processor["processLikeRequest"]();
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ error: "Author not found" }, Response.json({ error: "Author not found" }, { status: 404 }),
404,
); );
}); });
}); });
@ -368,27 +366,29 @@ describe("InboxProcessor", () => {
const mockUpdatedUser = { id: "user-id" }; const mockUpdatedUser = { id: "user-id" };
User.saveFromRemote = jest.fn().mockResolvedValue(mockUpdatedUser); User.saveFromRemote = jest.fn().mockResolvedValue(mockUpdatedUser);
mockContext.text = jest.fn().mockReturnValue({ status: 200 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockUser as unknown as Entity; processor["body"] = mockUser as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processUserRequest"](); const result = await processor["processUserRequest"]();
expect(User.saveFromRemote).toHaveBeenCalledWith("test-uri"); expect(User.saveFromRemote).toHaveBeenCalledWith("test-uri");
expect(mockContext.text).toHaveBeenCalledWith("User updated", 200); expect(result).toEqual(
new Response("User updated", { status: 200 }),
);
}); });
test("returns 500 when update fails", async () => { test("returns 500 when update fails", async () => {
User.saveFromRemote = jest.fn().mockResolvedValue(null); User.saveFromRemote = jest.fn().mockResolvedValue(null);
mockContext.json = jest.fn().mockReturnValue({ status: 500 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processUserRequest"](); const result = await processor["processUserRequest"]();
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ error: "Failed to update user" }, Response.json(
500, { error: "Failed to update user" },
{ status: 500 },
),
); );
}); });
}); });
@ -396,33 +396,35 @@ describe("InboxProcessor", () => {
describe("handleError", () => { describe("handleError", () => {
test("handles validation errors", () => { test("handles validation errors", () => {
const validationError = new ValidationError("Invalid data"); const validationError = new ValidationError("Invalid data");
mockContext.json = jest.fn().mockReturnValue({ status: 400 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
processor["handleError"](validationError); const result = processor["handleError"](validationError);
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ Response.json(
error: "Failed to process request", {
error_description: "Invalid data", error: "Failed to process request",
}, error_description: "Invalid data",
400, },
{ status: 400 },
),
); );
}); });
test("handles general errors", () => { test("handles general errors", () => {
const error = new Error("Something went wrong"); const error = new Error("Something went wrong");
mockContext.json = jest.fn().mockReturnValue({ status: 500 });
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
processor["handleError"](error); const result = processor["handleError"](error);
expect(mockContext.json).toHaveBeenCalledWith( expect(result).toEqual(
{ Response.json(
error: "Failed to process request", {
message: "Something went wrong", error: "Failed to process request",
}, message: "Something went wrong",
500, },
{ status: 500 },
),
); );
}); });
}); });

View file

@ -26,7 +26,6 @@ import {
import { Likes, Notes } from "@versia/kit/tables"; import { Likes, Notes } from "@versia/kit/tables";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context, TypedResponse } from "hono";
import type { StatusCode } from "hono/utils/http-status"; import type { StatusCode } from "hono/utils/http-status";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { type ValidationError, isValidationError } from "zod-validation-error"; import { type ValidationError, isValidationError } from "zod-validation-error";
@ -68,7 +67,7 @@ export class InboxProcessor {
/** /**
* Creates a new InboxProcessor instance. * Creates a new InboxProcessor instance.
* *
* @param context Hono request context. * @param request Request object.
* @param body Entity JSON body. * @param body Entity JSON body.
* @param senderInstance Sender of the request's instance (from X-Signed-By header). Null if request is from a bridge. * @param senderInstance Sender of the request's instance (from X-Signed-By header). Null if request is from a bridge.
* @param headers Various request headers. * @param headers Various request headers.
@ -76,7 +75,11 @@ export class InboxProcessor {
* @param requestIp Request IP address. Grabs it from the Hono context if not provided. * @param requestIp Request IP address. Grabs it from the Hono context if not provided.
*/ */
public constructor( public constructor(
private context: Context, private request: {
url: string;
method: string;
body: string;
},
private body: Entity, private body: Entity,
private senderInstance: Instance | null, private senderInstance: Instance | null,
private headers: { private headers: {
@ -85,7 +88,7 @@ export class InboxProcessor {
authorization?: string; authorization?: string;
}, },
private logger: Logger = getLogger(["federation", "inbox"]), private logger: Logger = getLogger(["federation", "inbox"]),
private requestIp: SocketAddress | null = context.env?.ip ?? null, private requestIp: SocketAddress | null = null,
) {} ) {}
/** /**
@ -115,13 +118,13 @@ export class InboxProcessor {
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason // HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
const isValid = await validator.validate( const isValid = await validator.validate(
new Request(this.context.req.url, { new Request(this.request.url, {
method: this.context.req.method, method: this.request.method,
headers: { headers: {
"X-Signature": this.headers.signature, "X-Signature": this.headers.signature,
"X-Nonce": this.headers.nonce, "X-Nonce": this.headers.nonce,
}, },
body: await this.context.req.text(), body: this.request.body,
}), }),
); );
@ -195,9 +198,7 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - HTTP response to send back. * @returns {Promise<Response>} - HTTP response to send back.
*/ */
public async process(): Promise< public async process(): Promise<Response> {
(Response & TypedResponse<{ error: string }, 500, "json">) | Response
> {
if ( if (
this.senderInstance && this.senderInstance &&
isDefederated(this.senderInstance.data.baseUrl) isDefederated(this.senderInstance.data.baseUrl)
@ -205,15 +206,17 @@ export class InboxProcessor {
// Return 201 to avoid // Return 201 to avoid
// 1. Leaking defederated instance information // 1. Leaking defederated instance information
// 2. Preventing the sender from thinking the message was not delivered and retrying // 2. Preventing the sender from thinking the message was not delivered and retrying
return this.context.text("", 201); return new Response("", {
status: 201,
});
} }
const shouldCheckSignature = this.shouldCheckSignature(); const shouldCheckSignature = this.shouldCheckSignature();
if (shouldCheckSignature !== true && shouldCheckSignature !== false) { if (shouldCheckSignature !== true && shouldCheckSignature !== false) {
return this.context.json( return Response.json(
{ error: shouldCheckSignature.message }, { error: shouldCheckSignature.message },
shouldCheckSignature.code, { status: shouldCheckSignature.code },
); );
} }
@ -221,9 +224,9 @@ export class InboxProcessor {
const isValid = await this.isSignatureValid(); const isValid = await this.isSignatureValid();
if (!isValid) { if (!isValid) {
return this.context.json( return Response.json(
{ error: "Signature is not valid" }, { error: "Signature is not valid" },
401, { status: 401 },
); );
} }
} }
@ -243,9 +246,11 @@ export class InboxProcessor {
this.processLikeRequest(), this.processLikeRequest(),
delete: (): Promise<Response> => this.processDelete(), delete: (): Promise<Response> => this.processDelete(),
user: (): Promise<Response> => this.processUserRequest(), user: (): Promise<Response> => this.processUserRequest(),
unknown: (): Response & unknown: (): Response =>
TypedResponse<{ error: string }, 400, "json"> => Response.json(
this.context.json({ error: "Unknown entity type" }, 400), { error: "Unknown entity type" },
{ status: 400 },
),
}); });
} catch (e) { } catch (e) {
return this.handleError(e as Error); return this.handleError(e as Error);
@ -257,27 +262,20 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processNote(): Promise< private async processNote(): Promise<Response> {
Response &
TypedResponse<
| {
error: string;
}
| string,
404 | 500 | 201,
"json" | "text"
>
> {
const note = this.body as VersiaNote; const note = this.body as VersiaNote;
const author = await User.resolve(note.author); const author = await User.resolve(note.author);
if (!author) { if (!author) {
return this.context.json({ error: "Author not found" }, 404); return Response.json(
{ error: "Author not found" },
{ status: 404 },
);
} }
await Note.fromVersia(note, author); await Note.fromVersia(note, author);
return this.context.text("Note created", 201); return new Response("Note created", { status: 201 });
} }
/** /**
@ -285,27 +283,23 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processFollowRequest(): Promise< private async processFollowRequest(): Promise<Response> {
Response &
TypedResponse<
| {
error: string;
}
| string,
200 | 404,
"text" | "json"
>
> {
const follow = this.body as unknown as VersiaFollow; const follow = this.body as unknown as VersiaFollow;
const author = await User.resolve(follow.author); const author = await User.resolve(follow.author);
const followee = await User.resolve(follow.followee); const followee = await User.resolve(follow.followee);
if (!author) { if (!author) {
return this.context.json({ error: "Author not found" }, 404); return Response.json(
{ error: "Author not found" },
{ status: 404 },
);
} }
if (!followee) { if (!followee) {
return this.context.json({ error: "Followee not found" }, 404); return Response.json(
{ error: "Followee not found" },
{ status: 404 },
);
} }
const foundRelationship = await Relationship.fromOwnerAndSubject( const foundRelationship = await Relationship.fromOwnerAndSubject(
@ -314,7 +308,7 @@ export class InboxProcessor {
); );
if (foundRelationship.data.following) { if (foundRelationship.data.following) {
return this.context.text("Already following", 200); return new Response("Already following", { status: 200 });
} }
await foundRelationship.update({ await foundRelationship.update({
@ -336,7 +330,7 @@ export class InboxProcessor {
await followee.sendFollowAccept(author); await followee.sendFollowAccept(author);
} }
return this.context.text("Follow request sent", 200); return new Response("Follow request sent", { status: 200 });
} }
/** /**
@ -344,24 +338,23 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processFollowAccept(): Promise< private async processFollowAccept(): Promise<Response> {
Response &
TypedResponse<
{ error: string } | string,
200 | 404,
"text" | "json"
>
> {
const followAccept = this.body as unknown as VersiaFollowAccept; const followAccept = this.body as unknown as VersiaFollowAccept;
const author = await User.resolve(followAccept.author); const author = await User.resolve(followAccept.author);
const follower = await User.resolve(followAccept.follower); const follower = await User.resolve(followAccept.follower);
if (!author) { if (!author) {
return this.context.json({ error: "Author not found" }, 404); return Response.json(
{ error: "Author not found" },
{ status: 404 },
);
} }
if (!follower) { if (!follower) {
return this.context.json({ error: "Follower not found" }, 404); return Response.json(
{ error: "Follower not found" },
{ status: 404 },
);
} }
const foundRelationship = await Relationship.fromOwnerAndSubject( const foundRelationship = await Relationship.fromOwnerAndSubject(
@ -370,10 +363,9 @@ export class InboxProcessor {
); );
if (!foundRelationship.data.requested) { if (!foundRelationship.data.requested) {
return this.context.text( return new Response("There is no follow request to accept", {
"There is no follow request to accept", status: 200,
200, });
);
} }
await foundRelationship.update({ await foundRelationship.update({
@ -381,7 +373,7 @@ export class InboxProcessor {
following: true, following: true,
}); });
return this.context.text("Follow request accepted", 200); return new Response("Follow request accepted", { status: 200 });
} }
/** /**
@ -389,24 +381,23 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processFollowReject(): Promise< private async processFollowReject(): Promise<Response> {
Response &
TypedResponse<
{ error: string } | string,
200 | 404,
"text" | "json"
>
> {
const followReject = this.body as unknown as VersiaFollowReject; const followReject = this.body as unknown as VersiaFollowReject;
const author = await User.resolve(followReject.author); const author = await User.resolve(followReject.author);
const follower = await User.resolve(followReject.follower); const follower = await User.resolve(followReject.follower);
if (!author) { if (!author) {
return this.context.json({ error: "Author not found" }, 404); return Response.json(
{ error: "Author not found" },
{ status: 404 },
);
} }
if (!follower) { if (!follower) {
return this.context.json({ error: "Follower not found" }, 404); return Response.json(
{ error: "Follower not found" },
{ status: 404 },
);
} }
const foundRelationship = await Relationship.fromOwnerAndSubject( const foundRelationship = await Relationship.fromOwnerAndSubject(
@ -415,10 +406,9 @@ export class InboxProcessor {
); );
if (!foundRelationship.data.requested) { if (!foundRelationship.data.requested) {
return this.context.text( return new Response("There is no follow request to reject", {
"There is no follow request to reject", status: 200,
200, });
);
} }
await foundRelationship.update({ await foundRelationship.update({
@ -426,7 +416,7 @@ export class InboxProcessor {
following: false, following: false,
}); });
return this.context.text("Follow request rejected", 200); return new Response("Follow request rejected", { status: 200 });
} }
/** /**
@ -434,14 +424,7 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
public async processDelete(): Promise< public async processDelete(): Promise<Response> {
Response &
TypedResponse<
{ error: string } | string,
200 | 400 | 404,
"text" | "json"
>
> {
// JS doesn't allow the use of `delete` as a variable name // JS doesn't allow the use of `delete` as a variable name
const delete_ = this.body as unknown as VersiaDelete; const delete_ = this.body as unknown as VersiaDelete;
const toDelete = delete_.deleted; const toDelete = delete_.deleted;
@ -458,40 +441,39 @@ export class InboxProcessor {
); );
if (!note) { if (!note) {
return this.context.json( return Response.json(
{ {
error: "Note to delete not found or not owned by sender", error: "Note to delete not found or not owned by sender",
}, },
404, { status: 404 },
); );
} }
await note.delete(); await note.delete();
return this.context.text("Note deleted", 200); return new Response("Note deleted", { status: 200 });
} }
case "User": { case "User": {
const userToDelete = await User.resolve(toDelete); const userToDelete = await User.resolve(toDelete);
if (!userToDelete) { if (!userToDelete) {
return this.context.json( return Response.json(
{ error: "User to delete not found" }, { error: "User to delete not found" },
404, { status: 404 },
); );
} }
if (!author || userToDelete.id === author.id) { if (!author || userToDelete.id === author.id) {
await userToDelete.delete(); await userToDelete.delete();
return this.context.text( return new Response("Account deleted, goodbye 👋", {
"Account deleted, goodbye 👋", status: 200,
200, });
);
} }
return this.context.json( return Response.json(
{ {
error: "Cannot delete other users than self", error: "Cannot delete other users than self",
}, },
400, { status: 400 },
); );
} }
case "pub.versia:likes/Like": { case "pub.versia:likes/Like": {
@ -501,21 +483,21 @@ export class InboxProcessor {
); );
if (!like) { if (!like) {
return this.context.json( return Response.json(
{ error: "Like not found or not owned by sender" }, { error: "Like not found or not owned by sender" },
404, { status: 404 },
); );
} }
await like.delete(); await like.delete();
return this.context.text("Like deleted", 200); return new Response("Like deleted", { status: 200 });
} }
default: { default: {
return this.context.json( return Response.json(
{ {
error: `Deletion of object ${toDelete} not implemented`, error: `Deletion of object ${toDelete} not implemented`,
}, },
400, { status: 400 },
); );
} }
} }
@ -526,29 +508,28 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processLikeRequest(): Promise< private async processLikeRequest(): Promise<Response> {
Response &
TypedResponse<
{ error: string } | string,
200 | 404,
"text" | "json"
>
> {
const like = this.body as unknown as VersiaLikeExtension; const like = this.body as unknown as VersiaLikeExtension;
const author = await User.resolve(like.author); const author = await User.resolve(like.author);
const likedNote = await Note.resolve(like.liked); const likedNote = await Note.resolve(like.liked);
if (!author) { if (!author) {
return this.context.json({ error: "Author not found" }, 404); return Response.json(
{ error: "Author not found" },
{ status: 404 },
);
} }
if (!likedNote) { if (!likedNote) {
return this.context.json({ error: "Liked Note not found" }, 404); return Response.json(
{ error: "Liked Note not found" },
{ status: 404 },
);
} }
await author.like(likedNote, like.uri); await author.like(likedNote, like.uri);
return this.context.text("Like created", 200); return new Response("Like created", { status: 200 });
} }
/** /**
@ -556,23 +537,19 @@ export class InboxProcessor {
* *
* @returns {Promise<Response>} - The response. * @returns {Promise<Response>} - The response.
*/ */
private async processUserRequest(): Promise< private async processUserRequest(): Promise<Response> {
Response &
TypedResponse<
{ error: string } | string,
200 | 500,
"text" | "json"
>
> {
const user = this.body as unknown as VersiaUser; const user = this.body as unknown as VersiaUser;
// FIXME: Instead of refetching the remote user, we should read the incoming json and update from that // FIXME: Instead of refetching the remote user, we should read the incoming json and update from that
const updatedAccount = await User.saveFromRemote(user.uri); const updatedAccount = await User.saveFromRemote(user.uri);
if (!updatedAccount) { if (!updatedAccount) {
return this.context.json({ error: "Failed to update user" }, 500); return Response.json(
{ error: "Failed to update user" },
{ status: 500 },
);
} }
return this.context.text("User updated", 200); return new Response("User updated", { status: 200 });
} }
/** /**
@ -581,44 +558,26 @@ export class InboxProcessor {
* @param {Error} e - The error object. * @param {Error} e - The error object.
* @returns {Response} - The error response. * @returns {Response} - The error response.
*/ */
private handleError(e: Error): private handleError(e: Error): Response {
| (Response &
TypedResponse<
{
error: string;
error_description: string;
},
400,
"json"
>)
| (Response &
TypedResponse<
{
error: string;
message: string;
},
500,
"json"
>) {
if (isValidationError(e)) { if (isValidationError(e)) {
return this.context.json( return Response.json(
{ {
error: "Failed to process request", error: "Failed to process request",
error_description: (e as ValidationError).message, error_description: (e as ValidationError).message,
}, },
400, { status: 400 },
); );
} }
this.logger.error`${e}`; this.logger.error`${e}`;
sentry?.captureException(e); sentry?.captureException(e);
return this.context.json( return Response.json(
{ {
error: "Failed to process request", error: "Failed to process request",
message: (e as Error).message, message: (e as Error).message,
}, },
500, { status: 500 },
); );
} }
} }

145
worker.ts
View file

@ -1,7 +1,10 @@
import { getLogger } from "@logtape/logtape";
import type { Entity } from "@versia/federation/types"; import type { Entity } from "@versia/federation/types";
import { Note } from "@versia/kit/db"; import { Instance, Note, User } from "@versia/kit/db";
import { Queue, Worker } from "bullmq"; import { Queue, Worker } from "bullmq";
import type { SocketAddress } from "bun";
import IORedis from "ioredis"; import IORedis from "ioredis";
import { InboxProcessor } from "./classes/inbox/processor.ts";
import { config } from "./packages/config-manager/index.ts"; import { config } from "./packages/config-manager/index.ts";
const connection = new IORedis({ const connection = new IORedis({
@ -9,16 +12,33 @@ const connection = new IORedis({
port: config.redis.queue.port, port: config.redis.queue.port,
password: config.redis.queue.password, password: config.redis.queue.password,
db: config.redis.queue.database, db: config.redis.queue.database,
maxRetriesPerRequest: null,
}); });
enum DeliveryJobType { export enum DeliveryJobType {
FederateNote = "federateNote", FederateNote = "federateNote",
} }
enum InboxJobType { export enum InboxJobType {
ProcessEntity = "processEntity", ProcessEntity = "processEntity",
} }
type InboxJobData = {
data: Entity;
headers: {
"x-signature"?: string;
"x-nonce"?: string;
"x-signed-by"?: string;
authorization?: string;
};
request: {
url: string;
method: string;
body: string;
};
ip: SocketAddress | null;
};
const deliveryQueue = new Queue<{ noteId: string }, void, DeliveryJobType>( const deliveryQueue = new Queue<{ noteId: string }, void, DeliveryJobType>(
"delivery", "delivery",
{ {
@ -26,14 +46,18 @@ const deliveryQueue = new Queue<{ noteId: string }, void, DeliveryJobType>(
}, },
); );
export const inboxQueue = new Queue<{ data: Entity }, void, InboxJobType>( export const inboxQueue = new Queue<InboxJobData, Response, InboxJobType>(
"inbox", "inbox",
{ {
connection, connection,
}, },
); );
export const worker = new Worker<{ noteId: string }, void, DeliveryJobType>( export const deliveryWorker = new Worker<
{ noteId: string },
void,
DeliveryJobType
>(
deliveryQueue.name, deliveryQueue.name,
async (job) => { async (job) => {
switch (job.name) { switch (job.name) {
@ -43,7 +67,9 @@ export const worker = new Worker<{ noteId: string }, void, DeliveryJobType>(
const note = await Note.fromId(noteId); const note = await Note.fromId(noteId);
if (!note) { if (!note) {
throw new Error(`Note with ID ${noteId} not found`); throw new Error(
`Note with ID ${noteId} not found in database`,
);
} }
await note.federateToUsers(); await note.federateToUsers();
@ -52,3 +78,110 @@ export const worker = new Worker<{ noteId: string }, void, DeliveryJobType>(
}, },
{ connection }, { connection },
); );
export const inboxWorker = new Worker<InboxJobData, Response, InboxJobType>(
inboxQueue.name,
async (job) => {
switch (job.name) {
case InboxJobType.ProcessEntity: {
const {
data,
headers: {
"x-signature": signature,
"x-nonce": nonce,
"x-signed-by": signedBy,
authorization,
},
request,
ip,
} = job.data;
const logger = getLogger(["federation", "inbox"]);
if (authorization) {
const processor = new InboxProcessor(
request,
data,
null,
{
signature,
nonce,
authorization,
},
logger,
ip,
);
return await processor.process();
}
// If not potentially from bridge, check for required headers
if (!(signature && nonce && signedBy)) {
return Response.json(
{
error: "Missing required headers: x-signature, x-nonce, or x-signed-by",
},
{
status: 400,
},
);
}
const sender = await User.resolve(signedBy);
if (!(sender || signedBy.startsWith("instance "))) {
return Response.json(
{ error: `Couldn't resolve sender URI ${signedBy}` },
{
status: 404,
},
);
}
if (sender?.isLocal()) {
return Response.json(
{
error: "Cannot process federation requests from local users",
},
{
status: 400,
},
);
}
const remoteInstance = sender
? await Instance.fromUser(sender)
: await Instance.resolveFromHost(signedBy.split(" ")[1]);
if (!remoteInstance) {
return Response.json(
{ error: "Could not resolve the remote instance." },
{
status: 500,
},
);
}
const processor = new InboxProcessor(
request,
data,
remoteInstance,
{
signature,
nonce,
authorization,
},
logger,
ip,
);
return await processor.process();
}
default: {
throw new Error(`Unknown job type: ${job.name}`);
}
}
},
{ connection },
);