2024-08-19 20:06:38 +02:00
|
|
|
import { apiRoute, applyConfig, debugRequest, handleZodError } from "@/api";
|
2024-05-29 02:59:49 +02:00
|
|
|
import { errorResponse, jsonResponse, response } from "@/response";
|
2024-07-24 19:04:00 +02:00
|
|
|
import { sentry } from "@/sentry";
|
2024-05-06 09:16:33 +02:00
|
|
|
import { zValidator } from "@hono/zod-validator";
|
2024-06-27 01:11:39 +02:00
|
|
|
import { getLogger } from "@logtape/logtape";
|
2024-06-08 03:33:00 +02:00
|
|
|
import {
|
|
|
|
|
EntityValidator,
|
|
|
|
|
RequestParserHandler,
|
|
|
|
|
SignatureValidator,
|
|
|
|
|
} from "@lysand-org/federation";
|
2024-06-20 01:21:02 +02:00
|
|
|
import type { Entity } from "@lysand-org/federation/types";
|
2024-05-17 19:56:13 +02:00
|
|
|
import type { SocketAddress } from "bun";
|
2024-07-27 20:46:19 +02:00
|
|
|
import { eq } from "drizzle-orm";
|
2024-05-17 19:56:13 +02:00
|
|
|
import { matches } from "ip-matching";
|
2024-05-06 09:16:33 +02:00
|
|
|
import { z } from "zod";
|
2024-05-29 02:36:15 +02:00
|
|
|
import { type ValidationError, isValidationError } from "zod-validation-error";
|
2024-07-27 20:46:19 +02:00
|
|
|
import { sendFollowAccept } from "~/classes/functions/user";
|
2024-05-29 02:59:49 +02:00
|
|
|
import { db } from "~/drizzle/db";
|
2024-07-27 20:46:19 +02:00
|
|
|
import { Notes, Notifications } from "~/drizzle/schema";
|
2024-05-29 02:59:49 +02:00
|
|
|
import { config } from "~/packages/config-manager";
|
2024-06-06 06:49:06 +02:00
|
|
|
import { Note } from "~/packages/database-interface/note";
|
2024-07-27 20:46:19 +02:00
|
|
|
import { Relationship } from "~/packages/database-interface/relationship";
|
2024-05-29 02:59:49 +02:00
|
|
|
import { User } from "~/packages/database-interface/user";
|
2024-05-06 09:16:33 +02:00
|
|
|
|
|
|
|
|
export const meta = applyConfig({
|
|
|
|
|
allowedMethods: ["POST"],
|
|
|
|
|
auth: {
|
|
|
|
|
required: false,
|
|
|
|
|
},
|
|
|
|
|
ratelimits: {
|
|
|
|
|
duration: 60,
|
|
|
|
|
max: 500,
|
|
|
|
|
},
|
2024-05-17 09:51:49 +02:00
|
|
|
route: "/users/:uuid/inbox",
|
2024-05-06 09:16:33 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const schemas = {
|
|
|
|
|
param: z.object({
|
|
|
|
|
uuid: z.string().uuid(),
|
|
|
|
|
}),
|
|
|
|
|
header: z.object({
|
|
|
|
|
signature: z.string(),
|
|
|
|
|
date: z.string(),
|
2024-05-22 02:59:03 +02:00
|
|
|
authorization: z.string().optional(),
|
2024-05-06 09:16:33 +02:00
|
|
|
}),
|
2024-05-17 10:37:06 +02:00
|
|
|
body: z.any(),
|
2024-05-06 09:16:33 +02:00
|
|
|
};
|
|
|
|
|
|
2024-08-19 20:06:38 +02:00
|
|
|
export default apiRoute((app) =>
|
2024-05-06 09:16:33 +02:00
|
|
|
app.on(
|
|
|
|
|
meta.allowedMethods,
|
|
|
|
|
meta.route,
|
|
|
|
|
zValidator("param", schemas.param, handleZodError),
|
|
|
|
|
zValidator("header", schemas.header, handleZodError),
|
2024-05-17 10:37:06 +02:00
|
|
|
zValidator("json", schemas.body, handleZodError),
|
2024-05-06 09:16:33 +02:00
|
|
|
async (context) => {
|
|
|
|
|
const { uuid } = context.req.valid("param");
|
2024-07-26 19:26:35 +02:00
|
|
|
const { signature, date, authorization } =
|
2024-05-22 02:59:03 +02:00
|
|
|
context.req.valid("header");
|
2024-06-27 01:11:39 +02:00
|
|
|
const logger = getLogger(["federation", "inbox"]);
|
2024-05-22 02:59:03 +02:00
|
|
|
|
2024-06-20 01:21:02 +02:00
|
|
|
const body: Entity = await context.req.valid("json");
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-05-22 02:59:03 +02:00
|
|
|
if (config.debug.federation) {
|
|
|
|
|
// Debug request
|
|
|
|
|
await debugRequest(
|
|
|
|
|
new Request(context.req.url, {
|
|
|
|
|
method: context.req.method,
|
|
|
|
|
headers: context.req.raw.headers,
|
|
|
|
|
body: await context.req.text(),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-06 09:16:33 +02:00
|
|
|
const user = await User.fromId(uuid);
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
return errorResponse("User not found", 404);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-20 00:17:35 +02:00
|
|
|
if (user.isRemote()) {
|
|
|
|
|
return errorResponse(
|
|
|
|
|
"Cannot view users from remote instances",
|
|
|
|
|
403,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-17 19:56:13 +02:00
|
|
|
// @ts-expect-error IP attribute is not in types
|
2024-06-13 04:26:43 +02:00
|
|
|
const requestIp = context.env?.ip as
|
2024-05-17 19:56:13 +02:00
|
|
|
| SocketAddress
|
|
|
|
|
| undefined
|
|
|
|
|
| null;
|
|
|
|
|
|
|
|
|
|
let checkSignature = true;
|
|
|
|
|
|
2024-05-22 02:59:03 +02:00
|
|
|
if (config.federation.bridge.enabled) {
|
|
|
|
|
const token = authorization?.split("Bearer ")[1];
|
|
|
|
|
if (token) {
|
|
|
|
|
// Request is bridge request
|
|
|
|
|
if (token !== config.federation.bridge.token) {
|
|
|
|
|
return errorResponse(
|
|
|
|
|
"An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
|
|
|
|
|
401,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-13 04:26:43 +02:00
|
|
|
if (requestIp?.address) {
|
|
|
|
|
if (config.federation.bridge.allowed_ips.length > 0) {
|
2024-05-22 02:59:03 +02:00
|
|
|
checkSignature = false;
|
2024-06-13 04:26:43 +02:00
|
|
|
}
|
2024-05-22 02:59:03 +02:00
|
|
|
|
|
|
|
|
for (const ip of config.federation.bridge.allowed_ips) {
|
2024-06-13 04:26:43 +02:00
|
|
|
if (matches(ip, requestIp?.address)) {
|
2024-05-22 02:59:03 +02:00
|
|
|
checkSignature = false;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return errorResponse(
|
|
|
|
|
"Request IP address is not available",
|
|
|
|
|
500,
|
|
|
|
|
);
|
2024-05-17 19:56:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-26 19:26:35 +02:00
|
|
|
const keyId = signature
|
|
|
|
|
.split("keyId=")[1]
|
|
|
|
|
.split(",")[0]
|
|
|
|
|
.replace(/"/g, "");
|
|
|
|
|
const sender = await User.resolve(keyId);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-07-26 19:26:35 +02:00
|
|
|
const origin = new URL(keyId).origin;
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-07-26 19:26:35 +02:00
|
|
|
// Check if Origin is defederated
|
|
|
|
|
if (
|
|
|
|
|
config.federation.blocked.find(
|
|
|
|
|
(blocked) =>
|
|
|
|
|
blocked.includes(origin) || origin.includes(blocked),
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
// Pretend to accept request
|
|
|
|
|
return response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify request signature
|
|
|
|
|
if (checkSignature) {
|
2024-05-06 09:16:33 +02:00
|
|
|
if (!sender) {
|
|
|
|
|
return errorResponse("Could not resolve keyId", 400);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-24 07:04:05 +02:00
|
|
|
if (config.debug.federation) {
|
|
|
|
|
// Log public key
|
2024-06-27 01:11:39 +02:00
|
|
|
logger.debug`Sender public key: ${sender.data.publicKey}`;
|
2024-05-24 07:04:05 +02:00
|
|
|
}
|
|
|
|
|
|
2024-05-06 09:16:33 +02:00
|
|
|
const validator = await SignatureValidator.fromStringKey(
|
2024-06-13 02:45:07 +02:00
|
|
|
sender.data.publicKey,
|
2024-05-06 09:16:33 +02:00
|
|
|
);
|
|
|
|
|
|
2024-05-17 23:42:42 +02:00
|
|
|
// If base_url uses https and request uses http, rewrite request to use https
|
|
|
|
|
// This fixes reverse proxy errors
|
|
|
|
|
const reqUrl = new URL(context.req.url);
|
|
|
|
|
if (
|
|
|
|
|
new URL(config.http.base_url).protocol === "https:" &&
|
|
|
|
|
reqUrl.protocol === "http:"
|
|
|
|
|
) {
|
|
|
|
|
reqUrl.protocol = "https:";
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-15 02:35:13 +02:00
|
|
|
const isValid = await validator
|
2024-05-17 21:38:38 +02:00
|
|
|
.validate(
|
2024-05-24 08:08:30 +02:00
|
|
|
new Request(reqUrl, {
|
|
|
|
|
method: context.req.method,
|
|
|
|
|
headers: {
|
|
|
|
|
Signature: signature,
|
|
|
|
|
Date: date,
|
|
|
|
|
},
|
|
|
|
|
body: await context.req.text(),
|
|
|
|
|
}),
|
2024-05-17 21:38:38 +02:00
|
|
|
)
|
2024-05-15 02:35:13 +02:00
|
|
|
.catch((e) => {
|
2024-06-27 01:11:39 +02:00
|
|
|
logger.error`${e}`;
|
2024-07-24 19:04:00 +02:00
|
|
|
sentry?.captureException(e);
|
2024-05-15 02:35:13 +02:00
|
|
|
return false;
|
|
|
|
|
});
|
2024-05-06 09:16:33 +02:00
|
|
|
|
|
|
|
|
if (!isValid) {
|
|
|
|
|
return errorResponse("Invalid signature", 400);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-15 02:35:13 +02:00
|
|
|
const validator = new EntityValidator();
|
2024-05-29 02:36:15 +02:00
|
|
|
const handler = new RequestParserHandler(body, validator);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
|
|
|
|
try {
|
2024-05-29 02:36:15 +02:00
|
|
|
const result = await handler.parseBody({
|
|
|
|
|
note: async (note) => {
|
2024-05-06 09:16:33 +02:00
|
|
|
const account = await User.resolve(note.author);
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
return errorResponse("Author not found", 404);
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 15:16:01 +02:00
|
|
|
const newStatus = await Note.fromVersia(
|
2024-05-06 09:16:33 +02:00
|
|
|
note,
|
2024-06-13 10:52:03 +02:00
|
|
|
account,
|
2024-05-06 09:16:33 +02:00
|
|
|
).catch((e) => {
|
2024-06-27 01:11:39 +02:00
|
|
|
logger.error`${e}`;
|
2024-07-24 19:04:00 +02:00
|
|
|
sentry?.captureException(e);
|
2024-05-06 09:16:33 +02:00
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!newStatus) {
|
|
|
|
|
return errorResponse("Failed to add status", 500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response("Note created", 201);
|
2024-05-29 02:36:15 +02:00
|
|
|
},
|
|
|
|
|
follow: async (follow) => {
|
2024-05-06 09:16:33 +02:00
|
|
|
const account = await User.resolve(follow.author);
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
return errorResponse("Author not found", 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const foundRelationship =
|
2024-07-27 20:46:19 +02:00
|
|
|
await Relationship.fromOwnerAndSubject(
|
|
|
|
|
account,
|
|
|
|
|
user,
|
|
|
|
|
);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
if (foundRelationship.data.following) {
|
2024-05-06 09:16:33 +02:00
|
|
|
return response("Already following", 200);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
await foundRelationship.update({
|
|
|
|
|
following: !user.data.isLocked,
|
|
|
|
|
requested: user.data.isLocked,
|
|
|
|
|
showingReblogs: true,
|
|
|
|
|
notifying: true,
|
|
|
|
|
languages: [],
|
|
|
|
|
});
|
2024-06-12 01:42:36 +02:00
|
|
|
|
2024-05-06 09:16:33 +02:00
|
|
|
await db.insert(Notifications).values({
|
|
|
|
|
accountId: account.id,
|
2024-06-13 02:45:07 +02:00
|
|
|
type: user.data.isLocked
|
2024-05-06 09:16:33 +02:00
|
|
|
? "follow_request"
|
|
|
|
|
: "follow",
|
|
|
|
|
notifiedId: user.id,
|
|
|
|
|
});
|
|
|
|
|
|
2024-06-13 02:45:07 +02:00
|
|
|
if (!user.data.isLocked) {
|
2024-05-06 09:16:33 +02:00
|
|
|
await sendFollowAccept(account, user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response("Follow request sent", 200);
|
2024-05-29 02:36:15 +02:00
|
|
|
},
|
|
|
|
|
followAccept: async (followAccept) => {
|
2024-05-06 09:16:33 +02:00
|
|
|
const account = await User.resolve(followAccept.author);
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
return errorResponse("Author not found", 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const foundRelationship =
|
2024-07-27 20:46:19 +02:00
|
|
|
await Relationship.fromOwnerAndSubject(
|
|
|
|
|
user,
|
|
|
|
|
account,
|
|
|
|
|
);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
if (!foundRelationship.data.requested) {
|
2024-05-06 09:16:33 +02:00
|
|
|
return response(
|
|
|
|
|
"There is no follow request to accept",
|
|
|
|
|
200,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
await foundRelationship.update({
|
|
|
|
|
requested: false,
|
|
|
|
|
following: true,
|
|
|
|
|
});
|
2024-06-12 01:42:36 +02:00
|
|
|
|
2024-05-06 09:16:33 +02:00
|
|
|
return response("Follow request accepted", 200);
|
2024-05-29 02:36:15 +02:00
|
|
|
},
|
|
|
|
|
followReject: async (followReject) => {
|
2024-05-06 09:16:33 +02:00
|
|
|
const account = await User.resolve(followReject.author);
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
return errorResponse("Author not found", 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const foundRelationship =
|
2024-07-27 20:46:19 +02:00
|
|
|
await Relationship.fromOwnerAndSubject(
|
|
|
|
|
user,
|
|
|
|
|
account,
|
|
|
|
|
);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
if (!foundRelationship.data.requested) {
|
2024-05-06 09:16:33 +02:00
|
|
|
return response(
|
|
|
|
|
"There is no follow request to reject",
|
|
|
|
|
200,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-27 20:46:19 +02:00
|
|
|
await foundRelationship.update({
|
|
|
|
|
requested: false,
|
|
|
|
|
following: false,
|
|
|
|
|
});
|
2024-06-12 01:42:36 +02:00
|
|
|
|
2024-05-06 09:16:33 +02:00
|
|
|
return response("Follow request rejected", 200);
|
2024-05-29 02:36:15 +02:00
|
|
|
},
|
2024-06-06 06:49:06 +02:00
|
|
|
undo: async (undo) => {
|
|
|
|
|
// Delete the specified object from database, if it exists and belongs to the user
|
|
|
|
|
const toDelete = undo.object;
|
|
|
|
|
|
|
|
|
|
// Try and find a follow, note, or user with the given URI
|
|
|
|
|
// Note
|
|
|
|
|
const note = await Note.fromSql(
|
|
|
|
|
eq(Notes.uri, toDelete),
|
|
|
|
|
eq(Notes.authorId, user.id),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (note) {
|
|
|
|
|
await note.delete();
|
|
|
|
|
return response("Note deleted", 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Follow (unfollow/cancel follow request)
|
|
|
|
|
// TODO: Remember to store URIs of follow requests/objects in the future
|
|
|
|
|
|
|
|
|
|
// User
|
|
|
|
|
const otherUser = await User.resolve(toDelete);
|
|
|
|
|
|
|
|
|
|
if (otherUser) {
|
|
|
|
|
if (otherUser.id === user.id) {
|
|
|
|
|
// Delete own account
|
|
|
|
|
await user.delete();
|
|
|
|
|
return response("Account deleted", 200);
|
|
|
|
|
}
|
|
|
|
|
return errorResponse(
|
|
|
|
|
"Cannot delete other users than self",
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return errorResponse(
|
|
|
|
|
`Deletion of object ${toDelete} not implemented`,
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
user: async (user) => {
|
|
|
|
|
// Refetch user to ensure we have the latest data
|
|
|
|
|
const updatedAccount = await User.saveFromRemote(
|
|
|
|
|
user.uri,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!updatedAccount) {
|
|
|
|
|
return errorResponse("Failed to update user", 500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response("User refreshed", 200);
|
|
|
|
|
},
|
2024-06-06 09:04:52 +02:00
|
|
|
patch: async (patch) => {
|
|
|
|
|
// Update the specified note in the database, if it exists and belongs to the user
|
|
|
|
|
const toPatch = patch.patched_id;
|
|
|
|
|
|
|
|
|
|
const note = await Note.fromSql(
|
|
|
|
|
eq(Notes.uri, toPatch),
|
|
|
|
|
eq(Notes.authorId, user.id),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Refetch note
|
|
|
|
|
if (!note) {
|
|
|
|
|
return errorResponse("Note not found", 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await note.updateFromRemote();
|
|
|
|
|
|
|
|
|
|
return response("Note updated", 200);
|
|
|
|
|
},
|
2024-05-29 02:36:15 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result) {
|
|
|
|
|
return result;
|
2024-05-06 09:16:33 +02:00
|
|
|
}
|
2024-05-29 02:36:15 +02:00
|
|
|
|
|
|
|
|
return errorResponse("Object has not been implemented", 400);
|
2024-05-06 09:16:33 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
if (isValidationError(e)) {
|
2024-05-29 02:36:15 +02:00
|
|
|
return errorResponse((e as ValidationError).message, 400);
|
2024-05-06 09:16:33 +02:00
|
|
|
}
|
2024-06-27 01:11:39 +02:00
|
|
|
logger.error`${e}`;
|
2024-07-24 19:04:00 +02:00
|
|
|
sentry?.captureException(e);
|
2024-05-06 09:16:33 +02:00
|
|
|
return jsonResponse(
|
|
|
|
|
{
|
|
|
|
|
error: "Failed to process request",
|
|
|
|
|
message: (e as Error).message,
|
|
|
|
|
},
|
|
|
|
|
500,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
2024-08-19 20:06:38 +02:00
|
|
|
),
|
|
|
|
|
);
|