diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 6216648e..9afe2216 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -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 { 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 { - const htmlContent = await contentToHtml(content, mentions); + mediaAttachments?: string[]; + replyId?: string; + quoteId?: string; + application?: Application; + }): Promise { + 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( - (emoji, index, self) => - index === self.findIndex((t) => t.id === emoji.id), - ); - } + 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 { } } - 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 { + 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( - (emoji, index, self) => - index === self.findIndex((t) => t.id === emoji.id), - ); - } + 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 { + // 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 { }) .execute(); } + } + public async recalculateDatabaseMentions(mentions: User[]): Promise { // 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,27 +450,27 @@ export class Note extends BaseInterface { }) .execute(); } + } + public async recalculateDatabaseAttachments( + mediaAttachments: string[], + ): Promise { // Set attachment parents - if (mediaAttachments) { + await db + .update(Attachments) + .set({ + noteId: null, + }) + .where(eq(Attachments.noteId, this.data.id)); + + if (mediaAttachments.length > 0) { await db .update(Attachments) .set({ - noteId: null, + noteId: this.data.id, }) - .where(eq(Attachments.noteId, this.data.id)); - - if (mediaAttachments.length > 0) { - await db - .update(Attachments) - .set({ - noteId: this.data.id, - }) - .where(inArray(Attachments.id, mediaAttachments)); - } + .where(inArray(Attachments.id, mediaAttachments)); } - - return await Note.fromId(newNote.id, newNote.authorId); } static async resolve( @@ -476,10 +508,6 @@ export class Note extends BaseInterface { 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 { }, }); - 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 { + 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 { } } - 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[], - ) - : [], - 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[], ), - 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; diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index d6d69170..06ed7587 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -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) => { diff --git a/server/api/api/v1/statuses/:id/index.ts b/server/api/api/v1/statuses/:id/index.ts index 8dae0f7d..b7150d59 100644 --- a/server/api/api/v1/statuses/:id/index.ts +++ b/server/api/api/v1/statuses/:id/index.ts @@ -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); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 63aefd2a..9ece9644 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -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); diff --git a/utils/api.ts b/utils/api.ts index 5a5ee83b..2315fb80 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -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,148 +197,45 @@ 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(), - ); +// 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(); + for (const [key, value] of [...formData.entries()]) { + if (Array.isArray(value)) { + for (const val of value) { + urlparams.append(key, val); } - } - - 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 - const formData = await context.req.formData(); - const urlparams = new URLSearchParams(); - const files = new Map(); - 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)); - } + } else if (value instanceof File) { + if (!files.has(key)) { + files.set(key, value); } - - const parsed = parse(urlparams.toString(), { - parseArrays: true, - interpretNumericEntities: true, - }); - - // @ts-ignore Very bad hack - context.req.parseBody = () => - 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), - }; - } else if (contentType?.includes("application/x-www-form-urlencoded")) { - const parsed = parse(await context.req.text(), { - parseArrays: true, - interpretNumericEntities: true, - }); - - context.req.parseBody = () => - 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; + } else { + urlparams.append(key, String(value)); } - await next(); + } + + const parsed = parse(urlparams.toString(), { + parseArrays: true, + interpretNumericEntities: true, }); -}; + + return { + parsed, + files, + }; +} + +// Helper function to parse urlencoded data +async function parseUrlEncoded(context: Context) { + const parsed = parse(await context.req.text(), { + parseArrays: true, + interpretNumericEntities: true, + }); + + 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 () => - (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 = () => - 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(); - 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 = () => - 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 = { + setContextFormDataToObject(context, { ...parsed, ...Object.fromEntries(files), - }; + }); } await next();