server/database/entities/User.ts

651 lines
18 KiB
TypeScript
Raw Normal View History

2024-04-07 07:30:49 +02:00
import { addUserToMeilisearch } from "@meilisearch";
import type { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
2024-04-07 07:30:49 +02:00
import { type Config, config } from "config-manager";
import { htmlToText } from "html-to-text";
import { client } from "~database/datasource";
2024-04-07 07:30:49 +02:00
import type { APIAccount } from "~types/entities/account";
import type { APISource } from "~types/entities/source";
import type * as Lysand from "lysand-types";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
2024-03-14 04:39:32 +01:00
import { userRelations } from "./relations";
import { createNewRelationship } from "./Relationship";
2024-04-10 07:13:13 +02:00
import { getBestContentType, urlToContentFormat } from "@content_types";
2024-04-10 07:51:00 +02:00
import { objectToInboxRequest } from "./Federation";
export interface AuthData {
2024-04-07 07:30:49 +02:00
user: UserWithRelations | null;
token: string;
}
2023-09-12 22:48:10 +02:00
/**
2023-09-28 20:19:21 +02:00
* Represents a user in the database.
2023-09-12 22:48:10 +02:00
* Stores local and remote users
*/
const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({
2024-04-07 07:30:49 +02:00
include: userRelations,
});
export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
/**
* Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
2024-04-07 06:16:54 +02:00
export const getAvatarUrl = (user: User, config: Config) => {
2024-04-08 05:55:12 +02:00
if (!user.avatar)
return (
config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${user.username}`
);
2024-04-08 06:07:11 +02:00
return user.avatar;
};
/**
* Get the user's header in raw URL format
* @param config The config to use
* @returns The raw URL for the user's header
*/
2024-04-07 06:16:54 +02:00
export const getHeaderUrl = (user: User, config: Config) => {
2024-04-07 07:30:49 +02:00
if (!user.header) return config.defaults.header;
2024-04-08 06:07:11 +02:00
return user.header;
};
export const getFromRequest = async (req: Request): Promise<AuthData> => {
2024-04-07 07:30:49 +02:00
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
2024-04-07 07:30:49 +02:00
return { user: await retrieveUserFromToken(token), token };
};
2024-04-10 07:51:00 +02:00
export const followRequestUser = async (
follower: User,
followee: User,
2024-04-09 04:26:48 +02:00
relationshipId: string,
reblogs = false,
notify = false,
languages: string[] = [],
) => {
2024-04-10 07:51:00 +02:00
const isRemote = follower.instanceId !== followee.instanceId;
const relationship = await client.relationship.update({
2024-04-09 04:26:48 +02:00
where: { id: relationshipId },
data: {
2024-04-10 07:51:00 +02:00
following: isRemote ? false : !followee.isLocked,
requested: isRemote ? true : followee.isLocked,
showingReblogs: reblogs,
notifying: notify,
languages: languages,
},
});
2024-04-10 07:51:00 +02:00
if (isRemote) {
// Federate
// TODO: Make database job
const request = await objectToInboxRequest(
followRequestToLysand(follower, followee),
follower,
followee,
);
2024-04-10 07:51:00 +02:00
// Send request
const response = await fetch(request);
2024-04-10 07:51:00 +02:00
if (!response.ok) {
2024-04-10 07:56:46 +02:00
console.error(response.text());
2024-04-10 07:51:00 +02:00
throw new Error(
`Failed to federate follow request from ${follower.id} to ${followee.uri}`,
);
}
} else {
2024-04-10 07:51:00 +02:00
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;
};
export const resolveUser = async (uri: string) => {
2024-04-07 07:30:49 +02:00
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
uri,
},
include: userRelations,
});
if (foundUser) return foundUser;
const response = await fetch(uri, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
const data = (await response.json()) as Partial<Lysand.User>;
2024-04-07 07:30:49 +02:00
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.dislikes &&
2024-04-07 07:30:49 +02:00
data.featured &&
data.likes &&
2024-04-07 07:30:49 +02:00
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
// Parse emojis and add them to database
const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
2024-04-10 07:13:13 +02:00
const instance = await addInstanceIfNotExists(data.uri);
const emojis = [];
for (const emoji of userEmojis) {
emojis.push(await addEmojiIfNotExists(emoji));
}
2024-04-07 07:30:49 +02:00
const user = await client.user.create({
data: {
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at),
endpoints: {
dislikes: data.dislikes,
2024-04-07 07:30:49 +02:00
featured: data.featured,
likes: data.likes,
2024-04-07 07:30:49 +02:00
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
2024-04-10 07:13:13 +02:00
emojis: {
connect: emojis.map((emoji) => ({
id: emoji.id,
})),
},
instanceId: instance.id,
avatar: data.avatar
? Object.entries(data.avatar)[0][1].content
: "",
header: data.header
? Object.entries(data.header)[0][1].content
: "",
2024-04-07 07:30:49 +02:00
displayName: data.display_name ?? "",
2024-04-10 07:13:13 +02:00
note: getBestContentType(data.bio).content,
2024-04-07 07:30:49 +02:00
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
2024-04-10 07:13:13 +02:00
include: userRelations,
2024-04-07 07:30:49 +02:00
});
// Add to Meilisearch
await addUserToMeilisearch(user);
2024-04-10 07:13:13 +02:00
return user;
};
2023-09-22 05:18:05 +02:00
/**
* Resolves a WebFinger identifier to a user.
* @param identifier Either a UUID or a username
*/
export const resolveWebFinger = async (identifier: string, host: string) => {
// Check if user not already in database
const foundUser = await client.user.findUnique({
where: {
username: identifier,
instance: {
base_url: host,
},
},
include: userRelations,
});
if (foundUser) return foundUser;
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
const response = await fetch(
new URL(
`/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${identifier}@${host}`,
})}`,
hostWithProtocol,
),
{
method: "GET",
headers: {
Accept: "application/json",
},
},
);
const data = (await response.json()) as {
subject: string;
links: {
rel: string;
type: string;
href: string;
}[];
};
if (!data.subject || !data.links) {
throw new Error(
"Invalid WebFinger data (missing subject or links from response)",
);
}
const relevantLink = data.links.find((link) => link.rel === "self");
if (!relevantLink) {
throw new Error(
"Invalid WebFinger data (missing link with rel: 'self')",
);
}
return resolveUser(relevantLink.href);
};
/**
* Fetches the list of followers associated with the actor and updates the user's followers
*/
export const fetchFollowers = () => {
2024-04-07 07:30:49 +02:00
//
};
2023-09-22 05:18:05 +02:00
/**
* Creates a new LOCAL user.
* @param data The data for the new user.
* @returns The newly created user.
*/
export const createNewLocalUser = async (data: {
2024-04-07 07:30:49 +02:00
username: string;
display_name?: string;
password: string;
email: string;
bio?: string;
avatar?: string;
header?: string;
admin?: boolean;
}) => {
2024-04-07 07:30:49 +02:00
const keys = await generateUserKeys();
const user = await client.user.create({
data: {
username: data.username,
displayName: data.display_name ?? data.username,
password: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar,
header: data.header ?? config.defaults.avatar,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
privateKey: keys.private_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
},
include: userRelations,
2024-04-07 07:30:49 +02:00
});
// Add to Meilisearch
await addUserToMeilisearch(user);
return user;
};
2023-09-22 03:09:14 +02:00
/**
* Parses mentions from a list of URIs
*/
export const parseMentionsUris = async (mentions: string[]) => {
2024-04-07 07:30:49 +02:00
return await client.user.findMany({
where: {
uri: {
in: mentions,
},
},
include: userRelations,
});
};
2023-09-22 03:09:14 +02:00
/**
* Retrieves a user from a token.
* @param access_token The access token to retrieve the user from.
* @returns The user associated with the given access token.
*/
export const retrieveUserFromToken = async (access_token: string) => {
2024-04-07 07:30:49 +02:00
if (!access_token) return null;
const token = await client.token.findFirst({
where: {
access_token,
},
include: {
user: {
include: userRelations,
},
},
});
if (!token) return null;
return token.user;
};
2023-09-22 05:18:05 +02:00
/**
* Gets the relationship to another user.
* @param other The other user to get the relationship to.
* @returns The relationship to the other user.
*/
export const getRelationshipToOtherUser = async (
2024-04-07 07:30:49 +02:00
user: UserWithRelations,
other: User,
) => {
const relationship = await client.relationship.findFirst({
2024-04-07 07:30:49 +02:00
where: {
ownerId: user.id,
subjectId: other.id,
},
});
if (!relationship) {
// Create new relationship
const newRelationship = await createNewRelationship(user, other);
await client.user.update({
where: { id: user.id },
data: {
relationships: {
connect: {
id: newRelationship.id,
},
},
},
});
return newRelationship;
}
return relationship;
};
2023-09-22 03:09:14 +02:00
/**
* Generates keys for the user.
*/
export const generateUserKeys = async () => {
2024-04-07 07:30:49 +02:00
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = btoa(
String.fromCharCode.apply(null, [
...new Uint8Array(
// jesus help me what do these letters mean
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
),
]),
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
// why is exporting a key so hard
await crypto.subtle.exportKey("spki", keys.publicKey),
),
),
);
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key: privateKey,
public_key: publicKey,
};
};
2023-11-20 03:42:40 +01:00
export const userToAPI = (
2024-04-07 07:30:49 +02:00
user: UserWithRelations,
isOwnAccount = false,
2023-11-20 03:42:40 +01:00
): APIAccount => {
2024-04-07 07:30:49 +02:00
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(),
2024-04-07 07:30:49 +02:00
avatar: getAvatarUrl(user, config),
header: getHeaderUrl(user, config),
locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(),
followers_count: user.relationshipSubjects.filter((r) => r.following)
.length,
following_count: user.relationships.filter((r) => r.following).length,
statuses_count: user._count.statuses,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields
fields: [],
bot: user.isBot,
source:
isOwnAccount && user.source
? (user.source as APISource)
: undefined,
// TODO: Add static avatar and header
avatar_static: "",
header_static: "",
acct:
user.instance === null
? user.username
: `${user.username}@${user.instance.base_url}`,
// TODO: Add these fields
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
},
};
};
2023-09-18 07:38:08 +02:00
/**
* Should only return local users
*/
export const userToLysand = (user: UserWithRelations): Lysand.User => {
2024-04-07 07:30:49 +02:00
if (user.instanceId !== null) {
throw new Error("Cannot convert remote user to Lysand format");
}
return {
id: user.id,
type: "User",
uri:
user.uri ||
new URL(`/users/${user.id}`, config.http.base_url).toString(),
bio: {
"text/html": {
2024-04-07 07:30:49 +02:00
content: user.note,
},
"text/plain": {
2024-04-07 07:30:49 +02:00
content: htmlToText(user.note),
},
},
2024-04-07 07:30:49 +02:00
created_at: new Date(user.createdAt).toISOString(),
dislikes: new URL(
`/users/${user.id}/dislikes`,
2024-04-08 05:28:18 +02:00
config.http.base_url,
).toString(),
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).toString(),
likes: new URL(
`/users/${user.id}/likes`,
2024-04-08 05:28:18 +02:00
config.http.base_url,
).toString(),
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).toString(),
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).toString(),
inbox: new URL(
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
2024-04-07 07:30:49 +02:00
indexable: false,
username: user.username,
2024-04-10 04:05:02 +02:00
avatar: urlToContentFormat(getAvatarUrl(user, config)) ?? undefined,
header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined,
2024-04-07 07:30:49 +02:00
display_name: user.displayName,
fields: (user.source as APISource).fields.map((field) => ({
key: {
"text/html": {
2024-04-07 07:30:49 +02:00
content: field.name,
},
"text/plain": {
2024-04-07 07:30:49 +02:00
content: htmlToText(field.name),
},
},
value: {
"text/html": {
2024-04-07 07:30:49 +02:00
content: field.value,
},
"text/plain": {
2024-04-07 07:30:49 +02:00
content: htmlToText(field.value),
},
},
2024-04-07 07:30:49 +02:00
})),
public_key: {
2024-04-08 05:28:18 +02:00
actor: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
2024-04-07 07:30:49 +02:00
public_key: user.publicKey,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
},
},
};
};
2024-04-10 07:51:00 +02:00
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(),
};
};