mirror of
https://github.com/versia-pub/server.git
synced 2025-12-07 16:58:20 +01:00
refactor(federation): 🔥 Refactor Note federation and creation code
This commit is contained in:
parent
54b2dfb78d
commit
f79b0bc999
|
|
@ -45,7 +45,7 @@ export default apiRoute((app) =>
|
||||||
async (context) => {
|
async (context) => {
|
||||||
const otherUser = context.get("user");
|
const otherUser = context.get("user");
|
||||||
|
|
||||||
if (otherUser.isLocal()) {
|
if (otherUser.local) {
|
||||||
throw new ApiError(400, "Cannot refetch a local user");
|
throw new ApiError(400, "Cannot refetch a local user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export default apiRoute((app) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if accepting remote follow
|
// Check if accepting remote follow
|
||||||
if (account.isRemote()) {
|
if (account.remote) {
|
||||||
// Federate follow accept
|
// Federate follow accept
|
||||||
await user.acceptFollowRequest(account);
|
await user.acceptFollowRequest(account);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export default apiRoute((app) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if rejecting remote follow
|
// Check if rejecting remote follow
|
||||||
if (account.isRemote()) {
|
if (account.remote) {
|
||||||
// Federate follow reject
|
// Federate follow reject
|
||||||
await user.rejectFollowRequest(account);
|
await user.rejectFollowRequest(account);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
jsonOrForm,
|
jsonOrForm,
|
||||||
withNoteParam,
|
withNoteParam,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||||
import {
|
import {
|
||||||
Attachment as AttachmentSchema,
|
Attachment as AttachmentSchema,
|
||||||
PollOption,
|
PollOption,
|
||||||
|
|
@ -13,12 +14,13 @@ import {
|
||||||
zBoolean,
|
zBoolean,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Media } from "@versia/kit/db";
|
import { Emoji, Media } from "@versia/kit/db";
|
||||||
import * as VersiaEntities from "@versia/sdk/entities";
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
|
||||||
const schema = z
|
const schema = z
|
||||||
|
|
@ -226,22 +228,50 @@ export default apiRoute((app) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNote = await note.updateFromData({
|
const sanitizedSpoilerText = spoiler_text
|
||||||
author: user,
|
? await sanitizedHtmlStrip(spoiler_text)
|
||||||
content: statusText
|
: undefined;
|
||||||
? new VersiaEntities.TextContentFormat({
|
|
||||||
[content_type]: {
|
const content = statusText
|
||||||
content: statusText,
|
? new VersiaEntities.TextContentFormat({
|
||||||
remote: false,
|
[content_type]: {
|
||||||
},
|
content: statusText,
|
||||||
})
|
remote: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const parsedMentions = statusText
|
||||||
|
? await parseTextMentions(statusText, user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const parsedEmojis = statusText
|
||||||
|
? await Emoji.parseFromText(statusText)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await note.update({
|
||||||
|
spoilerText: sanitizedSpoilerText,
|
||||||
|
sensitive,
|
||||||
|
content: content
|
||||||
|
? await contentToHtml(content, parsedMentions)
|
||||||
: undefined,
|
: undefined,
|
||||||
isSensitive: sensitive,
|
|
||||||
spoilerText: spoiler_text,
|
|
||||||
mediaAttachments: foundAttachments,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.json(await newNote.toApi(user), 200);
|
// Emojis, mentions, and attachments are stored in a different table, so update them there too
|
||||||
|
await note.updateEmojis(parsedEmojis);
|
||||||
|
await note.updateMentions(parsedMentions);
|
||||||
|
await note.updateAttachments(foundAttachments);
|
||||||
|
|
||||||
|
await note.reload();
|
||||||
|
|
||||||
|
// Send notifications for mentioned local users
|
||||||
|
for (const mentioned of parsedMentions) {
|
||||||
|
if (mentioned.local) {
|
||||||
|
await mentioned.notify("mention", user, note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json(await note.toApi(user), 200);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export default apiRoute((app) =>
|
||||||
throw new Error("Failed to reblog");
|
throw new Error("Failed to reblog");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.author.isLocal() && user.isLocal()) {
|
if (note.author.local && user.local) {
|
||||||
await note.author.notify("reblog", user, newReblog);
|
await note.author.notify("reblog", user, newReblog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
|
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
|
||||||
|
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||||
import {
|
import {
|
||||||
Attachment as AttachmentSchema,
|
Attachment as AttachmentSchema,
|
||||||
PollOption,
|
PollOption,
|
||||||
|
|
@ -7,12 +8,14 @@ import {
|
||||||
zBoolean,
|
zBoolean,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Media, Note } from "@versia/kit/db";
|
import { Emoji, Media, Note } from "@versia/kit/db";
|
||||||
import * as VersiaEntities from "@versia/sdk/entities";
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
|
import { randomUUIDv7 } from "bun";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
|
||||||
const schema = z
|
const schema = z
|
||||||
|
|
@ -175,27 +178,59 @@ export default apiRoute((app) =>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNote = await Note.fromData({
|
const sanitizedSpoilerText = spoiler_text
|
||||||
author: user,
|
? await sanitizedHtmlStrip(spoiler_text)
|
||||||
content: new VersiaEntities.TextContentFormat({
|
: undefined;
|
||||||
[content_type]: {
|
|
||||||
content: status ?? "",
|
const content = status
|
||||||
remote: false,
|
? new VersiaEntities.TextContentFormat({
|
||||||
},
|
[content_type]: {
|
||||||
}),
|
content: status,
|
||||||
|
remote: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const parsedMentions = status
|
||||||
|
? await parseTextMentions(status, user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const parsedEmojis = status
|
||||||
|
? await Emoji.parseFromText(status)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const newNote = await Note.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
authorId: user.id,
|
||||||
visibility,
|
visibility,
|
||||||
isSensitive: sensitive ?? false,
|
content: content
|
||||||
spoilerText: spoiler_text ?? "",
|
? await contentToHtml(content, parsedMentions)
|
||||||
mediaAttachments: foundAttachments,
|
: undefined,
|
||||||
|
sensitive,
|
||||||
|
spoilerText: sanitizedSpoilerText,
|
||||||
replyId: in_reply_to_id ?? undefined,
|
replyId: in_reply_to_id ?? undefined,
|
||||||
quoteId: quote_id ?? undefined,
|
quotingId: quote_id ?? undefined,
|
||||||
application: application ?? undefined,
|
applicationId: application?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emojis, mentions, and attachments are stored in a different table, so update them there too
|
||||||
|
await newNote.updateEmojis(parsedEmojis);
|
||||||
|
await newNote.updateMentions(parsedMentions);
|
||||||
|
await newNote.updateAttachments(foundAttachments);
|
||||||
|
|
||||||
|
await newNote.reload();
|
||||||
|
|
||||||
if (!local_only) {
|
if (!local_only) {
|
||||||
await newNote.federateToUsers();
|
await newNote.federateToUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send notifications for mentioned local users
|
||||||
|
for (const mentioned of parsedMentions) {
|
||||||
|
if (mentioned.local) {
|
||||||
|
await mentioned.notify("mention", user, newNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return context.json(await newNote.toApi(user), 200);
|
return context.json(await newNote.toApi(user), 200);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export default apiRoute((app) =>
|
||||||
|
|
||||||
const liker = await User.fromId(like.data.likerId);
|
const liker = await User.fromId(like.data.likerId);
|
||||||
|
|
||||||
if (!liker || liker.isRemote()) {
|
if (!liker || liker.remote) {
|
||||||
throw ApiError.accountNotFound();
|
throw ApiError.accountNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,7 @@ export default apiRoute((app) =>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||||
!(note && (await note.isViewableByUser(null))) ||
|
|
||||||
note.isRemote()
|
|
||||||
) {
|
|
||||||
throw ApiError.noteNotFound();
|
throw ApiError.noteNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,7 @@ export default apiRoute((app) =>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||||
!(note && (await note.isViewableByUser(null))) ||
|
|
||||||
note.isRemote()
|
|
||||||
) {
|
|
||||||
throw ApiError.noteNotFound();
|
throw ApiError.noteNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,7 @@ export default apiRoute((app) =>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||||
!(note && (await note.isViewableByUser(null))) ||
|
|
||||||
note.isRemote()
|
|
||||||
) {
|
|
||||||
throw ApiError.noteNotFound();
|
throw ApiError.noteNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default apiRoute((app) =>
|
||||||
throw ApiError.accountNotFound();
|
throw ApiError.accountNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isRemote()) {
|
if (user.remote) {
|
||||||
throw new ApiError(403, "User is not on this instance");
|
throw new ApiError(403, "User is not on this instance");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export default apiRoute((app) =>
|
||||||
throw new ApiError(404, "User not found");
|
throw new ApiError(404, "User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (author.isRemote()) {
|
if (author.remote) {
|
||||||
throw new ApiError(403, "User is not on this instance");
|
throw new ApiError(403, "User is not on this instance");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { idValidator } from "@/api";
|
import { idValidator } from "@/api";
|
||||||
import { mergeAndDeduplicate } from "@/lib.ts";
|
import { mergeAndDeduplicate } from "@/lib.ts";
|
||||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||||
import { sentry } from "@/sentry";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import type { Status, Status as StatusSchema } from "@versia/client/schemas";
|
|
||||||
import { Instance, db } from "@versia/kit/db";
|
import { Instance, db } from "@versia/kit/db";
|
||||||
import {
|
import {
|
||||||
EmojiToNote,
|
EmojiToNote,
|
||||||
|
|
@ -28,11 +26,7 @@ import {
|
||||||
import { htmlToText } from "html-to-text";
|
import { htmlToText } from "html-to-text";
|
||||||
import { createRegExp, exactly, global } from "magic-regexp";
|
import { createRegExp, exactly, global } from "magic-regexp";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import { contentToHtml, findManyNotes } from "~/classes/functions/status";
|
||||||
contentToHtml,
|
|
||||||
findManyNotes,
|
|
||||||
parseTextMentions,
|
|
||||||
} from "~/classes/functions/status";
|
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts";
|
import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts";
|
||||||
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
|
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
|
||||||
|
|
@ -306,179 +300,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isRemote(): boolean {
|
public get remote(): boolean {
|
||||||
return this.author.isRemote();
|
return this.author.remote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public get local(): boolean {
|
||||||
* Update a note from remote federated servers
|
return this.author.local;
|
||||||
* @returns The updated note
|
|
||||||
*/
|
|
||||||
public async updateFromRemote(): Promise<Note> {
|
|
||||||
if (!this.isRemote()) {
|
|
||||||
throw new Error("Cannot refetch a local note (it is not remote)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const note = await User.federationRequester.fetchEntity(
|
|
||||||
this.getUri(),
|
|
||||||
VersiaEntities.Note,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updated = await Note.fromVersia(note);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new Error("Note not found after update");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.data = updated.data;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new note from user input
|
|
||||||
* @param data - The data to create the note from
|
|
||||||
* @returns The created note
|
|
||||||
*/
|
|
||||||
public static async fromData(data: {
|
|
||||||
author: User;
|
|
||||||
content: VersiaEntities.TextContentFormat;
|
|
||||||
visibility: z.infer<typeof StatusSchema.shape.visibility>;
|
|
||||||
isSensitive: boolean;
|
|
||||||
spoilerText: string;
|
|
||||||
emojis?: Emoji[];
|
|
||||||
uri?: URL;
|
|
||||||
mentions?: User[];
|
|
||||||
/** List of IDs of database Attachment objects */
|
|
||||||
mediaAttachments?: Media[];
|
|
||||||
replyId?: string;
|
|
||||||
quoteId?: string;
|
|
||||||
application?: Application;
|
|
||||||
}): Promise<Note> {
|
|
||||||
const plaintextContent =
|
|
||||||
data.content.data["text/plain"]?.content ??
|
|
||||||
Object.entries(data.content.data)[0][1].content;
|
|
||||||
|
|
||||||
const parsedMentions = mergeAndDeduplicate(
|
|
||||||
data.mentions ?? [],
|
|
||||||
await parseTextMentions(plaintextContent, data.author),
|
|
||||||
);
|
|
||||||
const parsedEmojis = mergeAndDeduplicate(
|
|
||||||
data.emojis ?? [],
|
|
||||||
await Emoji.parseFromText(plaintextContent),
|
|
||||||
);
|
|
||||||
|
|
||||||
const htmlContent = await contentToHtml(data.content, parsedMentions);
|
|
||||||
|
|
||||||
const newNote = await Note.insert({
|
|
||||||
id: randomUUIDv7(),
|
|
||||||
authorId: data.author.id,
|
|
||||||
content: htmlContent,
|
|
||||||
contentSource:
|
|
||||||
data.content.data["text/plain"]?.content ||
|
|
||||||
data.content.data["text/markdown"]?.content ||
|
|
||||||
Object.entries(data.content.data)[0][1].content ||
|
|
||||||
"",
|
|
||||||
contentType: "text/html",
|
|
||||||
visibility: data.visibility,
|
|
||||||
sensitive: data.isSensitive,
|
|
||||||
spoilerText: await sanitizedHtmlStrip(data.spoilerText),
|
|
||||||
uri: data.uri?.href || null,
|
|
||||||
replyId: data.replyId ?? null,
|
|
||||||
quotingId: data.quoteId ?? null,
|
|
||||||
applicationId: data.application?.id ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect emojis
|
|
||||||
await newNote.updateEmojis(parsedEmojis);
|
|
||||||
|
|
||||||
// Connect mentions
|
|
||||||
await newNote.updateMentions(parsedMentions);
|
|
||||||
|
|
||||||
// Set attachment parents
|
|
||||||
await newNote.updateAttachments(data.mediaAttachments ?? []);
|
|
||||||
|
|
||||||
// Send notifications for mentioned local users
|
|
||||||
for (const mention of parsedMentions) {
|
|
||||||
if (mention.isLocal()) {
|
|
||||||
await mention.notify("mention", data.author, newNote);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await newNote.reload(data.author.id);
|
|
||||||
|
|
||||||
return newNote;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a note from user input
|
|
||||||
* @param data - The data to update the note from
|
|
||||||
* @returns The updated note
|
|
||||||
*/
|
|
||||||
public async updateFromData(data: {
|
|
||||||
author: User;
|
|
||||||
content?: VersiaEntities.TextContentFormat;
|
|
||||||
visibility?: z.infer<typeof StatusSchema.shape.visibility>;
|
|
||||||
isSensitive?: boolean;
|
|
||||||
spoilerText?: string;
|
|
||||||
emojis?: Emoji[];
|
|
||||||
uri?: URL;
|
|
||||||
mentions?: User[];
|
|
||||||
mediaAttachments?: Media[];
|
|
||||||
replyId?: string;
|
|
||||||
quoteId?: string;
|
|
||||||
application?: Application;
|
|
||||||
}): Promise<Note> {
|
|
||||||
const plaintextContent = data.content
|
|
||||||
? (data.content.data["text/plain"]?.content ??
|
|
||||||
Object.entries(data.content.data)[0][1].content)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const parsedMentions = mergeAndDeduplicate(
|
|
||||||
data.mentions ?? [],
|
|
||||||
plaintextContent
|
|
||||||
? await parseTextMentions(plaintextContent, data.author)
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const parsedEmojis = mergeAndDeduplicate(
|
|
||||||
data.emojis ?? [],
|
|
||||||
plaintextContent ? await Emoji.parseFromText(plaintextContent) : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const htmlContent = data.content
|
|
||||||
? await contentToHtml(data.content, parsedMentions)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await this.update({
|
|
||||||
content: htmlContent,
|
|
||||||
contentSource: data.content
|
|
||||||
? data.content.data["text/plain"]?.content ||
|
|
||||||
data.content.data["text/markdown"]?.content ||
|
|
||||||
Object.entries(data.content.data)[0][1].content ||
|
|
||||||
""
|
|
||||||
: undefined,
|
|
||||||
contentType: "text/html",
|
|
||||||
visibility: data.visibility,
|
|
||||||
sensitive: data.isSensitive,
|
|
||||||
spoilerText: data.spoilerText,
|
|
||||||
uri: data.uri?.href,
|
|
||||||
replyId: data.replyId,
|
|
||||||
quotingId: data.quoteId,
|
|
||||||
applicationId: data.application?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect emojis
|
|
||||||
await this.updateEmojis(parsedEmojis);
|
|
||||||
|
|
||||||
// Connect mentions
|
|
||||||
await this.updateMentions(parsedMentions);
|
|
||||||
|
|
||||||
// Set attachment parents
|
|
||||||
await this.updateAttachments(data.mediaAttachments ?? []);
|
|
||||||
|
|
||||||
await this.reload(data.author.id);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -558,7 +385,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
*/
|
*/
|
||||||
public static async resolve(uri: URL): Promise<Note | null> {
|
public static async resolve(uri: URL): Promise<Note | null> {
|
||||||
// Check if note not already in database
|
// Check if note not already in database
|
||||||
const foundNote = await Note.fromSql(eq(Notes.uri, uri.toString()));
|
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
|
||||||
|
|
||||||
if (foundNote) {
|
if (foundNote) {
|
||||||
return foundNote;
|
return foundNote;
|
||||||
|
|
@ -577,118 +404,124 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
return await Note.fromId(uuid[0]);
|
return await Note.fromId(uuid[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await User.federationRequester.fetchEntity(
|
return Note.fromVersia(uri);
|
||||||
uri,
|
|
||||||
VersiaEntities.Note,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Note.fromVersia(note);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a Versia Note into a database note (saved)
|
* Tries to fetch a Versia Note from the given URL.
|
||||||
* @param note Versia Note
|
*
|
||||||
* @param author Author of the note
|
* @param url The URL to fetch the note from
|
||||||
* @param instance Instance of the note
|
|
||||||
* @returns The saved note
|
|
||||||
*/
|
*/
|
||||||
public static async fromVersia(note: VersiaEntities.Note): Promise<Note> {
|
public static async fromVersia(url: URL): Promise<Note>;
|
||||||
const emojis: Emoji[] = [];
|
|
||||||
const logger = getLogger(["federation", "resolvers"]);
|
|
||||||
|
|
||||||
const author = await User.resolve(note.data.author);
|
/**
|
||||||
if (!author) {
|
* Takes a Versia Note representation, and serializes it to the database.
|
||||||
throw new Error("Invalid object author");
|
*
|
||||||
}
|
* If the note already exists, it will update it.
|
||||||
|
* @param versiaNote
|
||||||
|
*/
|
||||||
|
public static async fromVersia(
|
||||||
|
versiaNote: VersiaEntities.Note,
|
||||||
|
): Promise<Note>;
|
||||||
|
|
||||||
const instance = await Instance.resolve(note.data.uri);
|
public static async fromVersia(
|
||||||
|
versiaNote: VersiaEntities.Note | URL,
|
||||||
for (const emoji of note.data.extensions?.["pub.versia:custom_emojis"]
|
): Promise<Note> {
|
||||||
?.emojis ?? []) {
|
if (versiaNote instanceof URL) {
|
||||||
const resolvedEmoji = await Emoji.fetchFromRemote(
|
// No bridge support for notes yet
|
||||||
emoji,
|
const note = await User.federationRequester.fetchEntity(
|
||||||
instance,
|
versiaNote,
|
||||||
).catch((e) => {
|
VersiaEntities.Note,
|
||||||
logger.error`${e}`;
|
|
||||||
sentry?.captureException(e);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resolvedEmoji) {
|
|
||||||
emojis.push(resolvedEmoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachments: Media[] = [];
|
|
||||||
|
|
||||||
for (const attachment of note.attachments) {
|
|
||||||
const resolvedAttachment = await Media.fromVersia(attachment).catch(
|
|
||||||
(e) => {
|
|
||||||
logger.error`${e}`;
|
|
||||||
sentry?.captureException(e);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resolvedAttachment) {
|
return Note.fromVersia(note);
|
||||||
attachments.push(resolvedAttachment);
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
author: authorUrl,
|
||||||
|
created_at,
|
||||||
|
uri,
|
||||||
|
extensions,
|
||||||
|
group,
|
||||||
|
is_sensitive,
|
||||||
|
mentions: noteMentions,
|
||||||
|
quotes,
|
||||||
|
replies_to,
|
||||||
|
subject,
|
||||||
|
} = versiaNote.data;
|
||||||
|
const instance = await Instance.resolve(authorUrl);
|
||||||
|
const author = await User.resolve(authorUrl);
|
||||||
|
|
||||||
|
if (!author) {
|
||||||
|
throw new Error("Entity author could not be resolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNote = await Note.fromSql(eq(Notes.uri, uri.href));
|
||||||
|
|
||||||
|
const note =
|
||||||
|
existingNote ??
|
||||||
|
(await Note.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
authorId: author.id,
|
||||||
|
visibility: "public",
|
||||||
|
uri: uri.href,
|
||||||
|
createdAt: new Date(created_at).toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
versiaNote.attachments.map((a) => Media.fromVersia(a)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emojis = await Promise.all(
|
||||||
|
extensions?.["pub.versia:custom_emojis"]?.emojis.map((emoji) =>
|
||||||
|
Emoji.fetchFromRemote(emoji, instance),
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mentions = (
|
||||||
|
await Promise.all(
|
||||||
|
noteMentions?.map((mention) => User.resolve(mention)) ?? [],
|
||||||
|
)
|
||||||
|
).filter((m) => m !== null);
|
||||||
|
|
||||||
|
// TODO: Implement groups
|
||||||
|
const visibility = !group || group instanceof URL ? "direct" : group;
|
||||||
|
|
||||||
|
const reply = replies_to ? await Note.resolve(replies_to) : null;
|
||||||
|
const quote = quotes ? await Note.resolve(quotes) : null;
|
||||||
|
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
|
||||||
|
|
||||||
|
await note.update({
|
||||||
|
content: versiaNote.content
|
||||||
|
? await contentToHtml(versiaNote.content, mentions)
|
||||||
|
: undefined,
|
||||||
|
contentSource: versiaNote.content
|
||||||
|
? versiaNote.content.data["text/plain"]?.content ||
|
||||||
|
versiaNote.content.data["text/markdown"]?.content
|
||||||
|
: undefined,
|
||||||
|
contentType: "text/html",
|
||||||
|
visibility: visibility === "followers" ? "private" : visibility,
|
||||||
|
sensitive: is_sensitive ?? false,
|
||||||
|
spoilerText: spoiler,
|
||||||
|
replyId: reply?.id,
|
||||||
|
quotingId: quote?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emojis, mentions, and attachments are stored in a different table, so update them there too
|
||||||
|
await note.updateEmojis(emojis);
|
||||||
|
await note.updateMentions(mentions);
|
||||||
|
await note.updateAttachments(attachments);
|
||||||
|
|
||||||
|
await note.reload(author.id);
|
||||||
|
|
||||||
|
// Send notifications for mentioned local users
|
||||||
|
for (const mentioned of mentions) {
|
||||||
|
if (mentioned.local) {
|
||||||
|
await mentioned.notify("mention", author, note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let visibility = note.data.group
|
return note;
|
||||||
? ["public", "followers"].includes(note.data.group as string)
|
|
||||||
? (note.data.group as "public" | "private")
|
|
||||||
: ("url" as const)
|
|
||||||
: ("direct" as const);
|
|
||||||
|
|
||||||
if (visibility === "url") {
|
|
||||||
// TODO: Implement groups
|
|
||||||
visibility = "direct";
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = {
|
|
||||||
author,
|
|
||||||
content:
|
|
||||||
note.content ??
|
|
||||||
new VersiaEntities.TextContentFormat({
|
|
||||||
"text/plain": {
|
|
||||||
content: "",
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
visibility,
|
|
||||||
isSensitive: note.data.is_sensitive ?? false,
|
|
||||||
spoilerText: note.data.subject ?? "",
|
|
||||||
emojis,
|
|
||||||
uri: note.data.uri,
|
|
||||||
mentions: (
|
|
||||||
await Promise.all(
|
|
||||||
(note.data.mentions ?? []).map(
|
|
||||||
async (mention) => await User.resolve(mention),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).filter((mention) => mention !== null),
|
|
||||||
mediaAttachments: attachments,
|
|
||||||
replyId: note.data.replies_to
|
|
||||||
? (await Note.resolve(note.data.replies_to))?.data.id
|
|
||||||
: undefined,
|
|
||||||
quoteId: note.data.quotes
|
|
||||||
? (await Note.resolve(note.data.quotes))?.data.id
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if new note already exists
|
|
||||||
const foundNote = await Note.fromSql(eq(Notes.uri, note.data.uri.href));
|
|
||||||
|
|
||||||
// If it exists, simply update it
|
|
||||||
if (foundNote) {
|
|
||||||
await foundNote.updateFromData(newData);
|
|
||||||
|
|
||||||
return foundNote;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else, create a new note
|
|
||||||
return await Note.fromData(newData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(ids?: string[]): Promise<void> {
|
public async delete(ids?: string[]): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isLocal(): boolean {
|
public get local(): boolean {
|
||||||
return this.data.author.instanceId === null;
|
return this.data.author.instanceId === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toVersia(): VersiaEntities.Reaction {
|
public toVersia(): VersiaEntities.Reaction {
|
||||||
if (!this.isLocal()) {
|
if (!this.local) {
|
||||||
throw new Error("Cannot convert a non-local reaction to Versia");
|
throw new Error("Cannot convert a non-local reaction to Versia");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +212,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
author: User,
|
author: User,
|
||||||
note: Note,
|
note: Note,
|
||||||
): Promise<Reaction> {
|
): Promise<Reaction> {
|
||||||
if (author.isLocal()) {
|
if (author.local) {
|
||||||
throw new Error("Cannot process a reaction from a local user");
|
throw new Error("Cannot process a reaction from a local user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,12 +148,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return this.data.id;
|
return this.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isLocal(): boolean {
|
public get local(): boolean {
|
||||||
return this.data.instanceId === null;
|
return this.data.instanceId === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isRemote(): boolean {
|
public get remote(): boolean {
|
||||||
return !this.isLocal();
|
return !this.local;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get uri(): URL {
|
public get uri(): URL {
|
||||||
|
|
@ -196,14 +196,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
);
|
);
|
||||||
|
|
||||||
await foundRelationship.update({
|
await foundRelationship.update({
|
||||||
following: otherUser.isRemote() ? false : !otherUser.data.isLocked,
|
following: otherUser.remote ? false : !otherUser.data.isLocked,
|
||||||
requested: otherUser.isRemote() ? true : otherUser.data.isLocked,
|
requested: otherUser.remote ? true : otherUser.data.isLocked,
|
||||||
showingReblogs: options?.reblogs,
|
showingReblogs: options?.reblogs,
|
||||||
notifying: options?.notify,
|
notifying: options?.notify,
|
||||||
languages: options?.languages,
|
languages: options?.languages,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (otherUser.isRemote()) {
|
if (otherUser.remote) {
|
||||||
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||||
entity: {
|
entity: {
|
||||||
type: "Follow",
|
type: "Follow",
|
||||||
|
|
@ -229,7 +229,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
followee: User,
|
followee: User,
|
||||||
relationship: Relationship,
|
relationship: Relationship,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (followee.isRemote()) {
|
if (followee.remote) {
|
||||||
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||||
entity: this.unfollowToVersia(followee).toJSON(),
|
entity: this.unfollowToVersia(followee).toJSON(),
|
||||||
recipientId: followee.id,
|
recipientId: followee.id,
|
||||||
|
|
@ -254,11 +254,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async acceptFollowRequest(follower: User): Promise<void> {
|
public async acceptFollowRequest(follower: User): Promise<void> {
|
||||||
if (!follower.isRemote()) {
|
if (!follower.remote) {
|
||||||
throw new Error("Follower must be a remote user");
|
throw new Error("Follower must be a remote user");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRemote()) {
|
if (this.remote) {
|
||||||
throw new Error("Followee must be a local user");
|
throw new Error("Followee must be a local user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,11 +278,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rejectFollowRequest(follower: User): Promise<void> {
|
public async rejectFollowRequest(follower: User): Promise<void> {
|
||||||
if (!follower.isRemote()) {
|
if (!follower.remote) {
|
||||||
throw new Error("Follower must be a remote user");
|
throw new Error("Follower must be a remote user");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRemote()) {
|
if (this.remote) {
|
||||||
throw new Error("Followee must be a local user");
|
throw new Error("Followee must be a local user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,10 +497,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
uri: uri?.href,
|
uri: uri?.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.isLocal() && note.author.isLocal()) {
|
if (this.local && note.author.local) {
|
||||||
// Notify the user that their post has been favourited
|
// Notify the user that their post has been favourited
|
||||||
await note.author.notify("favourite", this, note);
|
await note.author.notify("favourite", this, note);
|
||||||
} else if (this.isLocal() && note.author.isRemote()) {
|
} else if (this.local && note.author.remote) {
|
||||||
// Federate the like
|
// Federate the like
|
||||||
this.federateToFollowers(newLike.toVersia());
|
this.federateToFollowers(newLike.toVersia());
|
||||||
}
|
}
|
||||||
|
|
@ -526,10 +526,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
|
|
||||||
await likeToDelete.delete();
|
await likeToDelete.delete();
|
||||||
|
|
||||||
if (this.isLocal() && note.author.isLocal()) {
|
if (this.local && note.author.local) {
|
||||||
// Remove any eventual notifications for this like
|
// Remove any eventual notifications for this like
|
||||||
await likeToDelete.clearRelatedNotifications();
|
await likeToDelete.clearRelatedNotifications();
|
||||||
} else if (this.isLocal() && note.author.isRemote()) {
|
} else if (this.local && note.author.remote) {
|
||||||
// User is local, federate the delete
|
// User is local, federate the delete
|
||||||
this.federateToFollowers(likeToDelete.unlikeToVersia(this));
|
this.federateToFollowers(likeToDelete.unlikeToVersia(this));
|
||||||
}
|
}
|
||||||
|
|
@ -630,7 +630,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
* Takes a Versia User representation, and serializes it to the database.
|
* Takes a Versia User representation, and serializes it to the database.
|
||||||
*
|
*
|
||||||
* If the user already exists, it will update it.
|
* If the user already exists, it will update it.
|
||||||
* @param user
|
* @param versiaUser
|
||||||
*/
|
*/
|
||||||
public static async fromVersia(
|
public static async fromVersia(
|
||||||
versiaUser: VersiaEntities.User,
|
versiaUser: VersiaEntities.User,
|
||||||
|
|
@ -895,7 +895,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAcct(): string {
|
public getAcct(): string {
|
||||||
return this.isLocal()
|
return this.local
|
||||||
? this.data.username
|
? this.data.username
|
||||||
: `${this.data.username}@${this.data.instance?.baseUrl}`;
|
: `${this.data.username}@${this.data.instance?.baseUrl}`;
|
||||||
}
|
}
|
||||||
|
|
@ -921,7 +921,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
|
|
||||||
// If something important is updated, federate it
|
// If something important is updated, federate it
|
||||||
if (
|
if (
|
||||||
this.isLocal() &&
|
this.local &&
|
||||||
(newUser.username ||
|
(newUser.username ||
|
||||||
newUser.displayName ||
|
newUser.displayName ||
|
||||||
newUser.note ||
|
newUser.note ||
|
||||||
|
|
@ -1090,7 +1090,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toVersia(): VersiaEntities.User {
|
public toVersia(): VersiaEntities.User {
|
||||||
if (this.isRemote()) {
|
if (this.remote) {
|
||||||
throw new Error("Cannot convert remote user to Versia format");
|
throw new Error("Cannot convert remote user to Versia format");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => {
|
||||||
const linkTemplate = (displayText: string): string =>
|
const linkTemplate = (displayText: string): string =>
|
||||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
|
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
|
||||||
|
|
||||||
if (mention.isRemote()) {
|
if (mention.remote) {
|
||||||
return finalText.replaceAll(
|
return finalText.replaceAll(
|
||||||
`@${username}@${instance?.baseUrl}`,
|
`@${username}@${instance?.baseUrl}`,
|
||||||
linkTemplate(`@${username}@${instance?.baseUrl}`),
|
linkTemplate(`@${username}@${instance?.baseUrl}`),
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sender?.isLocal()) {
|
if (sender?.local) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Cannot process federation requests from local users",
|
"Cannot process federation requests from local users",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const refetchUserCommand = defineCommand(
|
||||||
throw new Error(`User ${chalk.gray(handle)} not found.`);
|
throw new Error(`User ${chalk.gray(handle)} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isLocal()) {
|
if (user.local) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"This user is local and as such cannot be refetched.",
|
"This user is local and as such cannot be refetched.",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ export const Notes = pgTable("Notes", {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
onUpdate: "cascade",
|
onUpdate: "cascade",
|
||||||
}),
|
}),
|
||||||
sensitive: boolean("sensitive").notNull(),
|
sensitive: boolean("sensitive").notNull().default(false),
|
||||||
spoilerText: text("spoiler_text").default("").notNull(),
|
spoilerText: text("spoiler_text").default("").notNull(),
|
||||||
applicationId: uuid("applicationId").references(() => Applications.id, {
|
applicationId: uuid("applicationId").references(() => Applications.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue