mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(federation): ✨ Add support for federation of note editing
This commit is contained in:
parent
3e94a9d491
commit
8f09ea4c60
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue