diff --git a/database/entities/Status.ts b/database/entities/Status.ts index edcad4c8..08235e24 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -28,14 +28,17 @@ 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 { type Attachments, Instances, Notes, Users } from "~/drizzle/schema"; -import { Note } from "~/packages/database-interface/note"; +import { + type Attachments, + Instances, + type Notes, + Users, +} from "~/drizzle/schema"; +import type { 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 } from "./Attachment"; -import { type EmojiWithInstance, fetchEmoji } from "./Emoji"; +import type { EmojiWithInstance } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; import { type UserWithInstance, @@ -249,121 +252,6 @@ export const findManyNotes = async ( })); }; -export const resolveNote = async ( - uri?: string, - providedNote?: typeof EntityValidator.$Note, -): Promise => { - 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 = 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 typeof EntityValidator.$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 User.resolve(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) => User.resolve(mention)) - .filter((mention) => mention !== null) as Promise[], - ), - 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; -}; - /** * Get people mentioned in the content (match @username or @username@domain.com mentions) * @param text The text to parse mentions from. diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index db6eda98..e318d37d 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -1,3 +1,5 @@ +import { idValidator } from "@/api"; +import { dualLogger } from "@/loggers"; import { proxyUrl } from "@/response"; import { sanitizedHtmlStrip } from "@/sanitization"; import type { EntityValidator } from "@lysand-org/federation"; @@ -13,12 +15,14 @@ import { sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; +import { LogLevel } from "log-manager"; import { createRegExp, exactly, global } from "magic-regexp"; import { type Application, applicationToAPI, } from "~/database/entities/Application"; import { + attachmentFromLysand, attachmentToAPI, attachmentToLysand, } from "~/database/entities/Attachment"; @@ -26,6 +30,7 @@ import { type EmojiWithInstance, emojiToAPI, emojiToLysand, + fetchEmoji, parseEmojis, } from "~/database/entities/Emoji"; import { localObjectURI } from "~/database/entities/Federation"; @@ -208,6 +213,26 @@ export class Note { return (await db.insert(Notes).values(values).returning())[0]; } + async isRemote() { + return this.getAuthor().isRemote(); + } + + async updateFromRemote() { + if (!this.isRemote()) { + throw new Error("Cannot refetch a local note (it is not remote)"); + } + + const updated = await Note.saveFromRemote(this.getURI()); + + if (!updated) { + throw new Error("Note not found after update"); + } + + this.status = updated.getStatus(); + + return this; + } + static async fromData( author: User, content: typeof EntityValidator.$ContentFormat, @@ -311,6 +336,9 @@ export class Note { mentions: User[] = [], /** List of IDs of database Attachment objects */ media_attachments: string[] = [], + replyId?: string, + quoteId?: string, + application?: Application, ) { const htmlContent = content ? await contentToHtml(content, mentions) @@ -340,6 +368,9 @@ export class Note { visibility, sensitive: is_sensitive, spoilerText: spoiler_text, + replyId, + quotingId: quoteId, + applicationId: application?.id, }); // Connect emojis @@ -393,6 +424,178 @@ export class Note { return await Note.fromId(newNote.id, newNote.authorId); } + static async resolve( + uri?: string, + providedNote?: typeof EntityValidator.$Note, + ): Promise { + // Check if note not already in database + const foundNote = uri && (await Note.fromSql(eq(Notes.uri, uri))); + + if (foundNote) return foundNote; + + // Check if URI is of a local note + if (uri?.startsWith(config.http.base_url)) { + const uuid = uri.match(idValidator); + + if (!uuid || !uuid[0]) { + throw new Error( + `URI ${uri} is of a local note, but it could not be parsed`, + ); + } + + return await Note.fromId(uuid[0]); + } + + return await Note.saveFromRemote(uri, providedNote); + } + + static async saveFromRemote( + uri?: string, + providedNote?: typeof EntityValidator.$Note, + ): Promise { + if (!uri && !providedNote) { + 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) { + 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 typeof EntityValidator.$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 User.resolve(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); + } + } + + 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))?.getStatus().id + : undefined, + note.quotes + ? (await Note.resolve(note.quotes))?.getStatus().id + : undefined, + ); + } + + 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) => User.resolve(mention)) + .filter((mention) => mention !== null) as Promise[], + ), + attachments.map((a) => a.id), + note.replies_to + ? (await Note.resolve(note.replies_to))?.getStatus().id + : undefined, + note.quotes + ? (await Note.resolve(note.quotes))?.getStatus().id + : undefined, + ); + + if (!createdNote) { + throw new Error("Failed to create status"); + } + + return createdNote; + } + async delete() { return ( await db diff --git a/packages/lysand-api b/packages/lysand-api index 3ed54a7c..69be6967 160000 --- a/packages/lysand-api +++ b/packages/lysand-api @@ -1 +1 @@ -Subproject commit 3ed54a7c21e69b79b410bac687f30f47d75e5397 +Subproject commit 69be6967bd0f0018ac5951238639f49f7c93206e diff --git a/server/api/users/:uuid/inbox/index.ts b/server/api/users/:uuid/inbox/index.ts index 11ee4e1e..476391f2 100644 --- a/server/api/users/:uuid/inbox/index.ts +++ b/server/api/users/:uuid/inbox/index.ts @@ -8,7 +8,6 @@ import type { Hono } from "hono"; import { matches } from "ip-matching"; import { z } from "zod"; import { type ValidationError, isValidationError } from "zod-validation-error"; -import { resolveNote } from "~/database/entities/Status"; import { getRelationshipToOtherUser, sendFollowAccept, @@ -204,7 +203,7 @@ export default (app: Hono) => return errorResponse("Author not found", 404); } - const newStatus = await resolveNote( + const newStatus = await Note.resolve( undefined, note, ).catch((e) => { @@ -366,6 +365,24 @@ export default (app: Hono) => return response("User refreshed", 200); }, + patch: async (patch) => { + // Update the specified note in the database, if it exists and belongs to the user + const toPatch = patch.patched_id; + + const note = await Note.fromSql( + eq(Notes.uri, toPatch), + eq(Notes.authorId, user.id), + ); + + // Refetch note + if (!note) { + return errorResponse("Note not found", 404); + } + + await note.updateFromRemote(); + + return response("Note updated", 200); + }, }); if (result) {