mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
refactor(federation): ♻️ More federation logic cleanup
This commit is contained in:
parent
83399ba5f1
commit
0ae9cfe26c
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue