feat(federation): Add support for federation of note editing

This commit is contained in:
Jesse Wierzbinski 2024-06-05 21:04:52 -10:00
parent 3e94a9d491
commit 8f09ea4c60
No known key found for this signature in database
4 changed files with 231 additions and 123 deletions

View file

@ -28,14 +28,17 @@ import markdownItAnchor from "markdown-it-anchor";
import markdownItContainer from "markdown-it-container"; import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right"; import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { type Attachments, Instances, Notes, Users } from "~/drizzle/schema"; import {
import { Note } from "~/packages/database-interface/note"; 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 { User } from "~/packages/database-interface/user";
import { LogLevel } from "~/packages/log-manager"; import { LogLevel } from "~/packages/log-manager";
import type { Status as APIStatus } from "~/types/mastodon/status";
import type { Application } from "./Application"; import type { Application } from "./Application";
import { attachmentFromLysand } from "./Attachment"; import type { EmojiWithInstance } from "./Emoji";
import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
import { objectToInboxRequest } from "./Federation"; import { objectToInboxRequest } from "./Federation";
import { import {
type UserWithInstance, type UserWithInstance,
@ -249,121 +252,6 @@ export const findManyNotes = async (
})); }));
}; };
export const resolveNote = async (
uri?: string,
providedNote?: typeof EntityValidator.$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 = 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<User>[],
),
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) * Get people mentioned in the content (match @username or @username@domain.com mentions)
* @param text The text to parse mentions from. * @param text The text to parse mentions from.

View file

@ -1,3 +1,5 @@
import { idValidator } from "@/api";
import { dualLogger } from "@/loggers";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import { sanitizedHtmlStrip } from "@/sanitization"; import { sanitizedHtmlStrip } from "@/sanitization";
import type { EntityValidator } from "@lysand-org/federation"; import type { EntityValidator } from "@lysand-org/federation";
@ -13,12 +15,14 @@ import {
sql, sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { LogLevel } from "log-manager";
import { createRegExp, exactly, global } from "magic-regexp"; import { createRegExp, exactly, global } from "magic-regexp";
import { import {
type Application, type Application,
applicationToAPI, applicationToAPI,
} from "~/database/entities/Application"; } from "~/database/entities/Application";
import { import {
attachmentFromLysand,
attachmentToAPI, attachmentToAPI,
attachmentToLysand, attachmentToLysand,
} from "~/database/entities/Attachment"; } from "~/database/entities/Attachment";
@ -26,6 +30,7 @@ import {
type EmojiWithInstance, type EmojiWithInstance,
emojiToAPI, emojiToAPI,
emojiToLysand, emojiToLysand,
fetchEmoji,
parseEmojis, parseEmojis,
} from "~/database/entities/Emoji"; } from "~/database/entities/Emoji";
import { localObjectURI } from "~/database/entities/Federation"; import { localObjectURI } from "~/database/entities/Federation";
@ -208,6 +213,26 @@ export class Note {
return (await db.insert(Notes).values(values).returning())[0]; 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( static async fromData(
author: User, author: User,
content: typeof EntityValidator.$ContentFormat, content: typeof EntityValidator.$ContentFormat,
@ -311,6 +336,9 @@ export class Note {
mentions: User[] = [], mentions: User[] = [],
/** List of IDs of database Attachment objects */ /** List of IDs of database Attachment objects */
media_attachments: string[] = [], media_attachments: string[] = [],
replyId?: string,
quoteId?: string,
application?: Application,
) { ) {
const htmlContent = content const htmlContent = content
? await contentToHtml(content, mentions) ? await contentToHtml(content, mentions)
@ -340,6 +368,9 @@ export class Note {
visibility, visibility,
sensitive: is_sensitive, sensitive: is_sensitive,
spoilerText: spoiler_text, spoilerText: spoiler_text,
replyId,
quotingId: quoteId,
applicationId: application?.id,
}); });
// Connect emojis // Connect emojis
@ -393,6 +424,178 @@ export class Note {
return await Note.fromId(newNote.id, newNote.authorId); return await Note.fromId(newNote.id, newNote.authorId);
} }
static async resolve(
uri?: string,
providedNote?: typeof EntityValidator.$Note,
): Promise<Note | null> {
// 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<Note | null> {
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<User>[],
)
: [],
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<User>[],
),
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() { async delete() {
return ( return (
await db await db

@ -1 +1 @@
Subproject commit 3ed54a7c21e69b79b410bac687f30f47d75e5397 Subproject commit 69be6967bd0f0018ac5951238639f49f7c93206e

View file

@ -8,7 +8,6 @@ import type { Hono } from "hono";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { z } from "zod"; import { z } from "zod";
import { type ValidationError, isValidationError } from "zod-validation-error"; import { type ValidationError, isValidationError } from "zod-validation-error";
import { resolveNote } from "~/database/entities/Status";
import { import {
getRelationshipToOtherUser, getRelationshipToOtherUser,
sendFollowAccept, sendFollowAccept,
@ -204,7 +203,7 @@ export default (app: Hono) =>
return errorResponse("Author not found", 404); return errorResponse("Author not found", 404);
} }
const newStatus = await resolveNote( const newStatus = await Note.resolve(
undefined, undefined,
note, note,
).catch((e) => { ).catch((e) => {
@ -366,6 +365,24 @@ export default (app: Hono) =>
return response("User refreshed", 200); 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) { if (result) {