Add ability to accept and reject remote follows if account is locked

This commit is contained in:
Jesse Wierzbinski 2024-04-09 22:07:03 -10:00
parent f72671fb07
commit cf295a596a
No known key found for this signature in database
5 changed files with 92 additions and 419 deletions

View file

@ -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",
};
};

View file

@ -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));
});

View file

@ -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));
});

View file

@ -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);
}

View file

@ -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({});
});