diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index eb12413d..055d80f2 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -117,6 +117,10 @@ export default apiRoute((app) => const uri = await User.webFinger(manager, username, domain); + if (!uri) { + return context.json({ error: "Account not found" }, 404); + } + const foundAccount = await User.resolve(uri); if (foundAccount) { diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index e69ef7fe..3cf9f41e 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -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 { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; @@ -77,19 +83,21 @@ export default apiRoute((app) => return context.json({ error: "Unauthorized" }, 401); } - const [username, host] = q.replace(/^@/, "").split("@"); + const { username, domain } = parseUserAddress(q); const accounts: User[] = []; - if (resolve && username && host) { + if (resolve && domain) { const manager = await (self ?? User).getFederationRequester(); - const uri = await User.webFinger(manager, username, host); + const uri = await User.webFinger(manager, username, domain); - const resolvedUser = await User.resolve(uri); + if (uri) { + const resolvedUser = await User.resolve(uri); - if (resolvedUser) { - accounts.push(resolvedUser); + if (resolvedUser) { + accounts.push(resolvedUser); + } } } else { accounts.push( diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 09ec4a9c..1e4f64da 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -138,7 +138,7 @@ export default apiRoute((app) => } 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); diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index a2fe7eb7..ac1ae5f5 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -163,17 +163,19 @@ export default apiRoute((app) => const uri = await User.webFinger(manager, username, domain); - const newUser = await User.resolve(uri); + if (uri) { + const newUser = await User.resolve(uri); - if (newUser) { - return context.json( - { - accounts: [newUser.toApi()], - statuses: [], - hashtags: [], - }, - 200, - ); + if (newUser) { + return context.json( + { + accounts: [newUser.toApi()], + statuses: [], + hashtags: [], + }, + 200, + ); + } } } } diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts index 4df0d7fa..a2094bc1 100644 --- a/classes/database/emoji.ts +++ b/classes/database/emoji.ts @@ -3,7 +3,7 @@ import { proxyUrl } from "@/response"; import type { Emoji as APIEmoji } from "@versia/client/types"; import type { CustomEmojiExtension } from "@versia/federation/types"; import { type Instance, db } from "@versia/kit/db"; -import { Emojis, Instances } from "@versia/kit/tables"; +import { Emojis, type Instances } from "@versia/kit/tables"; import { type InferInsertModel, type InferSelectModel, @@ -137,26 +137,15 @@ export class Emoji extends BaseInterface { emojiToFetch: CustomEmojiExtension["emojis"][0], instance: Instance, ): Promise { - const existingEmoji = await db - .select() - .from(Emojis) - .innerJoin(Instances, eq(Emojis.instanceId, Instances.id)) - .where( - and( - eq(Emojis.shortcode, emojiToFetch.name), - eq(Instances.id, instance.id), - ), - ) - .limit(1); + const existingEmoji = await Emoji.fromSql( + and( + eq(Emojis.shortcode, emojiToFetch.name), + eq(Emojis.instanceId, instance.id), + ), + ); - if (existingEmoji[0]) { - const found = await Emoji.fromId(existingEmoji[0].Emojis.id); - - if (!found) { - throw new Error("Failed to fetch emoji"); - } - - return found; + if (existingEmoji) { + return existingEmoji; } return await Emoji.fromVersia(emojiToFetch, instance); diff --git a/classes/database/note.ts b/classes/database/note.ts index 894ba029..6baf14ec 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -492,11 +492,7 @@ export class Note extends BaseInterface { // Send notifications for mentioned local users for (const mention of parsedMentions) { if (mention.isLocal()) { - await mention.createNotification( - "mention", - data.author, - newNote, - ); + await mention.notify("mention", data.author, newNote); } } diff --git a/classes/database/user.ts b/classes/database/user.ts index 15bf6f21..d55b07f2 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -275,7 +275,7 @@ export class User extends BaseInterface { senderId: this.id, }); } else { - await otherUser.createNotification( + await otherUser.notify( otherUser.data.isLocked ? "follow_request" : "follow", this, ); @@ -362,19 +362,31 @@ export class User extends BaseInterface { }); } - 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, username: string, hostname: string, - ): Promise { - return ( - (await manager.webFinger(username, hostname).catch(() => null)) ?? - (await manager.webFinger( - username, - hostname, - "application/activity+json", - )) - ); + ): Promise { + try { + return manager.webFinger(username, hostname); + } catch { + try { + return manager.webFinger( + username, + hostname, + "application/activity+json", + ); + } catch { + return Promise.resolve(null); + } + } } public static getCount(): Promise { @@ -511,7 +523,7 @@ export class User extends BaseInterface { if (this.isLocal() && note.author.isLocal()) { // 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()) { // Federate the like this.federateToFollowers(newLike.toVersia()); @@ -547,7 +559,7 @@ export class User extends BaseInterface { } } - public async createNotification( + public async notify( type: "mention" | "follow_request" | "follow" | "favourite" | "reblog", relatedUser: User, note?: Note, diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 1e12118a..bf5ecba6 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -218,9 +218,9 @@ export const parseTextMentions = async ( } const baseUrlHost = new URL(config.http.base_url).host; - const isLocal = (host?: string): boolean => host === baseUrlHost || !host; + // Find local and matching users const foundUsers = await db .select({ id: Users.id, @@ -233,47 +233,46 @@ export const parseTextMentions = async ( or( ...mentionedPeople.map((person) => and( - eq(Users.username, person?.[1] ?? ""), - isLocal(person?.[2]) + eq(Users.username, person[1] ?? ""), + isLocal(person[2]) ? isNull(Users.instanceId) - : eq(Instances.baseUrl, person?.[2] ?? ""), + : eq(Instances.baseUrl, person[2] ?? ""), ), ), ), ); + // Separate found and unresolved users + const finalList = await User.manyFromSql( + inArray( + Users.id, + foundUsers.map((u) => u.id), + ), + ); + + // Every remote user that isn't in database const notFoundRemoteUsers = mentionedPeople.filter( - (person) => + (p) => !( - isLocal(person?.[2]) || - foundUsers.find( - (user) => - user.username === person?.[1] && - user.baseUrl === person?.[2], - ) + foundUsers.some( + (user) => user.username === p[1] && user.baseUrl === p[2], + ) || isLocal(p[2]) ), ); - const finalList = - foundUsers.length > 0 - ? await User.manyFromSql( - inArray( - Users.id, - foundUsers.map((u) => u.id), - ), - ) - : []; - - // Attempt to resolve mentions that were not found + // Resolve remote mentions not in database for (const person of notFoundRemoteUsers) { const manager = await author.getFederationRequester(); - const uri = await User.webFinger( manager, - person?.[1] ?? "", - person?.[2] ?? "", + person[1] ?? "", + person[2] ?? "", ); + if (!uri) { + continue; + } + const user = await User.resolve(uri); if (user) { @@ -285,51 +284,35 @@ export const parseTextMentions = async ( }; export const replaceTextMentions = (text: string, mentions: User[]): string => { - let finalText = text; - for (const mention of mentions) { - const user = mention.data; - // Replace @username and @username@domain - if (user.instance) { - finalText = finalText.replace( - createRegExp( - exactly(`@${user.username}@${user.instance.baseUrl}`), - [global], - ), - `@${ - user.username - }@${user.instance.baseUrl}`, + return mentions.reduce((finalText, mention) => { + const { username, instance } = mention.data; + const uri = mention.getUri(); + const baseHost = new URL(config.http.base_url).host; + const linkTemplate = (displayText: string): string => + `${displayText}`; + + if (mention.isRemote()) { + return finalText.replaceAll( + `@${username}@${instance?.baseUrl}`, + linkTemplate(`@${username}@${instance?.baseUrl}`), ); - } else { - finalText = finalText.replace( - // Only replace @username if it doesn't have another @ right after + } + + return finalText + .replace( createRegExp( - exactly(`@${user.username}`) + exactly(`@${username}`) .notBefore(anyOf(letter, digit, charIn("@"))) .notAfter(anyOf(letter, digit, charIn("@"))), [global], ), - `@${ - user.username - }`, + linkTemplate(`@${username}@${baseHost}`), + ) + .replaceAll( + `@${username}@${baseHost}`, + linkTemplate(`@${username}@${baseHost}`), ); - - finalText = finalText.replace( - createRegExp( - exactly( - `@${user.username}@${ - new URL(config.http.base_url).host - }`, - ), - [global], - ), - `@${ - user.username - }`, - ); - } - } - - return finalText; + }, text); }; export const contentToHtml = async ( @@ -337,8 +320,8 @@ export const contentToHtml = async ( mentions: User[] = [], inline = false, ): Promise => { - let htmlContent: string; const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml; + let htmlContent = ""; if (content["text/html"]) { htmlContent = await sanitizer(content["text/html"].content); @@ -347,29 +330,20 @@ export const contentToHtml = async ( await markdownParse(content["text/markdown"].content), ); } else if (content["text/plain"]?.content) { - // Split by newline and add

tags htmlContent = (await sanitizer(content["text/plain"].content)) .split("\n") .map((line) => `

${line}

`) .join("\n"); - } else { - htmlContent = ""; } - // Replace mentions text - htmlContent = await replaceTextMentions(htmlContent, mentions ?? []); + htmlContent = replaceTextMentions(htmlContent, mentions); - // Linkify - htmlContent = linkifyHtml(htmlContent, { + return linkifyHtml(htmlContent, { defaultProtocol: "https", - validate: { - email: (): false => false, - }, + validate: { email: (): false => false }, target: "_blank", rel: "nofollow noopener noreferrer", }); - - return htmlContent; }; export const markdownParse = async (content: string): Promise => { diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 86e27737..5717c2ed 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -333,7 +333,7 @@ export class InboxProcessor { languages: [], }); - await followee.createNotification( + await followee.notify( followee.data.isLocked ? "follow_request" : "follow", author, ); diff --git a/cli/commands/federation/user/fetch.ts b/cli/commands/federation/user/fetch.ts index 5addeed6..9cd70e68 100644 --- a/cli/commands/federation/user/fetch.ts +++ b/cli/commands/federation/user/fetch.ts @@ -49,6 +49,12 @@ export default class FederationUserFetch extends BaseCommand< 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); if (newUser) {