mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(api): 🎨 Move User methods into their own class similar to Note
This commit is contained in:
parent
abc8f1ae16
commit
9d70778abd
81 changed files with 2055 additions and 1603 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { config } from "config-manager";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { type User, getUserUri } from "./User";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
export const localObjectURI = (id: string) => `/objects/${id}`;
|
||||
|
||||
|
|
@ -9,17 +9,17 @@ export const objectToInboxRequest = async (
|
|||
author: User,
|
||||
userToSendTo: User,
|
||||
): Promise<Request> => {
|
||||
if (!userToSendTo.instanceId || !userToSendTo.endpoints?.inbox) {
|
||||
if (userToSendTo.isLocal() || !userToSendTo.getUser().endpoints?.inbox) {
|
||||
throw new Error("UserToSendTo has no inbox or is a local user");
|
||||
}
|
||||
|
||||
if (author.instanceId) {
|
||||
if (author.isRemote()) {
|
||||
throw new Error("Author is a remote user");
|
||||
}
|
||||
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(author.privateKey ?? "", "base64"),
|
||||
Buffer.from(author.getUser().privateKey ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
|
|
@ -30,7 +30,7 @@ export const objectToInboxRequest = async (
|
|||
new TextEncoder().encode(JSON.stringify(object)),
|
||||
);
|
||||
|
||||
const userInbox = new URL(userToSendTo.endpoints.inbox);
|
||||
const userInbox = new URL(userToSendTo.getUser().endpoints?.inbox ?? "");
|
||||
|
||||
const date = new Date();
|
||||
|
||||
|
|
@ -41,14 +41,14 @@ export const objectToInboxRequest = async (
|
|||
`(request-target): post ${userInbox.pathname}\n` +
|
||||
`host: ${userInbox.host}\n` +
|
||||
`date: ${date.toISOString()}\n` +
|
||||
`digest: SHA-256=${btoa(
|
||||
String.fromCharCode(...new Uint8Array(digest)),
|
||||
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
|
||||
"base64",
|
||||
)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const signatureBase64 = btoa(
|
||||
String.fromCharCode(...new Uint8Array(signature)),
|
||||
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return new Request(userInbox, {
|
||||
|
|
@ -57,9 +57,7 @@ export const objectToInboxRequest = async (
|
|||
"Content-Type": "application/json",
|
||||
Date: date.toISOString(),
|
||||
Origin: new URL(config.http.base_url).host,
|
||||
Signature: `keyId="${getUserUri(
|
||||
author,
|
||||
)}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
Signature: `keyId="${author.getUri()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
},
|
||||
body: JSON.stringify(object),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const addInstanceIfNotExists = async (url: string) => {
|
|||
const origin = new URL(url).origin;
|
||||
const host = new URL(url).host;
|
||||
|
||||
const found = await db.query.instance.findFirst({
|
||||
const found = await db.query.Instances.findFirst({
|
||||
where: (instance, { eq }) => eq(instance.baseUrl, host),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { type InferSelectModel, and, eq } from "drizzle-orm";
|
|||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Likes, Notifications } from "~drizzle/schema";
|
||||
import type { StatusWithRelations } from "./Status";
|
||||
import type { UserWithRelations } from "./User";
|
||||
import type { Note } from "~packages/database-interface/note";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
export type Like = InferSelectModel<typeof Likes>;
|
||||
|
||||
|
|
@ -27,24 +27,21 @@ export const likeToLysand = (like: Like): Lysand.Like => {
|
|||
/**
|
||||
* Create a like
|
||||
* @param user User liking the status
|
||||
* @param status Status being liked
|
||||
* @param note Status being liked
|
||||
*/
|
||||
export const createLike = async (
|
||||
user: UserWithRelations,
|
||||
status: StatusWithRelations,
|
||||
) => {
|
||||
export const createLike = async (user: User, note: Note) => {
|
||||
await db.insert(Likes).values({
|
||||
likedId: status.id,
|
||||
likedId: note.id,
|
||||
likerId: user.id,
|
||||
});
|
||||
|
||||
if (status.author.instanceId === user.instanceId) {
|
||||
if (note.getAuthor().getUser().instanceId === user.getUser().instanceId) {
|
||||
// Notify the user that their post has been favourited
|
||||
await db.insert(Notifications).values({
|
||||
accountId: user.id,
|
||||
type: "favourite",
|
||||
notifiedId: status.authorId,
|
||||
noteId: status.id,
|
||||
notifiedId: note.getAuthor().id,
|
||||
noteId: note.id,
|
||||
});
|
||||
} else {
|
||||
// TODO: Add database jobs for federating this
|
||||
|
|
@ -54,15 +51,12 @@ export const createLike = async (
|
|||
/**
|
||||
* Delete a like
|
||||
* @param user User deleting their like
|
||||
* @param status Status being unliked
|
||||
* @param note Status being unliked
|
||||
*/
|
||||
export const deleteLike = async (
|
||||
user: UserWithRelations,
|
||||
status: StatusWithRelations,
|
||||
) => {
|
||||
export const deleteLike = async (user: User, note: Note) => {
|
||||
await db
|
||||
.delete(Likes)
|
||||
.where(and(eq(Likes.likedId, status.id), eq(Likes.likerId, user.id)));
|
||||
.where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id)));
|
||||
|
||||
// Notify the user that their post has been favourited
|
||||
await db
|
||||
|
|
@ -71,12 +65,12 @@ export const deleteLike = async (
|
|||
and(
|
||||
eq(Notifications.accountId, user.id),
|
||||
eq(Notifications.type, "favourite"),
|
||||
eq(Notifications.notifiedId, status.authorId),
|
||||
eq(Notifications.noteId, status.id),
|
||||
eq(Notifications.notifiedId, note.getAuthor().id),
|
||||
eq(Notifications.noteId, note.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (user.instanceId === null && status.author.instanceId !== null) {
|
||||
if (user.isLocal() && note.getAuthor().isRemote()) {
|
||||
// User is local, federate the delete
|
||||
// TODO: Federate this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { InferSelectModel } from "drizzle-orm";
|
|||
import { db } from "~drizzle/db";
|
||||
import type { Notifications } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import type { StatusWithRelations } from "./Status";
|
||||
import {
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
userToAPI,
|
||||
} from "./User";
|
||||
|
||||
export type Notification = InferSelectModel<typeof Notifications>;
|
||||
|
|
@ -50,15 +50,14 @@ export const findManyNotifications = async (
|
|||
export const notificationToAPI = async (
|
||||
notification: NotificationWithRelations,
|
||||
): Promise<APINotification> => {
|
||||
const account = new User(notification.account);
|
||||
return {
|
||||
account: userToAPI(notification.account),
|
||||
account: account.toAPI(),
|
||||
created_at: new Date(notification.createdAt).toISOString(),
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
status: notification.status
|
||||
? await Note.fromStatus(notification.status).toAPI(
|
||||
notification.account,
|
||||
)
|
||||
? await Note.fromStatus(notification.status).toAPI(account)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const createFromObject = async (
|
|||
object: Lysand.Entity,
|
||||
authorUri: string,
|
||||
) => {
|
||||
const foundObject = await db.query.lysandObject.findFirst({
|
||||
const foundObject = await db.query.LysandObjects.findFirst({
|
||||
where: (o, { eq }) => eq(o.remoteId, object.id),
|
||||
with: {
|
||||
author: true,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Relationships } from "~drizzle/schema";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
|
||||
import type { User } from "./User";
|
||||
import type { UserType } from "./User";
|
||||
|
||||
export type Relationship = InferSelectModel<typeof Relationships>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||
import { dualLogger } from "@loggers";
|
||||
import { sanitizeHtml } from "@sanitization";
|
||||
import { config } from "config-manager";
|
||||
|
|
@ -10,7 +11,6 @@ import {
|
|||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import linkifyHtml from "linkify-html";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import {
|
||||
|
|
@ -24,46 +24,30 @@ import {
|
|||
maybe,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import markdownItContainer from "markdown-it-container";
|
||||
import markdownItTocDoneRight from "markdown-it-toc-done-right";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
Attachments,
|
||||
EmojiToNote,
|
||||
Instances,
|
||||
NoteToMentions,
|
||||
Notes,
|
||||
Notifications,
|
||||
Users,
|
||||
} from "~drizzle/schema";
|
||||
import { type Attachments, Instances, Notes, Users } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||
import type { Application } from "./Application";
|
||||
import { attachmentFromLysand, attachmentToLysand } from "./Attachment";
|
||||
import {
|
||||
type EmojiWithInstance,
|
||||
emojiToLysand,
|
||||
fetchEmoji,
|
||||
parseEmojis,
|
||||
} from "./Emoji";
|
||||
import { attachmentFromLysand } from "./Attachment";
|
||||
import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import type { Like } from "./Like";
|
||||
import {
|
||||
type User,
|
||||
type UserType,
|
||||
type UserWithInstance,
|
||||
type UserWithRelations,
|
||||
findManyUsers,
|
||||
getUserUri,
|
||||
resolveUser,
|
||||
resolveWebFinger,
|
||||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
} from "./User";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItTocDoneRight from "markdown-it-toc-done-right";
|
||||
import markdownItContainer from "markdown-it-container";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||
|
||||
export type Status = InferSelectModel<typeof Notes>;
|
||||
|
||||
|
|
@ -362,7 +346,7 @@ export const resolveNote = async (
|
|||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const author = await resolveUser(note.author);
|
||||
const author = await User.resolve(note.author);
|
||||
|
||||
if (!author) {
|
||||
throw new Error("Invalid object author");
|
||||
|
|
@ -415,10 +399,8 @@ export const resolveNote = async (
|
|||
note.uri,
|
||||
await Promise.all(
|
||||
(note.mentions ?? [])
|
||||
.map((mention) => resolveUser(mention))
|
||||
.filter(
|
||||
(mention) => mention !== null,
|
||||
) as Promise<UserWithRelations>[],
|
||||
.map((mention) => User.resolve(mention))
|
||||
.filter((mention) => mention !== null) as Promise<User>[],
|
||||
),
|
||||
attachments.map((a) => a.id),
|
||||
note.replies_to
|
||||
|
|
@ -454,9 +436,7 @@ export const createMentionRegExp = () =>
|
|||
* @param text The text to parse mentions from.
|
||||
* @returns An array of users mentioned in the text.
|
||||
*/
|
||||
export const parseTextMentions = async (
|
||||
text: string,
|
||||
): Promise<UserWithRelations[]> => {
|
||||
export const parseTextMentions = async (text: string): Promise<User[]> => {
|
||||
const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? [];
|
||||
if (mentionedPeople.length === 0) return [];
|
||||
|
||||
|
|
@ -497,13 +477,12 @@ export const parseTextMentions = async (
|
|||
|
||||
const finalList =
|
||||
foundUsers.length > 0
|
||||
? await findManyUsers({
|
||||
where: (user, { inArray }) =>
|
||||
inArray(
|
||||
user.id,
|
||||
foundUsers.map((u) => u.id),
|
||||
),
|
||||
})
|
||||
? await User.manyFromSql(
|
||||
inArray(
|
||||
Users.id,
|
||||
foundUsers.map((u) => u.id),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Attempt to resolve mentions that were not found
|
||||
|
|
@ -521,49 +500,47 @@ export const parseTextMentions = async (
|
|||
return finalList;
|
||||
};
|
||||
|
||||
export const replaceTextMentions = async (
|
||||
text: string,
|
||||
mentions: UserWithRelations[],
|
||||
) => {
|
||||
export const replaceTextMentions = async (text: string, mentions: User[]) => {
|
||||
let finalText = text;
|
||||
for (const mention of mentions) {
|
||||
const user = mention.getUser();
|
||||
// Replace @username and @username@domain
|
||||
if (mention.instance) {
|
||||
if (user.instance) {
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(`@${mention.username}@${mention.instance.baseUrl}`),
|
||||
exactly(`@${user.username}@${user.instance.baseUrl}`),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
|
||||
mention,
|
||||
)}">@${mention.username}@${mention.instance.baseUrl}</a>`,
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}@${user.instance.baseUrl}</a>`,
|
||||
);
|
||||
} else {
|
||||
finalText = finalText.replace(
|
||||
// Only replace @username if it doesn't have another @ right after
|
||||
createRegExp(
|
||||
exactly(`@${mention.username}`)
|
||||
exactly(`@${user.username}`)
|
||||
.notBefore(anyOf(letter, digit, charIn("@")))
|
||||
.notAfter(anyOf(letter, digit, charIn("@"))),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
|
||||
mention,
|
||||
)}">@${mention.username}</a>`,
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(
|
||||
`@${mention.username}@${
|
||||
`@${user.username}@${
|
||||
new URL(config.http.base_url).host
|
||||
}`,
|
||||
),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
|
||||
mention,
|
||||
)}">@${mention.username}</a>`,
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -573,7 +550,7 @@ export const replaceTextMentions = async (
|
|||
|
||||
export const contentToHtml = async (
|
||||
content: Lysand.ContentFormat,
|
||||
mentions: UserWithRelations[] = [],
|
||||
mentions: User[] = [],
|
||||
): Promise<string> => {
|
||||
let htmlContent: string;
|
||||
|
||||
|
|
@ -663,152 +640,17 @@ export const federateNote = async (note: Note) => {
|
|||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.Status",
|
||||
`Failed to federate status ${note.getStatus().id} to ${
|
||||
user.uri
|
||||
}`,
|
||||
`Failed to federate status ${
|
||||
note.getStatus().id
|
||||
} to ${user.getUri()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const editStatus = async (
|
||||
statusToEdit: StatusWithRelations,
|
||||
data: {
|
||||
content: string;
|
||||
visibility?: APIStatus["visibility"];
|
||||
sensitive: boolean;
|
||||
spoiler_text: string;
|
||||
emojis?: EmojiWithInstance[];
|
||||
content_type?: string;
|
||||
uri?: string;
|
||||
mentions?: User[];
|
||||
media_attachments?: string[];
|
||||
},
|
||||
): Promise<Note | null> => {
|
||||
const mentions = await parseTextMentions(data.content);
|
||||
|
||||
// Parse emojis
|
||||
const emojis = await parseEmojis(data.content);
|
||||
|
||||
// Fuse and deduplicate emojis
|
||||
data.emojis = data.emojis
|
||||
? [...data.emojis, ...emojis].filter(
|
||||
(emoji, index, self) =>
|
||||
index === self.findIndex((t) => t.id === emoji.id),
|
||||
)
|
||||
: emojis;
|
||||
|
||||
const htmlContent = await contentToHtml({
|
||||
[data.content_type ?? "text/plain"]: {
|
||||
content: data.content,
|
||||
},
|
||||
});
|
||||
|
||||
const note = await Note.fromId(statusToEdit.id);
|
||||
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await note.update({
|
||||
content: htmlContent,
|
||||
contentSource: data.content,
|
||||
contentType: data.content_type,
|
||||
visibility: data.visibility,
|
||||
sensitive: data.sensitive,
|
||||
spoilerText: data.spoiler_text,
|
||||
});
|
||||
|
||||
// Connect emojis
|
||||
for (const emoji of data.emojis) {
|
||||
await db
|
||||
.insert(EmojiToNote)
|
||||
.values({
|
||||
emojiId: emoji.id,
|
||||
noteId: updated.id,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Connect mentions
|
||||
for (const mention of mentions) {
|
||||
await db
|
||||
.insert(NoteToMentions)
|
||||
.values({
|
||||
noteId: updated.id,
|
||||
userId: mention.id,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Send notifications for mentioned local users
|
||||
for (const mention of mentions ?? []) {
|
||||
if (mention.instanceId === null) {
|
||||
await db.insert(Notifications).values({
|
||||
accountId: statusToEdit.authorId,
|
||||
notifiedId: mention.id,
|
||||
type: "mention",
|
||||
noteId: updated.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set attachment parents
|
||||
await db
|
||||
.update(Attachments)
|
||||
.set({
|
||||
noteId: updated.id,
|
||||
})
|
||||
.where(inArray(Attachments.id, data.media_attachments ?? []));
|
||||
|
||||
return await Note.fromId(updated.id);
|
||||
};
|
||||
|
||||
export const isFavouritedBy = async (status: Status, user: User) => {
|
||||
export const isFavouritedBy = async (status: Status, user: UserType) => {
|
||||
return !!(await db.query.Likes.findFirst({
|
||||
where: (like, { and, eq }) =>
|
||||
and(eq(like.likerId, user.id), eq(like.likedId, status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getStatusUri = (status?: Status | null) => {
|
||||
if (!status) return undefined;
|
||||
|
||||
return (
|
||||
status.uri ||
|
||||
new URL(`/objects/${status.id}`, config.http.base_url).toString()
|
||||
);
|
||||
};
|
||||
|
||||
export const statusToLysand = (status: StatusWithRelations): Lysand.Note => {
|
||||
return {
|
||||
type: "Note",
|
||||
created_at: new Date(status.createdAt).toISOString(),
|
||||
id: status.id,
|
||||
author: getUserUri(status.author),
|
||||
uri: getStatusUri(status) ?? "",
|
||||
content: {
|
||||
"text/html": {
|
||||
content: status.content,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(status.content),
|
||||
},
|
||||
},
|
||||
attachments: (status.attachments ?? []).map((attachment) =>
|
||||
attachmentToLysand(attachment),
|
||||
),
|
||||
is_sensitive: status.sensitive,
|
||||
mentions: status.mentions.map((mention) => mention.uri || ""),
|
||||
quotes: getStatusUri(status.quote) ?? undefined,
|
||||
replies_to: getStatusUri(status.reply) ?? undefined,
|
||||
subject: status.spoilerText,
|
||||
visibility: status.visibility as Lysand.Visibility,
|
||||
extensions: {
|
||||
"org.lysand:custom_emojis": {
|
||||
emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
|
||||
},
|
||||
// TODO: Add polls and reactions
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,43 +1,32 @@
|
|||
import { getBestContentType, urlToContentFormat } from "@content_types";
|
||||
import { dualLogger } from "@loggers";
|
||||
import { addUserToMeilisearch } from "@meilisearch";
|
||||
import { type Config, config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq, sql } from "drizzle-orm";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import { config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
Applications,
|
||||
EmojiToUser,
|
||||
Instances,
|
||||
Notifications,
|
||||
Relationships,
|
||||
Tokens,
|
||||
Users,
|
||||
} from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||
import type { Mention as APIMention } from "~types/mastodon/mention";
|
||||
import type { Source as APISource } from "~types/mastodon/source";
|
||||
import type { Application } from "./Application";
|
||||
import {
|
||||
type EmojiWithInstance,
|
||||
emojiToAPI,
|
||||
emojiToLysand,
|
||||
fetchEmoji,
|
||||
} from "./Emoji";
|
||||
import type { EmojiWithInstance } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import { addInstanceIfNotExists } from "./Instance";
|
||||
import { createNewRelationship } from "./Relationship";
|
||||
import type { Token } from "./Token";
|
||||
|
||||
export type User = InferSelectModel<typeof Users>;
|
||||
export type UserType = InferSelectModel<typeof Users>;
|
||||
|
||||
export type UserWithInstance = User & {
|
||||
export type UserWithInstance = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
export type UserWithRelations = User & {
|
||||
export type UserWithRelations = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
emojis: EmojiWithInstance[];
|
||||
followerCount: number;
|
||||
|
|
@ -105,35 +94,11 @@ export const userExtrasTemplate = (name: string) => ({
|
|||
});
|
||||
|
||||
export interface AuthData {
|
||||
user: UserWithRelations | null;
|
||||
user: User | null;
|
||||
token: string;
|
||||
application: Application | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's avatar in raw URL format
|
||||
* @param config The config to use
|
||||
* @returns The raw URL for the user's avatar
|
||||
*/
|
||||
export const getAvatarUrl = (user: User, config: Config) => {
|
||||
if (!user.avatar)
|
||||
return (
|
||||
config.defaults.avatar ||
|
||||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${user.username}`
|
||||
);
|
||||
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
|
||||
*/
|
||||
export const getHeaderUrl = (user: User, config: Config) => {
|
||||
if (!user.header) return config.defaults.header;
|
||||
return user.header;
|
||||
};
|
||||
|
||||
export const getFromRequest = async (req: Request): Promise<AuthData> => {
|
||||
// Check auth token
|
||||
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
|
||||
|
|
@ -152,14 +117,14 @@ export const followRequestUser = async (
|
|||
notify = false,
|
||||
languages: string[] = [],
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const isRemote = followee.instanceId !== null;
|
||||
const isRemote = followee.isRemote();
|
||||
|
||||
const updatedRelationship = (
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set({
|
||||
following: isRemote ? false : !followee.isLocked,
|
||||
requested: isRemote ? true : followee.isLocked,
|
||||
following: isRemote ? false : !followee.getUser().isLocked,
|
||||
requested: isRemote ? true : followee.getUser().isLocked,
|
||||
showingReblogs: reblogs,
|
||||
notifying: notify,
|
||||
languages: languages,
|
||||
|
|
@ -190,7 +155,9 @@ export const followRequestUser = async (
|
|||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowRequest",
|
||||
`Failed to federate follow request from ${follower.id} to ${followee.uri}`,
|
||||
`Failed to federate follow request from ${
|
||||
follower.id
|
||||
} to ${followee.getUri()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -207,7 +174,7 @@ export const followRequestUser = async (
|
|||
} else {
|
||||
await db.insert(Notifications).values({
|
||||
accountId: follower.id,
|
||||
type: followee.isLocked ? "follow_request" : "follow",
|
||||
type: followee.getUser().isLocked ? "follow_request" : "follow",
|
||||
notifiedId: followee.id,
|
||||
});
|
||||
}
|
||||
|
|
@ -236,7 +203,9 @@ export const sendFollowAccept = async (follower: User, followee: User) => {
|
|||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowAccept",
|
||||
`Failed to federate follow accept from ${followee.id} to ${follower.uri}`,
|
||||
`Failed to federate follow accept from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -262,13 +231,15 @@ export const sendFollowReject = async (follower: User, followee: User) => {
|
|||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowReject",
|
||||
`Failed to federate follow reject from ${followee.id} to ${follower.uri}`,
|
||||
`Failed to federate follow reject from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const transformOutputToUserWithRelations = (
|
||||
user: Omit<User, "endpoints"> & {
|
||||
user: Omit<UserType, "endpoints"> & {
|
||||
followerCount: unknown;
|
||||
followingCount: unknown;
|
||||
statusCount: unknown;
|
||||
|
|
@ -343,146 +314,6 @@ export const findFirstUser = async (
|
|||
return transformOutputToUserWithRelations(output);
|
||||
};
|
||||
|
||||
export const resolveUser = async (
|
||||
uri: string,
|
||||
): Promise<UserWithRelations | null> => {
|
||||
// Check if user not already in database
|
||||
const foundUser = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.uri, uri),
|
||||
});
|
||||
|
||||
if (foundUser) return foundUser;
|
||||
|
||||
// Check if URI is of a local user
|
||||
if (uri.startsWith(config.http.base_url)) {
|
||||
const uuid = uri.match(
|
||||
/[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
|
||||
);
|
||||
|
||||
if (!uuid) {
|
||||
throw new Error(
|
||||
`URI ${uri} is of a local user, but it could not be parsed`,
|
||||
);
|
||||
}
|
||||
|
||||
const foundLocalUser = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, uuid[0]),
|
||||
});
|
||||
|
||||
return foundLocalUser || null;
|
||||
}
|
||||
|
||||
if (!URL.canParse(uri)) {
|
||||
throw new Error(`Invalid URI to parse ${uri}`);
|
||||
}
|
||||
|
||||
const response = await fetch(uri, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as Partial<Lysand.User>;
|
||||
|
||||
if (
|
||||
!(
|
||||
data.id &&
|
||||
data.username &&
|
||||
data.uri &&
|
||||
data.created_at &&
|
||||
data.dislikes &&
|
||||
data.featured &&
|
||||
data.likes &&
|
||||
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 ?? [];
|
||||
|
||||
const instance = await addInstanceIfNotExists(data.uri);
|
||||
|
||||
const emojis = [];
|
||||
|
||||
for (const emoji of userEmojis) {
|
||||
emojis.push(await fetchEmoji(emoji));
|
||||
}
|
||||
|
||||
const newUser = (
|
||||
await db
|
||||
.insert(Users)
|
||||
.values({
|
||||
username: data.username,
|
||||
uri: data.uri,
|
||||
createdAt: new Date(data.created_at).toISOString(),
|
||||
endpoints: {
|
||||
dislikes: data.dislikes,
|
||||
featured: data.featured,
|
||||
likes: data.likes,
|
||||
followers: data.followers,
|
||||
following: data.following,
|
||||
inbox: data.inbox,
|
||||
outbox: data.outbox,
|
||||
},
|
||||
updatedAt: new Date(data.created_at).toISOString(),
|
||||
instanceId: instance.id,
|
||||
avatar: data.avatar
|
||||
? Object.entries(data.avatar)[0][1].content
|
||||
: "",
|
||||
header: data.header
|
||||
? Object.entries(data.header)[0][1].content
|
||||
: "",
|
||||
displayName: data.display_name ?? "",
|
||||
note: getBestContentType(data.bio).content,
|
||||
publicKey: data.public_key.public_key,
|
||||
source: {
|
||||
language: null,
|
||||
note: "",
|
||||
privacy: "public",
|
||||
sensitive: false,
|
||||
fields: [],
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
// Add emojis to user
|
||||
if (emojis.length > 0) {
|
||||
await db.insert(EmojiToUser).values(
|
||||
emojis.map((emoji) => ({
|
||||
emojiId: emoji.id,
|
||||
userId: newUser.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const finalUser = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, newUser.id),
|
||||
});
|
||||
|
||||
if (!finalUser) return null;
|
||||
|
||||
// Add to Meilisearch
|
||||
await addUserToMeilisearch(finalUser);
|
||||
|
||||
return finalUser;
|
||||
};
|
||||
|
||||
export const getUserUri = (user: User) => {
|
||||
return (
|
||||
user.uri ||
|
||||
new URL(`/users/${user.id}`, config.http.base_url).toString()
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a WebFinger identifier to a user.
|
||||
* @param identifier Either a UUID or a username
|
||||
|
|
@ -490,7 +321,7 @@ export const getUserUri = (user: User) => {
|
|||
export const resolveWebFinger = async (
|
||||
identifier: string,
|
||||
host: string,
|
||||
): Promise<UserWithRelations | null> => {
|
||||
): Promise<User | null> => {
|
||||
// Check if user not already in database
|
||||
const foundUser = await db
|
||||
.select()
|
||||
|
|
@ -499,12 +330,7 @@ export const resolveWebFinger = async (
|
|||
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
|
||||
.limit(1);
|
||||
|
||||
if (foundUser[0])
|
||||
return (
|
||||
(await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, foundUser[0].Users.id),
|
||||
})) || null
|
||||
);
|
||||
if (foundUser[0]) return await User.fromId(foundUser[0].Users.id);
|
||||
|
||||
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
|
||||
|
||||
|
|
@ -550,7 +376,7 @@ export const resolveWebFinger = async (
|
|||
);
|
||||
}
|
||||
|
||||
return resolveUser(relevantLink.href);
|
||||
return User.resolve(relevantLink.href);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -575,7 +401,7 @@ export const createNewLocalUser = async (data: {
|
|||
header?: string;
|
||||
admin?: boolean;
|
||||
skipPasswordHash?: boolean;
|
||||
}): Promise<UserWithRelations | null> => {
|
||||
}): Promise<User | null> => {
|
||||
const keys = await generateUserKeys();
|
||||
|
||||
const newUser = (
|
||||
|
|
@ -606,9 +432,7 @@ export const createNewLocalUser = async (data: {
|
|||
.returning()
|
||||
)[0];
|
||||
|
||||
const finalUser = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, newUser.id),
|
||||
});
|
||||
const finalUser = await User.fromId(newUser.id);
|
||||
|
||||
if (!finalUser) return null;
|
||||
|
||||
|
|
@ -623,11 +447,8 @@ export const createNewLocalUser = async (data: {
|
|||
*/
|
||||
export const parseMentionsUris = async (
|
||||
mentions: string[],
|
||||
): Promise<UserWithRelations[]> => {
|
||||
return await findManyUsers({
|
||||
where: (user, { inArray }) => inArray(user.uri, mentions),
|
||||
with: userRelations,
|
||||
});
|
||||
): Promise<User[]> => {
|
||||
return await User.manyFromSql(inArray(Users.uri, mentions));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -637,16 +458,14 @@ export const parseMentionsUris = async (
|
|||
*/
|
||||
export const retrieveUserFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<UserWithRelations | null> => {
|
||||
): Promise<User | null> => {
|
||||
if (!access_token) return null;
|
||||
|
||||
const token = await retrieveToken(access_token);
|
||||
|
||||
if (!token || !token.userId) return null;
|
||||
|
||||
const user = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, token.userId ?? ""),
|
||||
});
|
||||
const user = await User.fromId(token.userId);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
@ -654,7 +473,7 @@ export const retrieveUserFromToken = async (
|
|||
export const retrieveUserAndApplicationFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<{
|
||||
user: UserWithRelations | null;
|
||||
user: User | null;
|
||||
application: Application | null;
|
||||
}> => {
|
||||
if (!access_token) return { user: null, application: null };
|
||||
|
|
@ -673,9 +492,7 @@ export const retrieveUserAndApplicationFromToken = async (
|
|||
|
||||
if (!output?.token.userId) return { user: null, application: null };
|
||||
|
||||
const user = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.id, output.token.userId ?? ""),
|
||||
});
|
||||
const user = await User.fromId(output.token.userId);
|
||||
|
||||
return { user, application: output.application ?? null };
|
||||
};
|
||||
|
|
@ -698,7 +515,7 @@ export const retrieveToken = async (
|
|||
* @returns The relationship to the other user.
|
||||
*/
|
||||
export const getRelationshipToOtherUser = async (
|
||||
user: UserWithRelations,
|
||||
user: User,
|
||||
other: User,
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const foundRelationship = await db.query.Relationships.findFirst({
|
||||
|
|
@ -745,169 +562,19 @@ export const generateUserKeys = async () => {
|
|||
};
|
||||
};
|
||||
|
||||
export const userToMention = (user: UserWithInstance): APIMention => ({
|
||||
url: getUserUri(user),
|
||||
username: user.username,
|
||||
acct:
|
||||
user.instance === null
|
||||
? user.username
|
||||
: `${user.username}@${user.instance.baseUrl}`,
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
export const userToAPI = (
|
||||
userToConvert: UserWithRelations,
|
||||
isOwnAccount = false,
|
||||
): APIAccount => {
|
||||
return {
|
||||
id: userToConvert.id,
|
||||
username: userToConvert.username,
|
||||
display_name: userToConvert.displayName,
|
||||
note: userToConvert.note,
|
||||
url:
|
||||
userToConvert.uri ||
|
||||
new URL(
|
||||
`/@${userToConvert.username}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
avatar: getAvatarUrl(userToConvert, config),
|
||||
header: getHeaderUrl(userToConvert, config),
|
||||
locked: userToConvert.isLocked,
|
||||
created_at: new Date(userToConvert.createdAt).toISOString(),
|
||||
followers_count: userToConvert.followerCount,
|
||||
following_count: userToConvert.followingCount,
|
||||
statuses_count: userToConvert.statusCount,
|
||||
emojis: userToConvert.emojis.map((emoji) => emojiToAPI(emoji)),
|
||||
// TODO: Add fields
|
||||
fields: [],
|
||||
bot: userToConvert.isBot,
|
||||
source:
|
||||
isOwnAccount && userToConvert.source
|
||||
? (userToConvert.source as APISource)
|
||||
: undefined,
|
||||
// TODO: Add static avatar and header
|
||||
avatar_static: getAvatarUrl(userToConvert, config),
|
||||
header_static: getHeaderUrl(userToConvert, config),
|
||||
acct:
|
||||
userToConvert.instance === null
|
||||
? userToConvert.username
|
||||
: `${userToConvert.username}@${userToConvert.instance.baseUrl}`,
|
||||
// TODO: Add these fields
|
||||
limited: false,
|
||||
moved: null,
|
||||
noindex: false,
|
||||
suspended: false,
|
||||
discoverable: undefined,
|
||||
mute_expires_at: undefined,
|
||||
group: false,
|
||||
// @ts-expect-error Pleroma extension
|
||||
pleroma: {
|
||||
is_admin: userToConvert.isAdmin,
|
||||
is_moderator: userToConvert.isAdmin,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Should only return local users
|
||||
*/
|
||||
export const userToLysand = (user: UserWithRelations): Lysand.User => {
|
||||
if (user.instanceId !== null) {
|
||||
throw new Error("Cannot convert remote user to Lysand format");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
type: "User",
|
||||
uri: getUserUri(user),
|
||||
bio: {
|
||||
"text/html": {
|
||||
content: user.note,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(user.note),
|
||||
},
|
||||
},
|
||||
created_at: new Date(user.createdAt).toISOString(),
|
||||
dislikes: new URL(
|
||||
`/users/${user.id}/dislikes`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
featured: new URL(
|
||||
`/users/${user.id}/featured`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
likes: new URL(
|
||||
`/users/${user.id}/likes`,
|
||||
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(),
|
||||
indexable: false,
|
||||
username: user.username,
|
||||
avatar: urlToContentFormat(getAvatarUrl(user, config)) ?? undefined,
|
||||
header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined,
|
||||
display_name: user.displayName,
|
||||
fields: (user.source as APISource).fields.map((field) => ({
|
||||
key: {
|
||||
"text/html": {
|
||||
content: field.name,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(field.name),
|
||||
},
|
||||
},
|
||||
value: {
|
||||
"text/html": {
|
||||
content: field.value,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(field.value),
|
||||
},
|
||||
},
|
||||
})),
|
||||
public_key: {
|
||||
actor: new URL(
|
||||
`/users/${user.id}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
public_key: user.publicKey,
|
||||
},
|
||||
extensions: {
|
||||
"org.lysand:custom_emojis": {
|
||||
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const followRequestToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): Lysand.Follow => {
|
||||
if (follower.instanceId) {
|
||||
if (follower.isRemote()) {
|
||||
throw new Error("Follower must be a local user");
|
||||
}
|
||||
|
||||
if (!followee.instanceId) {
|
||||
if (!followee.isRemote()) {
|
||||
throw new Error("Followee must be a remote user");
|
||||
}
|
||||
|
||||
if (!followee.uri) {
|
||||
if (!followee.getUser().uri) {
|
||||
throw new Error("Followee must have a URI in database");
|
||||
}
|
||||
|
||||
|
|
@ -916,8 +583,8 @@ export const followRequestToLysand = (
|
|||
return {
|
||||
type: "Follow",
|
||||
id: id,
|
||||
author: getUserUri(follower),
|
||||
followee: followee.uri,
|
||||
author: follower.getUri(),
|
||||
followee: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
|
|
@ -927,15 +594,15 @@ export const followAcceptToLysand = (
|
|||
follower: User,
|
||||
followee: User,
|
||||
): Lysand.FollowAccept => {
|
||||
if (!follower.instanceId) {
|
||||
if (!follower.isRemote()) {
|
||||
throw new Error("Follower must be a remote user");
|
||||
}
|
||||
|
||||
if (followee.instanceId) {
|
||||
if (followee.isRemote()) {
|
||||
throw new Error("Followee must be a local user");
|
||||
}
|
||||
|
||||
if (!follower.uri) {
|
||||
if (!follower.getUser().uri) {
|
||||
throw new Error("Follower must have a URI in database");
|
||||
}
|
||||
|
||||
|
|
@ -944,9 +611,9 @@ export const followAcceptToLysand = (
|
|||
return {
|
||||
type: "FollowAccept",
|
||||
id: id,
|
||||
author: getUserUri(followee),
|
||||
author: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
follower: follower.uri,
|
||||
follower: follower.getUri(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue