diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts index 1813cf954..c259cd345 100644 --- a/packages/backend/src/server/api/mastodon/converters/note.ts +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -23,7 +23,7 @@ import { PollConverter } from "@/server/api/mastodon/converters/poll.js"; import { populatePoll } from "@/models/repositories/note.js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { awaitAll } from "@/prelude/await-all.js"; -import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { In, IsNull } from "typeorm"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; @@ -308,14 +308,40 @@ export class NoteConverter { return Promise.resolve(dbHit) .then(res => { if (res === null || (res.updatedAt?.getTime() !== note.updatedAt?.getTime())) { - this.prewarmCache(note); - return null; + return this.dbCacheMiss(note, ctx); } return res; }) .then(hit => hit?.updatedAt === note.updatedAt ? hit?.content ?? null : null); } + private static async dbCacheMiss(note: Note, ctx: MastoContext): Promise { + const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`; + const cache = ctx.cache as AccountCache; + return cache.locks.acquire(identifier, async () => { + const cachedContent = await this.noteContentHtmlCache.get(identifier); + if (cachedContent !== undefined) { + return { content: cachedContent } as HtmlNoteCacheEntry; + } + + const quoteUri = note.renote + ? isQuote(note) + ? (note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}`) + : null + : null; + + const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null; + const content = text !== null + ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri) + .then(p => p ?? escapeMFM(text)) + : null; + + HtmlNoteCacheEntries.upsert({ noteId: note.id, updatedAt: note.updatedAt, content: await content }, ["noteId"]); + await this.noteContentHtmlCache.set(identifier, await content); + return { content } as HtmlNoteCacheEntry; + }); + } + public static async prewarmCache(note: Note): Promise { if (!config.htmlCache?.prewarm) return; const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`; diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index 7c0849d1f..5ea97d2ec 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -244,13 +244,48 @@ export class UserConverter { return Promise.resolve(dbHit) .then(res => { if (res === null || (res.updatedAt.getTime() !== (user.lastFetchedAt ?? user.createdAt).getTime())) { - this.prewarmCache(user, profile); - return null; + return this.dbCacheMiss(user, profile, ctx); } return res; }); } + private static async dbCacheMiss(user: User, profile: UserProfile | null, ctx: MastoContext): Promise { + const identifier = `${user.id}:${(user.lastFetchedAt ?? user.createdAt).getTime()}`; + const cache = ctx.cache as AccountCache; + 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 } as HtmlUserCacheEntry; + } + + if (profile === undefined) { + profile = await UserProfiles.findOneBy({ userId: user.id }); + } + + let bio: string | null | Promise | undefined = cachedBio; + let fields: MastodonEntity.Field[] | Promise | undefined = 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.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 } as HtmlUserCacheEntry; + }); + } + public static async prewarmCache(user: User, profile?: UserProfile | null, oldProfile?: UserProfile | null): Promise { const identifier = `${user.id}:${(user.lastFetchedAt ?? user.createdAt).getTime()}`; if (profile !== null) {