mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
778 lines
22 KiB
TypeScript
778 lines
22 KiB
TypeScript
import { dualLogger } from "@loggers";
|
|
import { sanitizeHtml } from "@sanitization";
|
|
import { config } from "config-manager";
|
|
import {
|
|
type InferSelectModel,
|
|
and,
|
|
eq,
|
|
inArray,
|
|
isNull,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import { htmlToText } from "html-to-text";
|
|
import linkifyHtml from "linkify-html";
|
|
import type * as Lysand from "lysand-types";
|
|
import {
|
|
anyOf,
|
|
charIn,
|
|
createRegExp,
|
|
digit,
|
|
exactly,
|
|
global,
|
|
letter,
|
|
maybe,
|
|
oneOrMore,
|
|
} from "magic-regexp";
|
|
import { parse } from "marked";
|
|
import { db } from "~drizzle/db";
|
|
import {
|
|
Attachments,
|
|
EmojiToNote,
|
|
Instances,
|
|
NoteToMentions,
|
|
Notes,
|
|
Notifications,
|
|
Users,
|
|
} from "~drizzle/schema";
|
|
import { Note } from "~packages/database-interface/note";
|
|
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 { objectToInboxRequest } from "./Federation";
|
|
import type { Like } from "./Like";
|
|
import {
|
|
type User,
|
|
type UserWithInstance,
|
|
type UserWithRelations,
|
|
findManyUsers,
|
|
getUserUri,
|
|
resolveUser,
|
|
resolveWebFinger,
|
|
transformOutputToUserWithRelations,
|
|
userExtrasTemplate,
|
|
userRelations,
|
|
} from "./User";
|
|
|
|
export type Status = InferSelectModel<typeof Notes>;
|
|
|
|
export type StatusWithRelations = Status & {
|
|
author: UserWithRelations;
|
|
mentions: UserWithInstance[];
|
|
attachments: InferSelectModel<typeof Attachments>[];
|
|
reblog: StatusWithoutRecursiveRelations | null;
|
|
emojis: EmojiWithInstance[];
|
|
likes: Like[];
|
|
reply: Status | null;
|
|
quote: Status | null;
|
|
application: Application | null;
|
|
reblogCount: number;
|
|
likeCount: number;
|
|
replyCount: number;
|
|
};
|
|
|
|
export type StatusWithoutRecursiveRelations = Omit<
|
|
StatusWithRelations,
|
|
"reply" | "quote" | "reblog"
|
|
>;
|
|
|
|
export const noteExtras = {
|
|
reblogCount:
|
|
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes".id)`.as(
|
|
"reblog_count",
|
|
),
|
|
likeCount:
|
|
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes".id)`.as(
|
|
"like_count",
|
|
),
|
|
replyCount:
|
|
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as(
|
|
"reply_count",
|
|
),
|
|
};
|
|
|
|
/**
|
|
* Wrapper against the Status object to make it easier to work with
|
|
* @param query
|
|
* @returns
|
|
*/
|
|
export const findManyNotes = async (
|
|
query: Parameters<typeof db.query.Notes.findMany>[0],
|
|
): Promise<StatusWithRelations[]> => {
|
|
const output = await db.query.Notes.findMany({
|
|
...query,
|
|
with: {
|
|
...query?.with,
|
|
attachments: {
|
|
where: (attachment, { eq }) =>
|
|
eq(attachment.noteId, sql`"Notes"."id"`),
|
|
},
|
|
emojis: {
|
|
with: {
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
...userRelations,
|
|
},
|
|
extras: userExtrasTemplate("Notes_author"),
|
|
},
|
|
mentions: {
|
|
with: {
|
|
user: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
reblog: {
|
|
with: {
|
|
attachments: true,
|
|
emojis: {
|
|
with: {
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
likes: true,
|
|
application: true,
|
|
mentions: {
|
|
with: {
|
|
user: {
|
|
with: userRelations,
|
|
extras: userExtrasTemplate(
|
|
"Notes_reblog_mentions_user",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
...userRelations,
|
|
},
|
|
extras: userExtrasTemplate("Notes_reblog_author"),
|
|
},
|
|
},
|
|
extras: {
|
|
...noteExtras,
|
|
},
|
|
},
|
|
reply: true,
|
|
quote: true,
|
|
},
|
|
extras: {
|
|
...noteExtras,
|
|
...query?.extras,
|
|
},
|
|
});
|
|
|
|
return output.map((post) => ({
|
|
...post,
|
|
author: transformOutputToUserWithRelations(post.author),
|
|
mentions: post.mentions.map((mention) => ({
|
|
...mention.user,
|
|
endpoints: mention.user.endpoints,
|
|
})),
|
|
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
|
reblog: post.reblog && {
|
|
...post.reblog,
|
|
author: transformOutputToUserWithRelations(post.reblog.author),
|
|
mentions: post.reblog.mentions.map((mention) => ({
|
|
...mention.user,
|
|
endpoints: mention.user.endpoints,
|
|
})),
|
|
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
|
reblogCount: Number(post.reblog.reblogCount),
|
|
likeCount: Number(post.reblog.likeCount),
|
|
replyCount: Number(post.reblog.replyCount),
|
|
},
|
|
reblogCount: Number(post.reblogCount),
|
|
likeCount: Number(post.likeCount),
|
|
replyCount: Number(post.replyCount),
|
|
}));
|
|
};
|
|
|
|
export const findFirstNote = async (
|
|
query: Parameters<typeof db.query.Notes.findFirst>[0],
|
|
): Promise<StatusWithRelations | null> => {
|
|
const output = await db.query.Notes.findFirst({
|
|
...query,
|
|
with: {
|
|
...query?.with,
|
|
attachments: {
|
|
where: (attachment, { eq }) =>
|
|
eq(attachment.noteId, sql`"Notes"."id"`),
|
|
},
|
|
emojis: {
|
|
with: {
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
...userRelations,
|
|
},
|
|
extras: userExtrasTemplate("Notes_author"),
|
|
},
|
|
mentions: {
|
|
with: {
|
|
user: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
reblog: {
|
|
with: {
|
|
attachments: true,
|
|
emojis: {
|
|
with: {
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
likes: true,
|
|
application: true,
|
|
mentions: {
|
|
with: {
|
|
user: {
|
|
with: userRelations,
|
|
extras: userExtrasTemplate(
|
|
"Notes_reblog_mentions_user",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
...userRelations,
|
|
},
|
|
extras: userExtrasTemplate("Notes_reblog_author"),
|
|
},
|
|
},
|
|
extras: {
|
|
...noteExtras,
|
|
},
|
|
},
|
|
reply: true,
|
|
quote: true,
|
|
},
|
|
extras: {
|
|
...noteExtras,
|
|
...query?.extras,
|
|
},
|
|
});
|
|
|
|
if (!output) return null;
|
|
|
|
return {
|
|
...output,
|
|
author: transformOutputToUserWithRelations(output.author),
|
|
mentions: output.mentions.map((mention) => ({
|
|
...mention.user,
|
|
endpoints: mention.user.endpoints,
|
|
})),
|
|
emojis: (output.emojis ?? []).map((emoji) => emoji.emoji),
|
|
reblog: output.reblog && {
|
|
...output.reblog,
|
|
author: transformOutputToUserWithRelations(output.reblog.author),
|
|
mentions: output.reblog.mentions.map((mention) => ({
|
|
...mention.user,
|
|
endpoints: mention.user.endpoints,
|
|
})),
|
|
emojis: (output.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
|
reblogCount: Number(output.reblog.reblogCount),
|
|
likeCount: Number(output.reblog.likeCount),
|
|
replyCount: Number(output.reblog.replyCount),
|
|
},
|
|
reblogCount: Number(output.reblogCount),
|
|
likeCount: Number(output.likeCount),
|
|
replyCount: Number(output.replyCount),
|
|
};
|
|
};
|
|
|
|
export const resolveNote = async (
|
|
uri?: string,
|
|
providedNote?: Lysand.Note,
|
|
): Promise<Note> => {
|
|
if (!uri && !providedNote) {
|
|
throw new Error("No URI or note provided");
|
|
}
|
|
|
|
const foundStatus = await Note.fromSql(
|
|
eq(Notes.uri, uri ?? providedNote?.uri ?? ""),
|
|
);
|
|
|
|
if (foundStatus) return foundStatus;
|
|
|
|
let note: Lysand.Note | null = providedNote ?? null;
|
|
|
|
if (uri) {
|
|
if (!URL.canParse(uri)) {
|
|
throw new Error(`Invalid URI to parse ${uri}`);
|
|
}
|
|
|
|
const response = await fetch(uri, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
|
|
note = (await response.json()) as Lysand.Note;
|
|
}
|
|
|
|
if (!note) {
|
|
throw new Error("No note was able to be fetched");
|
|
}
|
|
|
|
if (note.type !== "Note") {
|
|
throw new Error("Invalid object type");
|
|
}
|
|
|
|
if (!note.author) {
|
|
throw new Error("Invalid object author");
|
|
}
|
|
|
|
const author = await resolveUser(note.author);
|
|
|
|
if (!author) {
|
|
throw new Error("Invalid object author");
|
|
}
|
|
|
|
const attachments = [];
|
|
|
|
for (const attachment of note.attachments ?? []) {
|
|
const resolvedAttachment = await attachmentFromLysand(attachment).catch(
|
|
(e) => {
|
|
dualLogger.logError(
|
|
LogLevel.ERROR,
|
|
"Federation.StatusResolver",
|
|
e,
|
|
);
|
|
return null;
|
|
},
|
|
);
|
|
|
|
if (resolvedAttachment) {
|
|
attachments.push(resolvedAttachment);
|
|
}
|
|
}
|
|
|
|
const emojis = [];
|
|
|
|
for (const emoji of note.extensions?.["org.lysand:custom_emojis"]?.emojis ??
|
|
[]) {
|
|
const resolvedEmoji = await fetchEmoji(emoji).catch((e) => {
|
|
dualLogger.logError(LogLevel.ERROR, "Federation.StatusResolver", e);
|
|
return null;
|
|
});
|
|
|
|
if (resolvedEmoji) {
|
|
emojis.push(resolvedEmoji);
|
|
}
|
|
}
|
|
|
|
const createdNote = await Note.fromData(
|
|
author,
|
|
note.content ?? {
|
|
"text/plain": {
|
|
content: "",
|
|
},
|
|
},
|
|
note.visibility as APIStatus["visibility"],
|
|
note.is_sensitive ?? false,
|
|
note.subject ?? "",
|
|
emojis,
|
|
note.uri,
|
|
await Promise.all(
|
|
(note.mentions ?? [])
|
|
.map((mention) => resolveUser(mention))
|
|
.filter(
|
|
(mention) => mention !== null,
|
|
) as Promise<UserWithRelations>[],
|
|
),
|
|
attachments.map((a) => a.id),
|
|
note.replies_to
|
|
? (await resolveNote(note.replies_to)).getStatus().id
|
|
: undefined,
|
|
note.quotes
|
|
? (await resolveNote(note.quotes)).getStatus().id
|
|
: undefined,
|
|
);
|
|
|
|
if (!createdNote) {
|
|
throw new Error("Failed to create status");
|
|
}
|
|
|
|
return createdNote;
|
|
};
|
|
|
|
export const createMentionRegExp = () =>
|
|
createRegExp(
|
|
exactly("@"),
|
|
oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs(
|
|
"username",
|
|
),
|
|
maybe(
|
|
exactly("@"),
|
|
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"),
|
|
),
|
|
[global],
|
|
);
|
|
|
|
/**
|
|
* Get people mentioned in the content (match @username or @username@domain.com mentions)
|
|
* @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[]> => {
|
|
const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? [];
|
|
if (mentionedPeople.length === 0) return [];
|
|
|
|
const baseUrlHost = new URL(config.http.base_url).host;
|
|
|
|
const isLocal = (host?: string) => host === baseUrlHost || !host;
|
|
|
|
const foundUsers = await db
|
|
.select({
|
|
id: Users.id,
|
|
username: Users.username,
|
|
baseUrl: Instances.baseUrl,
|
|
})
|
|
.from(Users)
|
|
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
|
|
.where(
|
|
or(
|
|
...mentionedPeople.map((person) =>
|
|
and(
|
|
eq(Users.username, person?.[1] ?? ""),
|
|
isLocal(person?.[2])
|
|
? isNull(Users.instanceId)
|
|
: eq(Instances.baseUrl, person?.[2] ?? ""),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
const notFoundRemoteUsers = mentionedPeople.filter(
|
|
(person) =>
|
|
!isLocal(person?.[2]) &&
|
|
!foundUsers.find(
|
|
(user) =>
|
|
user.username === person?.[1] &&
|
|
user.baseUrl === person?.[2],
|
|
),
|
|
);
|
|
|
|
const finalList =
|
|
foundUsers.length > 0
|
|
? await findManyUsers({
|
|
where: (user, { inArray }) =>
|
|
inArray(
|
|
user.id,
|
|
foundUsers.map((u) => u.id),
|
|
),
|
|
})
|
|
: [];
|
|
|
|
// Attempt to resolve mentions that were not found
|
|
for (const person of notFoundRemoteUsers) {
|
|
const user = await resolveWebFinger(
|
|
person?.[1] ?? "",
|
|
person?.[2] ?? "",
|
|
);
|
|
|
|
if (user) {
|
|
finalList.push(user);
|
|
}
|
|
}
|
|
|
|
return finalList;
|
|
};
|
|
|
|
export const replaceTextMentions = async (
|
|
text: string,
|
|
mentions: UserWithRelations[],
|
|
) => {
|
|
let finalText = text;
|
|
for (const mention of mentions) {
|
|
// Replace @username and @username@domain
|
|
if (mention.instance) {
|
|
finalText = finalText.replace(
|
|
createRegExp(
|
|
exactly(`@${mention.username}@${mention.instance.baseUrl}`),
|
|
[global],
|
|
),
|
|
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
|
|
mention,
|
|
)}">@${mention.username}@${mention.instance.baseUrl}</a>`,
|
|
);
|
|
} else {
|
|
finalText = finalText.replace(
|
|
// Only replace @username if it doesn't have another @ right after
|
|
createRegExp(
|
|
exactly(`@${mention.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>`,
|
|
);
|
|
|
|
finalText = finalText.replace(
|
|
createRegExp(
|
|
exactly(
|
|
`@${mention.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>`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return finalText;
|
|
};
|
|
|
|
export const contentToHtml = async (
|
|
content: Lysand.ContentFormat,
|
|
mentions: UserWithRelations[] = [],
|
|
): Promise<string> => {
|
|
let htmlContent: string;
|
|
|
|
if (content["text/html"]) {
|
|
htmlContent = content["text/html"].content;
|
|
} else if (content["text/markdown"]) {
|
|
htmlContent = await sanitizeHtml(
|
|
await parse(content["text/markdown"].content),
|
|
);
|
|
} else if (content["text/plain"]) {
|
|
// Split by newline and add <p> tags
|
|
htmlContent = content["text/plain"].content
|
|
.split("\n")
|
|
.map((line) => `<p>${line}</p>`)
|
|
.join("\n");
|
|
} else {
|
|
htmlContent = "";
|
|
}
|
|
|
|
// Replace mentions text
|
|
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
|
|
|
|
// Linkify
|
|
htmlContent = linkifyHtml(htmlContent, {
|
|
defaultProtocol: "https",
|
|
validate: {
|
|
email: () => false,
|
|
},
|
|
target: "_blank",
|
|
rel: "nofollow noopener noreferrer",
|
|
});
|
|
|
|
return htmlContent;
|
|
};
|
|
|
|
export const federateNote = async (note: Note) => {
|
|
for (const user of await note.getUsersToFederateTo()) {
|
|
// TODO: Add queue system
|
|
const request = await objectToInboxRequest(
|
|
note.toLysand(),
|
|
note.getAuthor(),
|
|
user,
|
|
);
|
|
|
|
// Send request
|
|
const response = await fetch(request);
|
|
|
|
if (!response.ok) {
|
|
dualLogger.log(
|
|
LogLevel.DEBUG,
|
|
"Federation.Status",
|
|
await response.text(),
|
|
);
|
|
dualLogger.log(
|
|
LogLevel.ERROR,
|
|
"Federation.Status",
|
|
`Failed to federate status ${note.getStatus().id} to ${
|
|
user.uri
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
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) => {
|
|
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
|
|
},
|
|
};
|
|
};
|