refactor(api): 🎨 Refactor complex functions into smaller ones

This commit is contained in:
Jesse Wierzbinski 2024-06-12 18:16:59 -10:00
parent a1e02d0d78
commit c61f519a34
No known key found for this signature in database
5 changed files with 277 additions and 412 deletions

View file

@ -2,7 +2,7 @@ import { idValidator } from "@/api";
import { dualLogger } from "@/loggers";
import { proxyUrl } from "@/response";
import { sanitizedHtmlStrip } from "@/sanitization";
import type { EntityValidator } from "@lysand-org/federation";
import { EntityValidator } from "@lysand-org/federation";
import {
type InferInsertModel,
type SQL,
@ -33,6 +33,7 @@ import {
type StatusWithRelations,
contentToHtml,
findManyNotes,
parseTextMentions,
} from "~/database/entities/status";
import { db } from "~/drizzle/db";
import {
@ -249,90 +250,79 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return this;
}
static async fromData(
author: User,
content: typeof EntityValidator.$ContentFormat,
visibility: apiStatus["visibility"],
isSensitive: boolean,
spoilerText: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: User[],
static async fromData(data: {
author: User;
content: typeof EntityValidator.$ContentFormat;
visibility: apiStatus["visibility"];
isSensitive: boolean;
spoilerText: string;
emojis?: EmojiWithInstance[];
uri?: string;
mentions?: User[];
/** List of IDs of database Attachment objects */
mediaAttachments?: string[],
replyId?: string,
quoteId?: string,
application?: Application,
): Promise<Note | null> {
const htmlContent = await contentToHtml(content, mentions);
mediaAttachments?: string[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent =
data.content["text/plain"]?.content ??
Object.entries(data.content)[0][1].content;
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
const parsedMentions = [
...(data.mentions ?? []),
...(await parseTextMentions(plaintextContent)),
// Deduplicate by .id
].filter(
(mention, index, self) =>
index === self.findIndex((t) => t.id === mention.id),
);
if (author.isLocal()) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
const parsedEmojis = [
...(data.emojis ?? []),
...(await parseEmojis(plaintextContent)),
// Deduplicate by .id
].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
}
const htmlContent = await contentToHtml(data.content, parsedMentions);
const newNote = await Note.insert({
authorId: author.id,
authorId: data.author.id,
content: htmlContent,
contentSource:
content["text/plain"]?.content ||
content["text/markdown"]?.content ||
Object.entries(content)[0][1].content ||
data.content["text/plain"]?.content ||
data.content["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content ||
"",
contentType: "text/html",
visibility,
sensitive: isSensitive,
spoilerText: await sanitizedHtmlStrip(spoilerText),
uri: uri || null,
replyId: replyId ?? null,
quotingId: quoteId ?? null,
applicationId: application?.id ?? null,
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: await sanitizedHtmlStrip(data.spoilerText),
uri: data.uri || null,
replyId: data.replyId ?? null,
quotingId: data.quoteId ?? null,
applicationId: data.application?.id ?? null,
});
// Connect emojis
for (const emoji of foundEmojis) {
await db
.insert(EmojiToNote)
.values({
emojiId: emoji.id,
noteId: newNote.id,
})
.execute();
}
await newNote.recalculateDatabaseEmojis(parsedEmojis);
// Connect mentions
for (const mention of mentions ?? []) {
await db
.insert(NoteToMentions)
.values({
noteId: newNote.id,
userId: mention.id,
})
.execute();
}
await newNote.recalculateDatabaseMentions(parsedMentions);
// Set attachment parents
if (mediaAttachments && mediaAttachments.length > 0) {
await db
.update(Attachments)
.set({
noteId: newNote.id,
})
.where(inArray(Attachments.id, mediaAttachments));
}
await newNote.recalculateDatabaseAttachments(
data.mediaAttachments ?? [],
);
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
for (const mention of parsedMentions ?? []) {
if (mention.isLocal()) {
await db.insert(Notifications).values({
accountId: author.id,
accountId: data.author.id,
notifiedId: mention.id,
type: "mention",
noteId: newNote.id,
@ -340,61 +330,101 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
}
return await Note.fromId(newNote.id, newNote.data.authorId);
await newNote.reload();
return newNote;
}
async updateFromData(
content?: typeof EntityValidator.$ContentFormat,
visibility?: apiStatus["visibility"],
isSensitive?: boolean,
spoilerText?: string,
emojis: EmojiWithInstance[] = [],
mentions: User[] = [],
async updateFromData(data: {
author?: User;
content?: typeof EntityValidator.$ContentFormat;
visibility?: apiStatus["visibility"];
isSensitive?: boolean;
spoilerText?: string;
emojis?: EmojiWithInstance[];
uri?: string;
mentions?: User[];
/** List of IDs of database Attachment objects */
mediaAttachments: string[] = [],
replyId?: string,
quoteId?: string,
application?: Application,
) {
const htmlContent = content
? await contentToHtml(content, mentions)
mediaAttachments?: string[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent = data.content
? data.content["text/plain"]?.content ??
Object.entries(data.content)[0][1].content
: undefined;
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
const parsedMentions = [
...(data.mentions ?? []),
...(plaintextContent
? await parseTextMentions(plaintextContent)
: []),
// Deduplicate by .id
].filter(
(mention, index, self) =>
index === self.findIndex((t) => t.id === mention.id),
);
if (this.author.isLocal() && htmlContent) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
const parsedEmojis = [
...(data.emojis ?? []),
...(plaintextContent ? await parseEmojis(plaintextContent) : []),
// Deduplicate by .id
].filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
}
const newNote = await this.update({
const htmlContent = data.content
? await contentToHtml(data.content, parsedMentions)
: undefined;
await this.update({
content: htmlContent,
contentSource: content
? content["text/plain"]?.content ||
content["text/markdown"]?.content ||
Object.entries(content)[0][1].content ||
contentSource: data.content
? data.content["text/plain"]?.content ||
data.content["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content ||
""
: undefined,
contentType: "text/html",
visibility,
sensitive: isSensitive,
spoilerText: spoilerText,
replyId,
quotingId: quoteId,
applicationId: application?.id,
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: data.spoilerText,
replyId: data.replyId,
quotingId: data.quoteId,
applicationId: data.application?.id,
});
// Connect emojis
await this.recalculateDatabaseEmojis(parsedEmojis);
// Connect mentions
await this.recalculateDatabaseMentions(parsedMentions);
// Set attachment parents
await this.recalculateDatabaseAttachments(data.mediaAttachments ?? []);
await this.reload();
return this;
}
public async recalculateDatabaseEmojis(
emojis: EmojiWithInstance[],
): Promise<void> {
// Fuse and deduplicate
const fusedEmojis = emojis.filter(
(emoji, index, self) =>
index === self.findIndex((t) => t.id === emoji.id),
);
// Connect emojis
await db
.delete(EmojiToNote)
.where(eq(EmojiToNote.noteId, this.data.id));
for (const emoji of foundEmojis) {
for (const emoji of fusedEmojis) {
await db
.insert(EmojiToNote)
.values({
@ -403,13 +433,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
})
.execute();
}
}
public async recalculateDatabaseMentions(mentions: User[]): Promise<void> {
// Connect mentions
await db
.delete(NoteToMentions)
.where(eq(NoteToMentions.noteId, this.data.id));
for (const mention of mentions ?? []) {
for (const mention of mentions) {
await db
.insert(NoteToMentions)
.values({
@ -418,9 +450,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
})
.execute();
}
}
public async recalculateDatabaseAttachments(
mediaAttachments: string[],
): Promise<void> {
// Set attachment parents
if (mediaAttachments) {
await db
.update(Attachments)
.set({
@ -438,9 +473,6 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
}
return await Note.fromId(newNote.id, newNote.authorId);
}
static async resolve(
uri?: string,
providedNote?: typeof EntityValidator.$Note,
@ -476,10 +508,6 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
throw new Error("No URI or note provided");
}
const foundStatus = await Note.fromSql(
eq(Notes.uri, uri ?? providedNote?.uri ?? ""),
);
let note = providedNote || null;
if (uri) {
@ -494,27 +522,44 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
},
});
note = (await response.json()) as typeof EntityValidator.$Note;
note = await new EntityValidator().Note(await response.json());
}
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 User.resolve(note.author);
if (!author) {
throw new Error("Invalid object author");
}
return await Note.fromLysand(note, author);
}
static async fromLysand(
note: typeof EntityValidator.$Note,
author: User,
): Promise<Note> {
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 attachments = [];
for (const attachment of note.attachments ?? []) {
@ -534,85 +579,45 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
}
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);
}
}
if (foundStatus) {
return await foundStatus.updateFromData(
note.content ?? {
"text/plain": {
content: "",
},
},
note.visibility as apiStatus["visibility"],
note.is_sensitive ?? false,
note.subject ?? "",
emojis,
note.mentions
? await Promise.all(
(note.mentions ?? [])
.map((mention) => User.resolve(mention))
.filter(
(mention) => mention !== null,
) as Promise<User>[],
)
: [],
attachments.map((a) => a.id),
note.replies_to
? (await Note.resolve(note.replies_to))?.data.id
: undefined,
note.quotes
? (await Note.resolve(note.quotes))?.data.id
: undefined,
);
}
const createdNote = await Note.fromData(
const newData = {
author,
note.content ?? {
content: note.content ?? {
"text/plain": {
content: "",
},
},
note.visibility as apiStatus["visibility"],
note.is_sensitive ?? false,
note.subject ?? "",
visibility: note.visibility as apiStatus["visibility"],
isSensitive: note.is_sensitive ?? false,
spoilerText: note.subject ?? "",
emojis,
note.uri,
await Promise.all(
uri: note.uri,
mentions: await Promise.all(
(note.mentions ?? [])
.map((mention) => User.resolve(mention))
.filter((mention) => mention !== null) as Promise<User>[],
),
attachments.map((a) => a.id),
note.replies_to
mediaAttachments: attachments.map((a) => a.id),
replyId: note.replies_to
? (await Note.resolve(note.replies_to))?.data.id
: undefined,
note.quotes
quoteId: note.quotes
? (await Note.resolve(note.quotes))?.data.id
: undefined,
);
};
if (!createdNote) {
throw new Error("Failed to create status");
// Check if new note already exists
const foundNote = await Note.fromSql(eq(Notes.uri, note.uri));
// If it exists, simply update it
if (foundNote) {
await foundNote.updateFromData(newData);
return foundNote;
}
return createdNote;
// Else, create a new note
return await Note.fromData(newData);
}
async delete(ids: string[]): Promise<void>;

View file

@ -1,4 +1,4 @@
import { applyConfig, auth, handleZodError, qs } from "@/api";
import { applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
import { errorResponse, jsonResponse } from "@/response";
import { sanitizedHtmlStrip } from "@/sanitization";
import { zValidator } from "@hono/zod-validator";
@ -99,7 +99,7 @@ export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
qs(),
jsonOrForm(),
zValidator("form", schemas.form, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {

View file

@ -159,21 +159,18 @@ export default (app: Hono) =>
}
}
const newNote = await foundStatus.updateFromData(
statusText
const newNote = await foundStatus.updateFromData({
content: statusText
? {
[content_type]: {
content: statusText,
},
}
: undefined,
undefined,
sensitive,
spoiler_text,
undefined,
undefined,
media_ids,
);
isSensitive: sensitive,
spoilerText: spoiler_text,
mediaAttachments: media_ids,
});
if (!newNote) {
return errorResponse("Failed to update status", 500);

View file

@ -5,7 +5,7 @@ import { config } from "config-manager";
import type { Hono } from "hono";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { federateNote, parseTextMentions } from "~/database/entities/status";
import { federateNote } from "~/database/entities/status";
import { db } from "~/drizzle/db";
import { RolePermissions } from "~/drizzle/schema";
import { Note } from "~/packages/database-interface/note";
@ -175,26 +175,21 @@ export default (app: Hono) =>
}
}
const mentions = await parseTextMentions(status ?? "");
const newNote = await Note.fromData(
user,
{
const newNote = await Note.fromData({
author: user,
content: {
[content_type]: {
content: status ?? "",
},
},
visibility,
sensitive ?? false,
spoiler_text ?? "",
[],
undefined,
mentions,
media_ids,
in_reply_to_id ?? undefined,
quote_id ?? undefined,
application ?? undefined,
);
isSensitive: sensitive ?? false,
spoilerText: spoiler_text ?? "",
mediaAttachments: media_ids,
replyId: in_reply_to_id ?? undefined,
quoteId: quote_id ?? undefined,
application: application ?? undefined,
});
if (!newNote) {
return errorResponse("Failed to create status", 500);

View file

@ -3,7 +3,6 @@ import chalk from "chalk";
import { config } from "config-manager";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { BodyData } from "hono/utils/body";
import { validator } from "hono/validator";
import {
anyOf,
@ -198,92 +197,8 @@ export const auth = (
return checkRouteNeedsAuth(auth, authData, context);
});
/* export const auth = (
authData: ApiRouteMetadata["auth"],
permissionData?: ApiRouteMetadata["permissions"],
) =>
validator("header", async (value, context) => {
const auth = value.authorization
? await getFromHeader(value.authorization)
: null;
const error = errorResponse("Unauthorized", 401);
// Permissions check
if (permissionData) {
const userPerms = auth?.user
? auth.user.getAllPermissions()
: config.permissions.anonymous;
const requiredPerms =
permissionData.methodOverrides?.[
context.req.method as HttpVerb
] ?? permissionData.required;
if (!requiredPerms.every((perm) => userPerms.includes(perm))) {
const missingPerms = requiredPerms.filter(
(perm) => !userPerms.includes(perm),
);
return context.json(
{
error: `You do not have the required permissions to access this route. Missing: ${missingPerms.join(
", ",
)}`,
},
403,
error.headers.toJSON(),
);
}
}
if (auth?.user) {
return {
user: auth.user as User,
token: auth.token as string,
application: auth.application as Application | null,
};
}
if (authData.required) {
return context.json(
{
error: "Unauthorized",
},
401,
error.headers.toJSON(),
);
}
if (
authData.requiredOnMethods?.includes(context.req.method as HttpVerb)
) {
return context.json(
{
error: "Unauthorized",
},
401,
error.headers.toJSON(),
);
}
return {
user: null,
token: null,
application: null,
};
}); */
/**
* Middleware to magically unfuck forms
* Add it to random Hono routes and hope it works
* @returns
*/
export const qs = () => {
return createMiddleware(async (context, next) => {
const contentType = context.req.header("content-type");
if (contentType?.includes("multipart/form-data")) {
// Get it as a query format to pass on to qs, then insert back files
// Helper function to parse form data
async function parseFormData(context: Context) {
const formData = await context.req.formData();
const urlparams = new URLSearchParams();
const files = new Map<string, File>();
@ -306,40 +221,21 @@ export const qs = () => {
interpretNumericEntities: true,
});
// @ts-ignore Very bad hack
context.req.parseBody = <T extends BodyData = BodyData>() =>
Promise.resolve({
...parsed,
...Object.fromEntries(files),
} as T);
context.req.formData = () =>
// @ts-ignore I'm so sorry for this
Promise.resolve({
...parsed,
...Object.fromEntries(files),
});
// @ts-ignore I'm so sorry for this
context.req.bodyCache.formData = {
...parsed,
...Object.fromEntries(files),
return {
parsed,
files,
};
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
}
// Helper function to parse urlencoded data
async function parseUrlEncoded(context: Context) {
const parsed = parse(await context.req.text(), {
parseArrays: true,
interpretNumericEntities: true,
});
context.req.parseBody = <T extends BodyData = BodyData>() =>
Promise.resolve(parsed as T);
// @ts-ignore Very bad hack
context.req.formData = () => Promise.resolve(parsed);
// @ts-ignore I'm so sorry for this
context.req.bodyCache.formData = parsed;
}
await next();
});
};
return parsed;
}
export const qsQuery = () => {
return createMiddleware(async (context, next) => {
@ -350,77 +246,49 @@ export const qsQuery = () => {
// @ts-ignore Very bad hack
context.req.query = () => parsed;
// @ts-ignore I'm so sorry for this
context.req.queries = () => parsed;
await next();
});
};
// Fill in queries, formData and json
export const setContextFormDataToObject = (
context: Context,
setTo: object,
): Context => {
// @ts-expect-error HACK
context.req.bodyCache.formData = setTo;
context.req.parseBody = async () =>
context.req.bodyCache.formData as FormData;
context.req.formData = async () =>
context.req.bodyCache.formData as FormData;
return context;
};
/*
* Middleware to magically unfuck forms
* Add it to random Hono routes and hope it works
* @returns
*/
export const jsonOrForm = () => {
return createMiddleware(async (context, next) => {
const contentType = context.req.header("content-type");
if (contentType?.includes("application/json")) {
context.req.parseBody = async <T extends BodyData = BodyData>() =>
(await context.req.json()) as T;
context.req.bodyCache.formData = await context.req.json();
context.req.formData = async () =>
context.req.bodyCache.formData as FormData;
setContextFormDataToObject(context, await context.req.json());
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
const parsed = parse(await context.req.text(), {
parseArrays: true,
interpretNumericEntities: true,
});
const parsed = await parseUrlEncoded(context);
context.req.parseBody = <T extends BodyData = BodyData>() =>
Promise.resolve(parsed as T);
// @ts-ignore Very bad hack
context.req.formData = () => Promise.resolve(parsed);
// @ts-ignore I'm so sorry for this
context.req.bodyCache.formData = parsed;
setContextFormDataToObject(context, parsed);
} else if (contentType?.includes("multipart/form-data")) {
// Get it as a query format to pass on to qs, then insert back files
const formData = await context.req.formData();
const urlparams = new URLSearchParams();
const files = new Map<string, File>();
for (const [key, value] of [...formData.entries()]) {
if (Array.isArray(value)) {
for (const val of value) {
urlparams.append(key, val);
}
} else if (value instanceof File) {
if (!files.has(key)) {
files.set(key, value);
}
} else {
urlparams.append(key, String(value));
}
}
const { parsed, files } = await parseFormData(context);
const parsed = parse(urlparams.toString(), {
parseArrays: true,
interpretNumericEntities: true,
});
// @ts-ignore Very bad hack
context.req.parseBody = <T extends BodyData = BodyData>() =>
Promise.resolve({
...parsed,
...Object.fromEntries(files),
} as T);
context.req.formData = () =>
// @ts-ignore I'm so sorry for this
Promise.resolve({
setContextFormDataToObject(context, {
...parsed,
...Object.fromEntries(files),
});
// @ts-ignore I'm so sorry for this
context.req.bodyCache.formData = {
...parsed,
...Object.fromEntries(files),
};
}
await next();