Add following

This commit is contained in:
Jesse Wierzbinski 2024-04-09 19:51:00 -10:00
parent 7da7febd00
commit f56e4f623a
No known key found for this signature in database
5 changed files with 242 additions and 129 deletions

View file

@ -0,0 +1,58 @@
import type { User } from "@prisma/client";
import type * as Lysand from "lysand-types";
import { config } from "config-manager";
export const objectToInboxRequest = async (
object: Lysand.Entity,
author: User,
userToSendTo: User,
): Promise<Request> => {
if (!userToSendTo.instanceId || !userToSendTo.endpoints.inbox) {
throw new Error("User has no inbox or is a local user");
}
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Uint8Array.from(atob(author.privateKey ?? ""), (c) => c.charCodeAt(0)),
"Ed25519",
false,
["sign"],
);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(object)),
);
const userInbox = new URL(userToSendTo.endpoints.inbox);
const date = new Date();
const signature = await crypto.subtle.sign(
"Ed25519",
privateKey,
new TextEncoder().encode(
`(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
)}\n`,
),
);
const signatureBase64 = btoa(
String.fromCharCode(...new Uint8Array(signature)),
);
return new Request(userInbox, {
method: "POST",
headers: {
"Content-Type": "application/json",
Date: date.toISOString(),
Origin: config.http.base_url,
Signature: `keyId="${author.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
},
body: JSON.stringify(object),
});
};

View file

@ -25,6 +25,7 @@ import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
import type { UserWithRelations } from "./User";
import { resolveUser, parseMentionsUris, userToAPI } from "./User";
import { statusAndUserRelations, userRelations } from "./relations";
import { objectToInboxRequest } from "./Federation";
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
include: statusAndUserRelations,
@ -262,7 +263,11 @@ export const federateStatus = async (status: StatusWithRelations) => {
for (const user of toFederateTo) {
// TODO: Add queue system
const request = await statusToInboxRequest(status, user);
const request = await objectToInboxRequest(
statusToLysand(status),
status.author,
user,
);
// Send request
const response = await fetch(request);
@ -275,64 +280,6 @@ export const federateStatus = async (status: StatusWithRelations) => {
}
};
export const statusToInboxRequest = async (
status: StatusWithRelations,
user: User,
): Promise<Request> => {
const output = statusToLysand(status);
if (!user.instanceId || !user.endpoints.inbox) {
throw new Error("User has no inbox or is a local user");
}
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Uint8Array.from(atob(status.author.privateKey ?? ""), (c) =>
c.charCodeAt(0),
),
"Ed25519",
false,
["sign"],
);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(output)),
);
const userInbox = new URL(user.endpoints.inbox);
const date = new Date();
const signature = await crypto.subtle.sign(
"Ed25519",
privateKey,
new TextEncoder().encode(
`(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
)}\n`,
),
);
const signatureBase64 = btoa(
String.fromCharCode(...new Uint8Array(signature)),
);
return new Request(userInbox, {
method: "POST",
headers: {
"Content-Type": "application/json",
Date: date.toISOString(),
Origin: config.http.base_url,
Signature: `keyId="${status.author.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
},
body: JSON.stringify(output),
});
};
export const getUsersToFederateTo = async (status: StatusWithRelations) => {
return await client.user.findMany({
where: {

View file

@ -12,6 +12,7 @@ import { addInstanceIfNotExists } from "./Instance";
import { userRelations } from "./relations";
import { createNewRelationship } from "./Relationship";
import { getBestContentType, urlToContentFormat } from "@content_types";
import { objectToInboxRequest } from "./Federation";
export interface AuthData {
user: UserWithRelations | null;
@ -60,40 +61,6 @@ export const getFromRequest = async (req: Request): Promise<AuthData> => {
return { user: await retrieveUserFromToken(token), token };
};
export const followUser = async (
follower: User,
followee: User,
relationshipId: string,
reblogs = false,
notify = false,
languages: string[] = [],
) => {
const relationship = await client.relationship.update({
where: { id: relationshipId },
data: {
following: true,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
},
});
if (follower.instanceId === followee.instanceId) {
// Notify the user that their post has been favourited
await client.notification.create({
data: {
accountId: follower.id,
type: "follow",
notifiedId: followee.id,
},
});
} else {
// TODO: Add database jobs for federating this
}
return relationship;
};
export const followRequestUser = async (
follower: User,
followee: User,
@ -102,27 +69,54 @@ export const followRequestUser = async (
notify = false,
languages: string[] = [],
) => {
const isRemote = follower.instanceId !== followee.instanceId;
const relationship = await client.relationship.update({
where: { id: relationshipId },
data: {
requested: true,
following: isRemote ? false : !followee.isLocked,
requested: isRemote ? true : followee.isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
},
});
if (follower.instanceId === followee.instanceId) {
// Notify the user that their post has been favourited
await client.notification.create({
data: {
accountId: follower.id,
type: "follow_request",
notifiedId: followee.id,
},
});
if (isRemote) {
// Federate
// TODO: Make database job
const request = await objectToInboxRequest(
followRequestToLysand(follower, followee),
follower,
followee,
);
// Send request
const response = await fetch(request);
if (!response.ok) {
throw new Error(
`Failed to federate follow request from ${follower.id} to ${followee.uri}`,
);
}
} else {
// TODO: Add database jobs for federating this
if (followee.isLocked) {
await client.notification.create({
data: {
accountId: follower.id,
type: "follow_request",
notifiedId: followee.id,
},
});
} else {
await client.notification.create({
data: {
accountId: follower.id,
type: "follow",
notifiedId: followee.id,
},
});
}
}
return relationship;
@ -588,3 +582,68 @@ export const userToLysand = (user: UserWithRelations): Lysand.User => {
},
};
};
export const followRequestToLysand = (
follower: User,
followee: User,
): Lysand.Follow => {
if (follower.instanceId) {
throw new Error("Follower must be a local user");
}
if (!followee.instanceId) {
throw new Error("Followee must be a remote user");
}
if (!followee.uri) {
throw new Error("Followee must have a URI in database");
}
const id = crypto.randomUUID();
return {
type: "Follow",
id: id,
author: new URL(
`/users/${follower.id}`,
config.http.base_url,
).toString(),
followee: followee.uri,
created_at: new Date().toISOString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
};
export const followAcceptToLysand = (
follower: User,
followee: User,
): Lysand.FollowAccept => {
if (follower.instanceId) {
throw new Error("Follower must be a local user");
}
if (!followee.instanceId) {
throw new Error("Followee must be a remote user");
}
if (!followee.uri) {
throw new Error("Followee must have a URI in database");
}
const id = crypto.randomUUID();
return {
type: "FollowAccept",
id: id,
author: new URL(
`/users/${followee.id}`,
config.http.base_url,
).toString(),
created_at: new Date().toISOString(),
follower: new URL(
`/users/${follower.id}`,
config.http.base_url,
).toString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
};
};

View file

@ -7,7 +7,6 @@ import {
} from "~database/entities/Relationship";
import {
followRequestUser,
followUser,
getRelationshipToOtherUser,
} from "~database/entities/User";
@ -58,25 +57,14 @@ export default apiRoute<{
let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship.following) {
if (user.isLocked) {
relationship = await followRequestUser(
self,
user,
relationship.id,
reblogs,
notify,
languages,
);
} else {
relationship = await followUser(
self,
user,
relationship.id,
reblogs,
notify,
languages,
);
}
relationship = await followRequestUser(
self,
user,
relationship.id,
reblogs,
notify,
languages,
);
}
return jsonResponse(relationshipToAPI(relationship));

View file

@ -5,6 +5,12 @@ import { userRelations } from "~database/entities/relations";
import type * as Lysand from "lysand-types";
import { createNewStatus } from "~database/entities/Status";
import type { APIStatus } from "~types/entities/status";
import {
followAcceptToLysand,
getRelationshipToOtherUser,
resolveUser,
} from "~database/entities/User";
import { objectToInboxRequest } from "~database/entities/Federation";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -120,11 +126,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
case "Note": {
const note = body as Lysand.Note;
const account = await client.user.findUnique({
where: {
uri: note.author,
},
});
const account = await resolveUser(note.author);
if (!account) {
return errorResponse("Author not found", 400);
@ -153,6 +155,65 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return response("Note created", 201);
}
case "Follow": {
const follow = body as Lysand.Follow;
const account = await resolveUser(follow.author);
if (!account) {
return errorResponse("Author not found", 400);
}
const relationship = await getRelationshipToOtherUser(
account,
user,
);
// Check if already following
if (relationship.following) {
return response("Already following", 200);
}
await client.relationship.update({
where: { id: relationship.id },
data: {
following: !user.isLocked,
requested: user.isLocked,
showingReblogs: true,
notifying: true,
languages: [],
},
});
await client.notification.create({
data: {
accountId: account.id,
type: user.isLocked ? "follow_request" : "follow",
notifiedId: user.id,
},
});
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) {
throw new Error(
`Failed to federate follow accept from ${user.id} to ${account.uri}`,
);
}
}
return response("Follow request sent", 200);
}
default: {
return errorResponse("Unknown object type", 400);
}