mirror of
https://github.com/versia-pub/server.git
synced 2025-12-08 09:18: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 type { UserWithRelations } from "./User";
|
||||||
import { resolveUser, parseMentionsUris, userToAPI } from "./User";
|
import { resolveUser, parseMentionsUris, userToAPI } from "./User";
|
||||||
import { statusAndUserRelations, userRelations } from "./relations";
|
import { statusAndUserRelations, userRelations } from "./relations";
|
||||||
|
import { objectToInboxRequest } from "./Federation";
|
||||||
|
|
||||||
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
|
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
|
||||||
include: statusAndUserRelations,
|
include: statusAndUserRelations,
|
||||||
|
|
@ -262,7 +263,11 @@ export const federateStatus = async (status: StatusWithRelations) => {
|
||||||
|
|
||||||
for (const user of toFederateTo) {
|
for (const user of toFederateTo) {
|
||||||
// TODO: Add queue system
|
// TODO: Add queue system
|
||||||
const request = await statusToInboxRequest(status, user);
|
const request = await objectToInboxRequest(
|
||||||
|
statusToLysand(status),
|
||||||
|
status.author,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
const response = await fetch(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) => {
|
export const getUsersToFederateTo = async (status: StatusWithRelations) => {
|
||||||
return await client.user.findMany({
|
return await client.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { addInstanceIfNotExists } from "./Instance";
|
||||||
import { userRelations } from "./relations";
|
import { userRelations } from "./relations";
|
||||||
import { createNewRelationship } from "./Relationship";
|
import { createNewRelationship } from "./Relationship";
|
||||||
import { getBestContentType, urlToContentFormat } from "@content_types";
|
import { getBestContentType, urlToContentFormat } from "@content_types";
|
||||||
|
import { objectToInboxRequest } from "./Federation";
|
||||||
|
|
||||||
export interface AuthData {
|
export interface AuthData {
|
||||||
user: UserWithRelations | null;
|
user: UserWithRelations | null;
|
||||||
|
|
@ -60,40 +61,6 @@ export const getFromRequest = async (req: Request): Promise<AuthData> => {
|
||||||
return { user: await retrieveUserFromToken(token), token };
|
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 (
|
export const followRequestUser = async (
|
||||||
follower: User,
|
follower: User,
|
||||||
followee: User,
|
followee: User,
|
||||||
|
|
@ -102,27 +69,54 @@ export const followRequestUser = async (
|
||||||
notify = false,
|
notify = false,
|
||||||
languages: string[] = [],
|
languages: string[] = [],
|
||||||
) => {
|
) => {
|
||||||
|
const isRemote = follower.instanceId !== followee.instanceId;
|
||||||
|
|
||||||
const relationship = await client.relationship.update({
|
const relationship = await client.relationship.update({
|
||||||
where: { id: relationshipId },
|
where: { id: relationshipId },
|
||||||
data: {
|
data: {
|
||||||
requested: true,
|
following: isRemote ? false : !followee.isLocked,
|
||||||
|
requested: isRemote ? true : followee.isLocked,
|
||||||
showingReblogs: reblogs,
|
showingReblogs: reblogs,
|
||||||
notifying: notify,
|
notifying: notify,
|
||||||
languages: languages,
|
languages: languages,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (follower.instanceId === followee.instanceId) {
|
if (isRemote) {
|
||||||
// Notify the user that their post has been favourited
|
// Federate
|
||||||
await client.notification.create({
|
// TODO: Make database job
|
||||||
data: {
|
const request = await objectToInboxRequest(
|
||||||
accountId: follower.id,
|
followRequestToLysand(follower, followee),
|
||||||
type: "follow_request",
|
follower,
|
||||||
notifiedId: followee.id,
|
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 {
|
} 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;
|
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";
|
} from "~database/entities/Relationship";
|
||||||
import {
|
import {
|
||||||
followRequestUser,
|
followRequestUser,
|
||||||
followUser,
|
|
||||||
getRelationshipToOtherUser,
|
getRelationshipToOtherUser,
|
||||||
} from "~database/entities/User";
|
} from "~database/entities/User";
|
||||||
|
|
||||||
|
|
@ -58,25 +57,14 @@ export default apiRoute<{
|
||||||
let relationship = await getRelationshipToOtherUser(self, user);
|
let relationship = await getRelationshipToOtherUser(self, user);
|
||||||
|
|
||||||
if (!relationship.following) {
|
if (!relationship.following) {
|
||||||
if (user.isLocked) {
|
relationship = await followRequestUser(
|
||||||
relationship = await followRequestUser(
|
self,
|
||||||
self,
|
user,
|
||||||
user,
|
relationship.id,
|
||||||
relationship.id,
|
reblogs,
|
||||||
reblogs,
|
notify,
|
||||||
notify,
|
languages,
|
||||||
languages,
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
relationship = await followUser(
|
|
||||||
self,
|
|
||||||
user,
|
|
||||||
relationship.id,
|
|
||||||
reblogs,
|
|
||||||
notify,
|
|
||||||
languages,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(relationshipToAPI(relationship));
|
return jsonResponse(relationshipToAPI(relationship));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import { userRelations } from "~database/entities/relations";
|
||||||
import type * as Lysand from "lysand-types";
|
import type * as Lysand from "lysand-types";
|
||||||
import { createNewStatus } from "~database/entities/Status";
|
import { createNewStatus } from "~database/entities/Status";
|
||||||
import type { APIStatus } from "~types/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({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -120,11 +126,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
case "Note": {
|
case "Note": {
|
||||||
const note = body as Lysand.Note;
|
const note = body as Lysand.Note;
|
||||||
|
|
||||||
const account = await client.user.findUnique({
|
const account = await resolveUser(note.author);
|
||||||
where: {
|
|
||||||
uri: note.author,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return errorResponse("Author not found", 400);
|
return errorResponse("Author not found", 400);
|
||||||
|
|
@ -153,6 +155,65 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
|
|
||||||
return response("Note created", 201);
|
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: {
|
default: {
|
||||||
return errorResponse("Unknown object type", 400);
|
return errorResponse("Unknown object type", 400);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue