import { Followings, Hashtags, Notes, Users } from "../../../../models/index.js"; import { sqlLikeEscape } from "../../../../misc/sql-like-escape.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { PaginationHelpers } from "./pagination.js"; import { Brackets, IsNull } from "typeorm"; import { awaitAll } from "../../../../prelude/await-all.js"; import { NoteConverter } from "../converters/note.js"; import Resolver from "../../../../remote/activitypub/resolver.js"; import { getApId, isActor, isPost } from "../../../../remote/activitypub/type.js"; import DbResolver from "../../../../remote/activitypub/db-resolver.js"; import { createPerson } from "../../../../remote/activitypub/models/person.js"; import { UserConverter } from "../converters/user.js"; import { resolveUser } from "../../../../remote/resolve-user.js"; import { createNote } from "../../../../remote/activitypub/models/note.js"; import config from "../../../../config/index.js"; import { logger } from "../index.js"; import { generateFtsQuery } from "../../common/generate-fts-query.js"; export class SearchHelpers { static async search(q, type, resolve = false, following = false, accountId, excludeUnreviewed = false, maxId, minId, limit = 20, offset, ctx) { if (q === undefined || q.trim().length === 0) throw new Error('Search query cannot be empty'); if (limit > 40) limit = 40; const user = ctx.user; const notes = type === 'statuses' || !type ? this.searchNotes(q, resolve, following, accountId, maxId, minId, limit, offset, ctx) : []; const users = type === 'accounts' || !type ? this.searchUsers(q, resolve, following, maxId, minId, limit, offset, ctx) : []; const tags = type === 'hashtags' || !type ? this.searchTags(q, excludeUnreviewed, limit, offset) : []; const result = { statuses: Promise.resolve(notes).then((p)=>NoteConverter.encodeMany(p, ctx)), accounts: Promise.resolve(users).then((p)=>UserConverter.encodeMany(p, ctx)), hashtags: Promise.resolve(tags) }; return awaitAll(result); } static async searchUsers(q, resolve, following, maxId, minId, limit, offset, ctx) { const user = ctx.user; if (resolve) { try { if (q.startsWith('https://') || q.startsWith('http://')) { // try resolving locally first const dbResolver = new DbResolver(); const dbResult = await dbResolver.getUserFromApId(q); if (dbResult) return [ dbResult ]; // ask remote const resolver = new Resolver(); resolver.setUser(user); const object = await resolver.resolve(q); if (q !== object.id) { const result = await dbResolver.getUserFromApId(getApId(object)); if (result) return [ result ]; } return isActor(object) ? Promise.all([ createPerson(getApId(object), resolver.reset()) ]) : []; } else { let match = q.match(/^@?(?[a-zA-Z0-9_]+)@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/); if (!match) match = q.match(/^@(?[a-zA-Z0-9_]+)$/); if (match) { // check if user is already in database const dbResult = await Users.findOneBy({ usernameLower: match.groups.user.toLowerCase(), host: match.groups?.host ?? IsNull() }); if (dbResult) return [ dbResult ]; const result = await resolveUser(match.groups.user.toLowerCase(), match.groups?.host ?? null); if (result) return [ result ]; // no matches found return []; } } } catch (e) { console.log(`[mastodon-client] resolve user '${q}' failed: ${e.message}`); return []; } } const query = PaginationHelpers.makePaginationQuery(Users.createQueryBuilder("user"), undefined, minId, maxId); if (following) { const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :followerId", { followerId: user.id }); query.andWhere(new Brackets((qb)=>{ qb.where(`user.id IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, { meId: user.id }); })); } query.andWhere(new Brackets((qb)=>{ qb.where("user.name ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }); qb.orWhere("concat_ws('@', user.usernameLower, user.host) ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }); })); query.orderBy({ 'user.notesCount': 'DESC' }); return query.skip(offset ?? 0).take(limit).getMany().then((p)=>minId ? p.reverse() : p); } static async searchNotes(q, resolve, following, accountId, maxId, minId, limit, offset, ctx) { if (accountId && following) throw new Error("The 'following' and 'accountId' parameters cannot be used simultaneously"); const user = ctx.user; if (resolve) { try { if (q.startsWith('https://') || q.startsWith('http://')) { // try resolving locally first const dbResolver = new DbResolver(); const dbResult = await dbResolver.getNoteFromApId(q); if (dbResult) return [ dbResult ]; // ask remote const resolver = new Resolver(); resolver.setUser(user); const object = await resolver.resolve(q); if (q !== object.id) { const result = await dbResolver.getNoteFromApId(getApId(object)); if (result) return [ result ]; } return isPost(object) ? createNote(getApId(object), resolver.reset(), true).then((p)=>p ? [ p ] : []) : []; } } catch (e) { logger.warn(`Resolving note '${q}' failed: ${e.message}`); return []; } } const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), undefined, minId, maxId); if (accountId) { query.andWhere("note.userId = :userId", { userId: accountId }); } if (following) { const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :followerId", { followerId: user.id }); query.andWhere(new Brackets((qb)=>{ qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, { meId: user.id }); })); } query.leftJoinAndSelect("note.renote", "renote"); generateFtsQuery(query, q); generateVisibilityQuery(query, user); if (!accountId) { generateMutedUserQuery(query, user); generateBlockedUserQuery(query, user); } query.setParameter("meId", user.id); return query.skip(offset ?? 0).take(limit).getMany().then((p)=>minId ? p.reverse() : p); } static async searchTags(q, excludeUnreviewed, limit, offset) { const tags = Hashtags.createQueryBuilder('tag').select('tag.name').distinctOn([ 'tag.name' ]).where("tag.name ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }).orderBy({ 'tag.name': 'ASC' }).skip(offset ?? 0).take(limit).getMany(); return tags.then((p)=>p.map((tag)=>{ return { name: tag.name, url: `${config.url}/tags/${tag.name}`, history: null }; })); } }