mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Add following
This commit is contained in:
parent
7da7febd00
commit
f56e4f623a
58
database/entities/Federation.ts
Normal file
58
database/entities/Federation.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue