UNPKG

@cocalc/server

Version:

CoCalc server functionality: functions used by either the hub and the next.js server

240 lines (215 loc) 7.21 kB
/* Search for users. - by exact account_id - by exact email_address - by partial match on first_name and last_name - by @username */ import getPool from "@cocalc/database/pool"; import { cmp, isValidUUID, is_valid_email_address as isValidEmailAddress, parse_user_search as parseUserSearch, } from "@cocalc/util/misc"; import { toEpoch } from "@cocalc/database/postgres/util"; import { getLogger } from "@cocalc/backend/logger"; const logger = getLogger("accounts/search"); export interface User { account_id: string; first_name?: string; last_name?: string; name?: string; // "vanity" username last_active?: number; // ms since epoch -- when account was last active created?: number; // ms since epoch -- when account created banned?: boolean; // true if this user has been banned (only set for admin searches, obviously) email_address_verified?: boolean; // true if their email has been verified (a sign they are more trustworthy). // For security reasons, the email_address *only* occurs in search queries that // are by email_address (or for admins); we must not reveal email addresses // of users queried by substring searches, obviously. email_address?: string; } interface DBUser { account_id: string; first_name?: string; last_name?: string; last_active?: Date; created?: number; banned?: boolean; email_address_verified?: object; email_address?: string; } interface Options { // query: comma separated list of email addresses or strings such as // 'foo bar' (find everything where foo and bar are in the name) query: string; // limit on string queries; email query always returns 0 or 1 result per email address // the default is 20. Ordered by last_active, starting with most recently active first. limit?: number; // If account is given, we do a first phase of search on current collaborators of this user, // and only then do a general search (up to the limit). //account_id?: string; // admins get to do full substring query on *email addresses*, whereas normal // users can only find a user by exact email address match (or substring query on name). // Also, admins get unlisted users, whereas non-admins never find them except by // exact email address search. admin?: boolean; } export default async function search({ /* account_id,*/ query, limit, admin, }: Options): Promise<User[]> { limit = limit ?? 20; admin = !!admin; logger.debug("search for ", query); // One special case: when the query is just an email address or uuid. // We just return that account or empty list if no match. if (isValidUUID(query)) { logger.debug("get user by account_id"); const user = process(await getUserByAccountId(query), admin, false); return user ? [user] : []; } if (isValidEmailAddress(query)) { logger.debug("get user by email address"); const user = process(await getUserByEmailAddress(query), admin, true); return user ? [user] : []; } const { string_queries, email_queries } = parseUserSearch(query); if (admin) { // For admin we just do substring queries anyways. for (const email_address of email_queries) { string_queries.push([email_address]); } email_queries.splice(0, email_queries.length); // empty array } const results: User[] = []; let matches: DBUser[] = await getUsersByEmailAddresses(email_queries, limit); for (const user of matches) { const x = process(user, admin, true); if (x) { results.push(x); } } matches = await getUsersByStringQueries( string_queries, admin, limit - matches.length ); for (const user of matches) { const x = process(user, admin, false); if (x) { results.push(x); } } results.sort( (a, b) => -cmp( Math.max(a.last_active ?? 0, a.created ?? 0), Math.max(b.last_active ?? 0, b.created ?? 0) ) ); return results; } function process( user: DBUser | undefined, admin: boolean = false, isEmailSearch: boolean ): User | undefined { if (user == null) return undefined; const x: any = { ...user }; if (x.email_address && x.email_address_verified) { x.email_address_verified = x.email_address_verified[x.email_address] != null; } if (!admin) { if (!isEmailSearch) { delete x.email_address; } delete x.banned; } toEpoch(x, ["last_active", "created"]); return x; } const FIELDS = " account_id, first_name, last_name, name, email_address, last_active, created, banned, email_address_verified "; async function getUserByEmailAddress( email_address: string ): Promise<DBUser | undefined> { const pool = getPool("medium"); const { rows } = await pool.query( `SELECT ${FIELDS} FROM accounts WHERE email_address=$1`, [email_address.toLowerCase()] ); return rows[0]; } async function getUserByAccountId( account_id: string ): Promise<DBUser | undefined> { const pool = getPool("medium"); const { rows } = await pool.query( `SELECT ${FIELDS} FROM accounts WHERE account_id=$1`, [account_id.toLowerCase()] ); return rows[0]; } async function getUsersByEmailAddresses( email_queries: string[], limit: number ): Promise<DBUser[]> { logger.debug("getUsersByEmailAddresses", email_queries); if (email_queries.length == 0 || limit <= 0) return []; const pool = getPool("medium"); const { rows } = await pool.query( `SELECT ${FIELDS} FROM accounts WHERE email_address = ANY($1::TEXT[]) AND deleted IS NULL`, [email_queries] ); return rows; } async function getUsersByStringQueries( string_queries: string[][], admin: boolean, limit: number ): Promise<DBUser[]> { logger.debug("getUsersByStringQueries", string_queries); if (limit <= 0 || string_queries.length <= 0) { return []; } /* Substring search on first and last name, and for admin also email_address. With the two indexes, the query below is very fast, even on millions of accounts: CREATE INDEX accounts_first_name_idx ON accounts(first_name text_pattern_ops); CREATE INDEX accounts_last_name_idx ON accounts(last_name text_pattern_ops); */ const params: (string | number)[] = []; const where: string[] = []; let i = 1; for (const terms of string_queries) { const v: string[] = []; for (const s of terms) { v.push( `(lower(first_name) LIKE $${i}::TEXT OR lower(last_name) LIKE $${i}::TEXT OR '@' || lower(name) LIKE $${i}::TEXT ${ admin ? `OR lower(email_address) LIKE $${i}::TEXT` : "" })` ); params.push(`%${s}%`); i += 1; } where.push(`(${v.join(" AND ")})`); } let query = `SELECT ${FIELDS} FROM accounts WHERE deleted IS NOT TRUE AND (${where.join( " OR " )})`; if (!admin) { // Exclude unlisted users from search results query += " AND unlisted IS NOT true "; } // recently active users are much more relevant than old ones -- #2991 query += " ORDER BY COALESCE(last_active, created) DESC NULLS LAST"; query += ` LIMIT $${i}::INTEGER `; i += 1; params.push(limit); const pool = getPool("medium"); const { rows } = await pool.query(query, params); return rows; }