import config from "../../../../config/index.js"; import { DriveFiles, Followings, HtmlUserCacheEntries, UserProfiles, Users } from "../../../../models/index.js"; import { EmojiConverter } from "./emoji.js"; import { populateEmojis } from "../../../../misc/populate-emojis.js"; import { escapeMFM } from "./mfm.js"; import mfm from "mfm-js"; import { awaitAll } from "../../../../prelude/await-all.js"; import { UserHelpers } from "../helpers/user.js"; import { MfmHelpers } from "../helpers/mfm.js"; import { In } from "typeorm"; import { unique } from "../../../../prelude/array.js"; import { Cache } from "../../../../misc/cache.js"; import { getUser } from "../../common/getters.js"; import AsyncLock from "async-lock"; export class UserConverter { static userBioHtmlCache = new Cache('html:user:bio', config.htmlCache?.ttlSeconds ?? 60 * 60); static userFieldsHtmlCache = new Cache('html:user:fields', config.htmlCache?.ttlSeconds ?? 60 * 60); static async encode(u, ctx) { const localUser = ctx.user; const cache = ctx.cache; return cache.locks.acquire(u.id, async ()=>{ const cacheHit = cache.accounts.find((p)=>p.id == u.id); if (cacheHit) return cacheHit; const identifier = `${u.id}:${(u.lastFetchedAt ?? u.updatedAt ?? u.createdAt).getTime()}`; let fqn = `${u.username}@${u.host ?? config.domain}`; let acct = u.username; let acctUrl = `https://${u.host || config.host}/@${u.username}`; if (u.host) { acct = `${u.username}@${u.host}`; acctUrl = `https://${u.host}/@${u.username}`; } const aggregateProfile = ctx.userProfileAggregate?.get(u.id); let htmlCacheEntry = undefined; const htmlCacheEntryLock = new AsyncLock(); const profile = aggregateProfile !== undefined ? aggregateProfile : UserProfiles.findOneBy({ userId: u.id }); const bio = this.userBioHtmlCache.fetch(identifier, async ()=>{ return htmlCacheEntryLock.acquire(u.id, async ()=>{ if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx); if (htmlCacheEntry === null) { return Promise.resolve(profile).then(async (profile)=>{ return MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '
' ? p : null); }); } return htmlCacheEntry?.bio ?? null; }); }, true).then((p)=>p ?? ''); const avatar = u.avatarId ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? DriveFiles.findOneBy({ id: u.avatarId }).then((p)=>p?.url ?? Users.getIdenticonUrl(u.id)).then((p)=>DriveFiles.getFinalUrl(p)) : Users.getIdenticonUrl(u.id); const banner = u.bannerId ? DriveFiles.getFinalUrlMaybe(u.bannerUrl) ?? DriveFiles.findOneBy({ id: u.bannerId }).then((p)=>p?.url ?? `${config.url}/static-assets/transparent.png`).then((p)=>DriveFiles.getFinalUrl(p)) : `${config.url}/static-assets/transparent.png`; const isFollowedOrSelf = ctx.followedOrSelfAggregate?.get(u.id) ?? (!!localUser && (localUser.id === u.id || Followings.exist({ where: { followeeId: u.id, followerId: localUser.id } }))); const followersCount = Promise.resolve(profile).then(async (profile)=>{ if (profile === null) return u.followersCount; switch(profile.ffVisibility){ case "public": return u.followersCount; case "followers": return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followersCount : 0); case "private": return localUser?.id === profile.userId ? u.followersCount : 0; } }); const followingCount = Promise.resolve(profile).then(async (profile)=>{ if (profile === null) return u.followingCount; switch(profile.ffVisibility){ case "public": return u.followingCount; case "followers": return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followingCount : 0); case "private": return localUser?.id === profile.userId ? u.followingCount : 0; } }); const fields = this.userFieldsHtmlCache.fetch(identifier, async ()=>{ return htmlCacheEntryLock.acquire(u.id, async ()=>{ if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx); if (htmlCacheEntry === null) { return Promise.resolve(profile).then((profile)=>Promise.all(profile?.fields.map(async (p)=>this.encodeField(p, u.host, profile?.mentions)) ?? [])); } return htmlCacheEntry?.fields ?? []; }); }, true); return awaitAll({ id: u.id, username: u.username, acct: acct, fqn: fqn, display_name: u.name || u.username, locked: u.isLocked, created_at: u.createdAt.toISOString(), followers_count: followersCount, following_count: followingCount, statuses_count: u.notesCount, note: bio, url: u.uri ?? acctUrl, avatar: avatar, avatar_static: avatar, header: banner, header_static: banner, emojis: populateEmojis(u.emojis, u.host).then((emoji)=>emoji.map((e)=>EmojiConverter.encode(e))), moved: null, fields: fields, bot: u.isBot, discoverable: u.isExplorable }).then((p)=>{ // noinspection ES6MissingAwait UserHelpers.updateUserInBackground(u); cache.accounts.push(p); return p; }); }); } static async aggregateData(users, ctx) { const user = ctx.user; const targets = unique(users.map((u)=>u.id)); const followedOrSelfAggregate = new Map(); const userProfileAggregate = new Map(); const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map(); if (config.htmlCache?.dbFallback) { const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({ userId: In(targets) }); for (const target of targets){ htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null); } } if (user) { const targetsWithoutSelf = targets.filter((u)=>u !== user.id); if (targetsWithoutSelf.length > 0) { const followings = await Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId', { meId: user.id }).andWhere('following.followeeId IN (:...targets)', { targets: targetsWithoutSelf }).getMany(); for (const userId of targetsWithoutSelf){ followedOrSelfAggregate.set(userId, !!followings.find((f)=>f.followerId === userId)); } } followedOrSelfAggregate.set(user.id, true); } const profiles = await UserProfiles.findBy({ userId: In(targets) }); for (const userId of targets){ userProfileAggregate.set(userId, profiles.find((p)=>p.userId === userId) ?? null); } ctx.followedOrSelfAggregate = followedOrSelfAggregate; ctx.htmlUserCacheAggregate = htmlUserCacheAggregate; } static async aggregateDataByIds(userIds, ctx) { const targets = unique(userIds); const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map(); if (config.htmlCache?.dbFallback) { const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({ userId: In(targets) }); for (const target of targets){ htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null); } } ctx.htmlUserCacheAggregate = htmlUserCacheAggregate; } static async encodeMany(users, ctx) { await this.aggregateData(users, ctx); const encoded = users.map((u)=>this.encode(u, ctx)); return Promise.all(encoded); } static async encodeField(f, host, mentions) { return { name: f.name, value: await MfmHelpers.toHtml(mfm.parse(f.value), mentions, host, true) ?? escapeMFM(f.value), verified_at: f.verified ? new Date().toISOString() : null }; } static async fetchFromCacheWithFallback(user, profile, ctx) { if (!config.htmlCache?.dbFallback) return null; let dbHit = ctx.htmlUserCacheAggregate?.get(user.id); if (dbHit === undefined) dbHit = HtmlUserCacheEntries.findOneBy({ userId: user.id }); return Promise.resolve(dbHit).then((res)=>{ if (res === null || res.updatedAt.getTime() !== (user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()) { return this.dbCacheMiss(user, profile, ctx); } return res; }); } static async dbCacheMiss(user, profile, ctx) { const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`; const cache = ctx.cache; return cache.locks.acquire(identifier, async ()=>{ const cachedBio = await this.userBioHtmlCache.get(identifier); const cachedFields = await this.userFieldsHtmlCache.get(identifier); if (cachedBio !== undefined && cachedFields !== undefined) { return { bio: cachedBio, fields: cachedFields }; } if (profile === undefined) { profile = await UserProfiles.findOneBy({ userId: user.id }); } let bio = cachedBio; let fields = cachedFields; if (bio === undefined) { bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '' ? p : null); } if (fields === undefined) { fields = Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []); } HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, bio: await bio, fields: await fields }, [ "userId" ]); await this.userBioHtmlCache.set(identifier, await bio); await this.userFieldsHtmlCache.set(identifier, await fields); return { bio, fields }; }); } static async prewarmCache(user, profile, oldProfile) { const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`; if (profile !== null) { if (config.htmlCache?.dbFallback) { if (profile === undefined) { profile = await UserProfiles.findOneBy({ userId: user.id }); } if (oldProfile !== undefined && profile?.fields === oldProfile?.fields && profile?.description === oldProfile?.description) { HtmlUserCacheEntries.update({ userId: user.id }, { updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt }); return; } } if (!config.htmlCache?.prewarm) return; if (profile === undefined) { profile = await UserProfiles.findOneBy({ userId: user.id }); } if (await this.userBioHtmlCache.get(identifier) === undefined) { const bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '' ? p : null); this.userBioHtmlCache.set(identifier, await bio); if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, bio: await bio }, [ "userId" ]); } if (await this.userFieldsHtmlCache.get(identifier) === undefined) { const fields = await Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []); this.userFieldsHtmlCache.set(identifier, fields); if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, fields: fields }, [ "userId" ]); } } } static async prewarmCacheById(userId, oldProfile) { await this.prewarmCache(await getUser(userId), undefined, oldProfile); } }