Identical article body for cache key collision coverage.
", + language: "en", + tags: {}, + emojis: {}, + url: `http://localhost/@${author.account.username}/2026/${slug}`, + published, + updated: published, + } satisfies NewPost, + ); + } + + const result = await execute({ + schema, + document: articleContentOgImageCollisionQuery, + variableValues: { + handle: author.account.username, + idOrYear: "2026", + firstSlug: slugs[0], + secondSlug: slugs[1], + }, + contextValue: makeUserContext(tx, author.account, { + disk: createOgTestDisk().disk, + }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + const data = toPlainJson(result.data) as { + first: { contents: Array<{ ogImageUrl: string }> }; + second: { contents: Array<{ ogImageUrl: string }> }; + }; + assert.notEqual( + data.first.contents[0].ogImageUrl, + data.second.contents[0].ogImageUrl, + ); + }); +}); + +test("ArticleContent.ogImageUrl rejects bulk article list queries", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "articleogbulk", + name: "Article OG Bulk", + email: "articleogbulk@example.com", + }); + const disk = createOgTestDisk(); + const result = await execute({ + schema, + document: articleContentOgImageBulkQuery, + variableValues: { handle: author.account.username }, + contextValue: makeGuestContext(tx, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.deepEqual(toPlainJson(result.data), { actorByHandle: null }); + assert.match(result.errors?.[0]?.message ?? "", /Query exceeds Complexity/); + assert.deepEqual(disk.putKeys, []); + + const byLanguageResult = await execute({ + schema, + document: articleContentOgImageBulkByLanguageQuery, + variableValues: { handle: author.account.username }, + contextValue: makeGuestContext(tx, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.deepEqual(toPlainJson(byLanguageResult.data), { + actorByHandle: null, + }); + assert.match( + byLanguageResult.errors?.[0]?.message ?? "", + /Query exceeds Complexity/, + ); + assert.deepEqual(disk.putKeys, []); + }); +}); + +test("ArticleContent.ogImageUrl renders per-language article images", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "articleoggraphql", + name: "Article OG GraphQL", + email: "articleoggraphql@example.com", + }); + await tx.update(accountTable) + .set({ avatarKey: "article-avatar-og-test" }) + .where(eq(accountTable.id, author.account.id)); + const sourceId = generateUuidV7(); + const postId = generateUuidV7(); + const published = new Date("2026-04-15T00:00:00.000Z"); + + await tx.insert(articleSourceTable).values({ + id: sourceId, + accountId: author.account.id, + publishedYear: 2026, + slug: "og-article", + tags: [], + allowLlmTranslation: false, + published, + updated: published, + }); + await tx.insert(articleContentTable).values([ + { + sourceId, + language: "en", + title: "Open Graph article", + content: "English body with emoji 😀 and Korean 안녕하세요.", + ogImageKey: "og/v2/stale-article-en.png", + published, + updated: published, + }, + { + sourceId, + language: "ko-KR", + title: "오픈 그래프 글", + content: "한국어 본문과 English mixed script, emoji 😀.", + ogImageKey: "og/v2/stale-article-ko.png", + published, + updated: published, + }, + ]); + await tx.insert(postTable).values( + { + id: postId, + iri: `http://localhost/objects/${postId}`, + type: "Article", + visibility: "public", + actorId: author.actor.id, + articleSourceId: sourceId, + name: "Open Graph article", + contentHtml: "English body with emoji 😀 and Korean 안녕하세요.
", + language: "en", + tags: {}, + emojis: {}, + url: `http://localhost/@${author.account.username}/2026/og-article`, + published, + updated: published, + } satisfies NewPost, + ); + + const disk = createOgTestDisk(); + async function executeOgImageQuery(language: string) { + const result = await execute({ + schema, + document: articleContentOgImageUrlQuery, + variableValues: { + handle: author.account.username, + idOrYear: "2026", + slug: "og-article", + language, + }, + contextValue: makeUserContext(tx, author.account, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + assert.equal(result.errors, undefined); + const contents = (toPlainJson(result.data) as { + articleByYearAndSlug: { + contents: Array<{ language: string; ogImageUrl: string }>; + }; + }).articleByYearAndSlug.contents; + assert.equal(contents.length, 1); + return contents[0]; + } + + const firstContentsByLanguage = [ + await executeOgImageQuery("en"), + await executeOgImageQuery("ko-KR"), + ]; + assert.equal( + new Set(firstContentsByLanguage.map((c) => c.ogImageUrl)).size, + 2, + ); + assert.ok( + firstContentsByLanguage.every((content) => + /^http:\/\/localhost\/media\/og\/v2\/.+\.png$/.test( + content.ogImageUrl, + ) + ), + ); + assert.equal(disk.putKeys.length, 2); + assert.deepEqual(disk.deleteKeys, []); + + const stored = await tx.query.articleContentTable.findMany({ + where: { sourceId }, + orderBy: { language: "asc" }, + }); + assert.equal(stored.length, 2); + assert.ok( + stored.every((content) => content.ogImageKey?.startsWith("og/v2/")), + ); + + assert.deepEqual( + [ + await executeOgImageQuery("en"), + await executeOgImageQuery("ko-KR"), + ], + firstContentsByLanguage, + ); + assert.equal(disk.putKeys.length, 2); + assert.deepEqual(disk.deleteKeys, []); + }); +}); + test("articleByYearAndSlug returns a local article by route components", async () => { await withRollback(async (tx) => { const author = await insertAccountWithActor(tx, { diff --git a/graphql/post.ts b/graphql/post.ts index f067cff7d..1c2b6006e 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -1,7 +1,8 @@ -import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji"; -import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html"; -import { negotiateLocale } from "@hackerspub/models/i18n"; -import { renderMarkup } from "@hackerspub/models/markup"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { unreachable } from "@std/assert"; +import { assertNever } from "@std/assert/unstable-never"; +import { and, eq } from "drizzle-orm"; +import { getAvatarUrl } from "@hackerspub/models/account"; import { createArticle, deleteArticleDraft, @@ -14,6 +15,10 @@ import { deleteBookmark, isPostBookmarkedBy, } from "@hackerspub/models/bookmark"; +import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji"; +import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html"; +import { negotiateLocale } from "@hackerspub/models/i18n"; +import { renderMarkup } from "@hackerspub/models/markup"; import { createNote } from "@hackerspub/models/note"; import { isPostPinnedBy, @@ -30,30 +35,30 @@ import { } from "@hackerspub/models/post"; import { react, undoReaction } from "@hackerspub/models/reaction"; import { + articleContentTable, articleDraftTable, articleMediumTable, } from "@hackerspub/models/schema"; +import type * as schema from "@hackerspub/models/schema"; +import { withTransaction } from "@hackerspub/models/tx"; import { MAX_IMAGE_SIZE, SUPPORTED_IMAGE_TYPES, uploadImage, } from "@hackerspub/models/upload"; -import type * as schema from "@hackerspub/models/schema"; -import { withTransaction } from "@hackerspub/models/tx"; import { generateUuidV7 } from "@hackerspub/models/uuid"; -import { and, eq } from "drizzle-orm"; -import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; -import { unreachable } from "@std/assert"; -import { assertNever } from "@std/assert/unstable-never"; import { Account } from "./account.ts"; import { Actor } from "./actor.ts"; import { builder, Node } from "./builder.ts"; import { InvalidInputError } from "./error.ts"; import { lookupPostByUrl, parseHttpUrl } from "./lookup.ts"; +import { putArticleOgImage } from "./og.ts"; import { PostVisibility, toPostVisibility } from "./postvisibility.ts"; import { Reactable, Reaction } from "./reactable.ts"; import { NotAuthenticatedError } from "./session.ts"; +const articleContentOgImageComplexity = 2_000; + class SharedPostDeletionNotAllowedError extends Error { public constructor(public readonly inputPath: string) { super("Shared posts cannot be deleted. Use unsharePost instead."); @@ -274,6 +279,10 @@ export const Article = builder.drizzleNode("postTable", { defaultValue: false, }), }, + complexity: (args) => ({ + field: 1, + multiplier: args.language == null ? 10 : 1, + }), select: (args) => ({ with: { articleSource: { @@ -430,6 +439,64 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { beingTranslated: t.exposeBoolean("beingTranslated"), updated: t.expose("updated", { type: "DateTime" }), published: t.expose("published", { type: "DateTime" }), + ogImageUrl: t.field({ + type: "URL", + complexity: articleContentOgImageComplexity, + select: { + columns: { + content: true, + language: true, + ogImageKey: true, + sourceId: true, + summary: true, + title: true, + }, + with: { + source: { + with: { + account: { + with: { + actor: { + columns: { + handleHost: true, + }, + }, + emails: true, + }, + }, + }, + }, + }, + }, + async resolve(content, _, ctx) { + const account = content.source.account; + const rendered = await renderMarkup(ctx.fedCtx, content.content, { + kv: ctx.kv, + }); + const avatarUrl = await getAvatarUrl(ctx.disk, account); + const key = await putArticleOgImage(ctx.disk, content.ogImageKey, { + authorName: account.name, + avatarKey: account.avatarKey ?? avatarUrl, + avatarUrl, + excerpt: content.summary ?? rendered.text, + handle: `@${account.username}@${account.actor.handleHost}`, + language: content.language, + sourceId: content.sourceId, + title: content.title, + }); + if (key !== content.ogImageKey) { + await ctx.db.update(articleContentTable) + .set({ ogImageKey: key }) + .where( + and( + eq(articleContentTable.sourceId, content.sourceId), + eq(articleContentTable.language, content.language), + ), + ); + } + return new URL(await ctx.disk.getUrl(key)); + }, + }), url: t.field({ type: "URL", select: { diff --git a/graphql/schema.graphql b/graphql/schema.graphql index c56c9babf..3e9c4ba13 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -17,6 +17,7 @@ type Account implements Node { moderator: Boolean! name: String! notifications(after: String, before: String, first: Int, last: Int): AccountNotificationsConnection! + ogImageUrl: URL! passkeys(after: String, before: String, first: Int, last: Int): AccountPasskeysConnection! preferAiSummary: Boolean! updated: DateTime! @@ -323,6 +324,7 @@ type ArticleContent implements Node { content: HTML! id: ID! language: Locale! + ogImageUrl: URL! originalLanguage: Locale published: DateTime! diff --git a/web-next/src/routes/(root)/[handle]/(profile)/index.tsx b/web-next/src/routes/(root)/[handle]/(profile)/index.tsx index 2f61e1ba9..7167934f3 100644 --- a/web-next/src/routes/(root)/[handle]/(profile)/index.tsx +++ b/web-next/src/routes/(root)/[handle]/(profile)/index.tsx @@ -46,6 +46,7 @@ const ProfilePageQuery = graphql` username url iri + local viewerBlocks blocksViewer ...NavigateIfHandleIsNotCanonical_actor @@ -172,6 +173,16 @@ export default function ProfilePage() { property="og:title" content={actor().rawName ?? actor().username} /> +