refactor(federation): ♻️ More federation logic cleanup

This commit is contained in:
Jesse Wierzbinski 2024-12-09 15:01:19 +01:00
parent 83399ba5f1
commit 0ae9cfe26c
No known key found for this signature in database
10 changed files with 124 additions and 133 deletions

View file

@ -117,6 +117,10 @@ export default apiRoute((app) =>
const uri = await User.webFinger(manager, username, domain); const uri = await User.webFinger(manager, username, domain);
if (!uri) {
return context.json({ error: "Account not found" }, 404);
}
const foundAccount = await User.resolve(uri); const foundAccount = await User.resolve(uri);
if (foundAccount) { if (foundAccount) {

View file

@ -1,4 +1,10 @@
import { apiRoute, applyConfig, auth, userAddressValidator } from "@/api"; import {
apiRoute,
applyConfig,
auth,
parseUserAddress,
userAddressValidator,
} from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
@ -77,20 +83,22 @@ export default apiRoute((app) =>
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
const [username, host] = q.replace(/^@/, "").split("@"); const { username, domain } = parseUserAddress(q);
const accounts: User[] = []; const accounts: User[] = [];
if (resolve && username && host) { if (resolve && domain) {
const manager = await (self ?? User).getFederationRequester(); const manager = await (self ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, host); const uri = await User.webFinger(manager, username, domain);
if (uri) {
const resolvedUser = await User.resolve(uri); const resolvedUser = await User.resolve(uri);
if (resolvedUser) { if (resolvedUser) {
accounts.push(resolvedUser); accounts.push(resolvedUser);
} }
}
} else { } else {
accounts.push( accounts.push(
...(await User.manyFromSql( ...(await User.manyFromSql(

View file

@ -138,7 +138,7 @@ export default apiRoute((app) =>
} }
if (note.author.isLocal() && user.isLocal()) { if (note.author.isLocal() && user.isLocal()) {
await note.author.createNotification("reblog", user, newReblog); await note.author.notify("reblog", user, newReblog);
} }
return context.json(await finalNewReblog.toApi(user), 201); return context.json(await finalNewReblog.toApi(user), 201);

View file

@ -163,6 +163,7 @@ export default apiRoute((app) =>
const uri = await User.webFinger(manager, username, domain); const uri = await User.webFinger(manager, username, domain);
if (uri) {
const newUser = await User.resolve(uri); const newUser = await User.resolve(uri);
if (newUser) { if (newUser) {
@ -177,6 +178,7 @@ export default apiRoute((app) =>
} }
} }
} }
}
accountResults = await searchManager.searchAccounts( accountResults = await searchManager.searchAccounts(
q, q,

View file

@ -3,7 +3,7 @@ import { proxyUrl } from "@/response";
import type { Emoji as APIEmoji } from "@versia/client/types"; import type { Emoji as APIEmoji } from "@versia/client/types";
import type { CustomEmojiExtension } from "@versia/federation/types"; import type { CustomEmojiExtension } from "@versia/federation/types";
import { type Instance, db } from "@versia/kit/db"; import { type Instance, db } from "@versia/kit/db";
import { Emojis, Instances } from "@versia/kit/tables"; import { Emojis, type Instances } from "@versia/kit/tables";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -137,26 +137,15 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
emojiToFetch: CustomEmojiExtension["emojis"][0], emojiToFetch: CustomEmojiExtension["emojis"][0],
instance: Instance, instance: Instance,
): Promise<Emoji> { ): Promise<Emoji> {
const existingEmoji = await db const existingEmoji = await Emoji.fromSql(
.select()
.from(Emojis)
.innerJoin(Instances, eq(Emojis.instanceId, Instances.id))
.where(
and( and(
eq(Emojis.shortcode, emojiToFetch.name), eq(Emojis.shortcode, emojiToFetch.name),
eq(Instances.id, instance.id), eq(Emojis.instanceId, instance.id),
), ),
) );
.limit(1);
if (existingEmoji[0]) { if (existingEmoji) {
const found = await Emoji.fromId(existingEmoji[0].Emojis.id); return existingEmoji;
if (!found) {
throw new Error("Failed to fetch emoji");
}
return found;
} }
return await Emoji.fromVersia(emojiToFetch, instance); return await Emoji.fromVersia(emojiToFetch, instance);

View file

@ -492,11 +492,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
// Send notifications for mentioned local users // Send notifications for mentioned local users
for (const mention of parsedMentions) { for (const mention of parsedMentions) {
if (mention.isLocal()) { if (mention.isLocal()) {
await mention.createNotification( await mention.notify("mention", data.author, newNote);
"mention",
data.author,
newNote,
);
} }
} }

View file

@ -275,7 +275,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
senderId: this.id, senderId: this.id,
}); });
} else { } else {
await otherUser.createNotification( await otherUser.notify(
otherUser.data.isLocked ? "follow_request" : "follow", otherUser.data.isLocked ? "follow_request" : "follow",
this, this,
); );
@ -362,19 +362,31 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}); });
} }
public static async webFinger( /**
* Perform a WebFinger lookup to find a user's URI
* @param manager
* @param username
* @param hostname
* @returns URI, or null if not found
*/
public static webFinger(
manager: FederationRequester, manager: FederationRequester,
username: string, username: string,
hostname: string, hostname: string,
): Promise<string> { ): Promise<string | null> {
return ( try {
(await manager.webFinger(username, hostname).catch(() => null)) ?? return manager.webFinger(username, hostname);
(await manager.webFinger( } catch {
try {
return manager.webFinger(
username, username,
hostname, hostname,
"application/activity+json", "application/activity+json",
))
); );
} catch {
return Promise.resolve(null);
}
}
} }
public static getCount(): Promise<number> { public static getCount(): Promise<number> {
@ -511,7 +523,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (this.isLocal() && note.author.isLocal()) { if (this.isLocal() && note.author.isLocal()) {
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await note.author.createNotification("favourite", this, note); await note.author.notify("favourite", this, note);
} else if (this.isLocal() && note.author.isRemote()) { } else if (this.isLocal() && note.author.isRemote()) {
// Federate the like // Federate the like
this.federateToFollowers(newLike.toVersia()); this.federateToFollowers(newLike.toVersia());
@ -547,7 +559,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
} }
public async createNotification( public async notify(
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog", type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
relatedUser: User, relatedUser: User,
note?: Note, note?: Note,

View file

@ -218,9 +218,9 @@ export const parseTextMentions = async (
} }
const baseUrlHost = new URL(config.http.base_url).host; const baseUrlHost = new URL(config.http.base_url).host;
const isLocal = (host?: string): boolean => host === baseUrlHost || !host; const isLocal = (host?: string): boolean => host === baseUrlHost || !host;
// Find local and matching users
const foundUsers = await db const foundUsers = await db
.select({ .select({
id: Users.id, id: Users.id,
@ -233,47 +233,46 @@ export const parseTextMentions = async (
or( or(
...mentionedPeople.map((person) => ...mentionedPeople.map((person) =>
and( and(
eq(Users.username, person?.[1] ?? ""), eq(Users.username, person[1] ?? ""),
isLocal(person?.[2]) isLocal(person[2])
? isNull(Users.instanceId) ? isNull(Users.instanceId)
: eq(Instances.baseUrl, person?.[2] ?? ""), : eq(Instances.baseUrl, person[2] ?? ""),
), ),
), ),
), ),
); );
const notFoundRemoteUsers = mentionedPeople.filter( // Separate found and unresolved users
(person) => const finalList = await User.manyFromSql(
!(
isLocal(person?.[2]) ||
foundUsers.find(
(user) =>
user.username === person?.[1] &&
user.baseUrl === person?.[2],
)
),
);
const finalList =
foundUsers.length > 0
? await User.manyFromSql(
inArray( inArray(
Users.id, Users.id,
foundUsers.map((u) => u.id), foundUsers.map((u) => u.id),
), ),
) );
: [];
// Attempt to resolve mentions that were not found // Every remote user that isn't in database
const notFoundRemoteUsers = mentionedPeople.filter(
(p) =>
!(
foundUsers.some(
(user) => user.username === p[1] && user.baseUrl === p[2],
) || isLocal(p[2])
),
);
// Resolve remote mentions not in database
for (const person of notFoundRemoteUsers) { for (const person of notFoundRemoteUsers) {
const manager = await author.getFederationRequester(); const manager = await author.getFederationRequester();
const uri = await User.webFinger( const uri = await User.webFinger(
manager, manager,
person?.[1] ?? "", person[1] ?? "",
person?.[2] ?? "", person[2] ?? "",
); );
if (!uri) {
continue;
}
const user = await User.resolve(uri); const user = await User.resolve(uri);
if (user) { if (user) {
@ -285,51 +284,35 @@ export const parseTextMentions = async (
}; };
export const replaceTextMentions = (text: string, mentions: User[]): string => { export const replaceTextMentions = (text: string, mentions: User[]): string => {
let finalText = text; return mentions.reduce((finalText, mention) => {
for (const mention of mentions) { const { username, instance } = mention.data;
const user = mention.data; const uri = mention.getUri();
// Replace @username and @username@domain const baseHost = new URL(config.http.base_url).host;
if (user.instance) { const linkTemplate = (displayText: string): string =>
finalText = finalText.replace( `<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
createRegExp(
exactly(`@${user.username}@${user.instance.baseUrl}`), if (mention.isRemote()) {
[global], return finalText.replaceAll(
), `@${username}@${instance?.baseUrl}`,
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${ linkTemplate(`@${username}@${instance?.baseUrl}`),
user.username
}@${user.instance.baseUrl}</a>`,
); );
} else { }
finalText = finalText.replace(
// Only replace @username if it doesn't have another @ right after return finalText
.replace(
createRegExp( createRegExp(
exactly(`@${user.username}`) exactly(`@${username}`)
.notBefore(anyOf(letter, digit, charIn("@"))) .notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))), .notAfter(anyOf(letter, digit, charIn("@"))),
[global], [global],
), ),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${ linkTemplate(`@${username}@${baseHost}`),
user.username )
}</a>`, .replaceAll(
`@${username}@${baseHost}`,
linkTemplate(`@${username}@${baseHost}`),
); );
}, text);
finalText = finalText.replace(
createRegExp(
exactly(
`@${user.username}@${
new URL(config.http.base_url).host
}`,
),
[global],
),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
user.username
}</a>`,
);
}
}
return finalText;
}; };
export const contentToHtml = async ( export const contentToHtml = async (
@ -337,8 +320,8 @@ export const contentToHtml = async (
mentions: User[] = [], mentions: User[] = [],
inline = false, inline = false,
): Promise<string> => { ): Promise<string> => {
let htmlContent: string;
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml; const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
let htmlContent = "";
if (content["text/html"]) { if (content["text/html"]) {
htmlContent = await sanitizer(content["text/html"].content); htmlContent = await sanitizer(content["text/html"].content);
@ -347,29 +330,20 @@ export const contentToHtml = async (
await markdownParse(content["text/markdown"].content), await markdownParse(content["text/markdown"].content),
); );
} else if (content["text/plain"]?.content) { } else if (content["text/plain"]?.content) {
// Split by newline and add <p> tags
htmlContent = (await sanitizer(content["text/plain"].content)) htmlContent = (await sanitizer(content["text/plain"].content))
.split("\n") .split("\n")
.map((line) => `<p>${line}</p>`) .map((line) => `<p>${line}</p>`)
.join("\n"); .join("\n");
} else {
htmlContent = "";
} }
// Replace mentions text htmlContent = replaceTextMentions(htmlContent, mentions);
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
// Linkify return linkifyHtml(htmlContent, {
htmlContent = linkifyHtml(htmlContent, {
defaultProtocol: "https", defaultProtocol: "https",
validate: { validate: { email: (): false => false },
email: (): false => false,
},
target: "_blank", target: "_blank",
rel: "nofollow noopener noreferrer", rel: "nofollow noopener noreferrer",
}); });
return htmlContent;
}; };
export const markdownParse = async (content: string): Promise<string> => { export const markdownParse = async (content: string): Promise<string> => {

View file

@ -333,7 +333,7 @@ export class InboxProcessor {
languages: [], languages: [],
}); });
await followee.createNotification( await followee.notify(
followee.data.isLocked ? "follow_request" : "follow", followee.data.isLocked ? "follow_request" : "follow",
author, author,
); );

View file

@ -49,6 +49,12 @@ export default class FederationUserFetch extends BaseCommand<
const uri = await User.webFinger(manager, username, host); const uri = await User.webFinger(manager, username, host);
if (!uri) {
spinner.fail();
this.log(chalk.red("User not found"));
this.exit(1);
}
const newUser = await User.resolve(uri); const newUser = await User.resolve(uri);
if (newUser) { if (newUser) {