diff --git a/graphql/account.test.ts b/graphql/account.test.ts index a7009af7f..f86526398 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -4,7 +4,9 @@ import { encodeGlobalID } from "@pothos/plugin-relay"; import * as vocab from "@fedify/vocab"; import { execute, parse } from "graphql"; import { updateAccountData } from "@hackerspub/models/account"; +import type { UserContext } from "./builder.ts"; import { schema } from "./mod.ts"; +import { putProfileOgImage } from "./og.ts"; import { createFedCtx, insertAccountWithActor, @@ -34,6 +36,22 @@ const accountByUsernameQuery = parse(` } `); +const accountOgImageUrlQuery = parse(` + query AccountOgImageUrl($username: String!) { + accountByUsername(username: $username) { + ogImageUrl + } + } +`); + +const accountsOgImageUrlQuery = parse(` + query AccountsOgImageUrl { + accounts { + ogImageUrl + } + } +`); + const invitationTreeQuery = parse(` query InvitationTree { invitationTree { @@ -62,6 +80,52 @@ const updateAccountMutation = parse(` } `); +const smallPngDataUrl = "data:image/png;base64," + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + +function createOgTestDisk(): { + disk: UserContext["disk"]; + putKeys: string[]; + deleteKeys: string[]; +} { + const putKeys: string[] = []; + const deleteKeys: string[] = []; + return { + putKeys, + deleteKeys, + disk: { + getUrl(key: string) { + if (key === "avatar-og-test") return Promise.resolve(smallPngDataUrl); + return Promise.resolve(`http://localhost/media/${key}`); + }, + put(key: string) { + putKeys.push(key); + return Promise.resolve(undefined); + }, + delete(key: string) { + deleteKeys.push(key); + return Promise.resolve(undefined); + }, + } as unknown as UserContext["disk"], + }; +} + +test("putProfileOgImage leaves existing cached images for the caller", async () => { + const disk = createOgTestDisk(); + + const key = await putProfileOgImage(disk.disk, "og/v2/stale-profile.png", { + avatarKey: "avatar-og-test", + avatarUrl: smallPngDataUrl, + bio: "Cached profile image should survive until metadata is updated.", + displayName: "Profile Cache Review", + handle: "@profilecache@localhost", + }); + + assert.match(key, /^og\/v2\/.+\.png$/); + assert.notEqual(key, "og/v2/stale-profile.png"); + assert.deepEqual(disk.deleteKeys, []); +}); + test("viewer returns the signed-in account and null for guests", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { @@ -101,6 +165,77 @@ test("viewer returns the signed-in account and null for guests", async () => { }); }); +test("Account.ogImageUrl renders and reuses a cached profile image", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "profileoggraphql", + name: "Profile OG GraphQL", + email: "profileoggraphql@example.com", + }); + const updated = await updateAccountData(tx, { + id: account.account.id, + avatarKey: "avatar-og-test", + bio: "Mixed script bio: Hello, 안녕하세요, こんにちは, 你好, 😀", + ogImageKey: "og/v2/stale-profile.png", + }); + assert.ok(updated != null); + + const disk = createOgTestDisk(); + const firstResult = await execute({ + schema, + document: accountOgImageUrlQuery, + variableValues: { username: account.account.username }, + contextValue: makeGuestContext(tx, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.equal(firstResult.errors, undefined); + const firstUrl = (toPlainJson(firstResult.data) as { + accountByUsername: { ogImageUrl: string }; + }).accountByUsername.ogImageUrl; + assert.match(firstUrl, /^http:\/\/localhost\/media\/og\/v2\/.+\.png$/); + assert.equal(disk.putKeys.length, 1); + assert.deepEqual(disk.deleteKeys, []); + + const stored = await tx.query.accountTable.findFirst({ + where: { id: account.account.id }, + }); + assert.ok(stored?.ogImageKey?.startsWith("og/v2/")); + + const secondResult = await execute({ + schema, + document: accountOgImageUrlQuery, + variableValues: { username: account.account.username }, + contextValue: makeGuestContext(tx, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.equal(secondResult.errors, undefined); + const secondUrl = (toPlainJson(secondResult.data) as { + accountByUsername: { ogImageUrl: string }; + }).accountByUsername.ogImageUrl; + assert.equal(secondUrl, firstUrl); + assert.equal(disk.putKeys.length, 1); + assert.deepEqual(disk.deleteKeys, []); + }); +}); + +test("Account.ogImageUrl rejects bulk account list queries", async () => { + await withRollback(async (tx) => { + const disk = createOgTestDisk(); + const result = await execute({ + schema, + document: accountsOgImageUrlQuery, + contextValue: makeGuestContext(tx, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.deepEqual(toPlainJson(result.data), { accounts: null }); + assert.match(result.errors?.[0]?.message ?? "", /Query exceeds Complexity/); + assert.deepEqual(disk.putKeys, []); + }); +}); + test("accountByUsername returns a local account by username", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { diff --git a/graphql/account.ts b/graphql/account.ts index c253a56f4..62d1c60c3 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -1,3 +1,10 @@ +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { + resolveCursorConnection, + type ResolveCursorConnectionArgs, +} from "@pothos/plugin-relay"; +import { assertNever } from "@std/assert/unstable-never"; +import { and, desc, eq, gt, lt, sql } from "drizzle-orm"; import { getAvatarUrl, transformAvatar, @@ -5,22 +12,17 @@ import { } from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; import type { Locale } from "@hackerspub/models/i18n"; +import { renderMarkup } from "@hackerspub/models/markup"; import { accountTable, actorTable, notificationTable, } from "@hackerspub/models/schema"; -import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; -import { - resolveCursorConnection, - type ResolveCursorConnectionArgs, -} from "@pothos/plugin-relay"; -import { assertNever } from "@std/assert/unstable-never"; -import { and, desc, eq, gt, lt, sql } from "drizzle-orm"; import { Actor } from "./actor.ts"; import { builder } from "./builder.ts"; import { InvitationLink } from "./invitation-link.ts"; import { Notification } from "./notification.ts"; +import { putProfileOgImage } from "./og.ts"; import { ArticleDraft } from "./post.ts"; import { fromPostVisibility, @@ -28,6 +30,8 @@ import { toPostVisibility, } from "./postvisibility.ts"; +const profileOgImageComplexity = 2_000; + export const Account = builder.drizzleNode("accountTable", { name: "Account", id: { @@ -74,6 +78,48 @@ export const Account = builder.drizzleNode("accountTable", { return new URL(url); }, }), + ogImageUrl: t.field({ + type: "URL", + complexity: profileOgImageComplexity, + select: { + columns: { + avatarKey: true, + bio: true, + id: true, + name: true, + ogImageKey: true, + username: true, + }, + with: { + actor: { + columns: { + handleHost: true, + }, + }, + emails: true, + }, + }, + async resolve(account, _, ctx) { + const avatarUrl = await getAvatarUrl(ctx.disk, account); + const bio = await renderMarkup(ctx.fedCtx, account.bio, { + kv: ctx.kv, + }); + const handle = `@${account.username}@${account.actor.handleHost}`; + const key = await putProfileOgImage(ctx.disk, account.ogImageKey, { + avatarKey: account.avatarKey ?? avatarUrl, + avatarUrl, + bio: bio.text, + displayName: account.name, + handle, + }); + if (key !== account.ogImageKey) { + await ctx.db.update(accountTable) + .set({ ogImageKey: key }) + .where(eq(accountTable.id, account.id)); + } + return new URL(await ctx.disk.getUrl(key)); + }, + }), locales: t.field({ type: ["Locale"], nullable: true, diff --git a/graphql/assets/README.md b/graphql/assets/README.md new file mode 100644 index 000000000..d6caff347 --- /dev/null +++ b/graphql/assets/README.md @@ -0,0 +1,5 @@ +Hackers' Pub visual identity assets in this directory are from: +https://github.com/hackers-pub/visual-identity + +Visual Identity of Hackers' Pub (c) 2025 Bak Eunji is licensed under Creative +Commons Attribution-ShareAlike 4.0 International. diff --git a/graphql/assets/pubnyan-normal-transparent.svg b/graphql/assets/pubnyan-normal-transparent.svg new file mode 100644 index 000000000..e62eb5581 --- /dev/null +++ b/graphql/assets/pubnyan-normal-transparent.svg @@ -0,0 +1,7853 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphql/og.test.ts b/graphql/og.test.ts new file mode 100644 index 000000000..b0af49d22 --- /dev/null +++ b/graphql/og.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Disk } from "flydrive"; +import { + loadImageDataUri, + putArticleOgImage, + putProfileOgImage, + truncateText, +} from "./og.ts"; + +const smallPngDataUrl = "data:image/png;base64," + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const smallPngBytes = Uint8Array.from( + atob(smallPngDataUrl.slice("data:image/png;base64,".length)), + (char) => char.charCodeAt(0), +); + +function createOgTestDisk() { + const putKeys: string[] = []; + return { + putKeys, + disk: { + put(key: string) { + putKeys.push(key); + return Promise.resolve(undefined); + }, + } as unknown as Disk, + }; +} + +test("truncateText preserves grapheme clusters", () => { + assert.equal(truncateText("Flags 👩‍💻👩‍💻", 7), "Flags…"); + assert.equal(truncateText("Cafe\u0301 au lait", 6), "Cafe\u0301…"); +}); + +test("loadImageDataUri returns data URIs unchanged", async () => { + assert.equal(await loadImageDataUri(smallPngDataUrl), smallPngDataUrl); +}); + +test("loadImageDataUri embeds remote images", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => + new Response(smallPngBytes, { + headers: { "content-type": "image/png" }, + })); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal(await loadImageDataUri(url), smallPngDataUrl); + } finally { + await server.shutdown(); + } +}); + +test("loadImageDataUri rejects non-image responses", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => + new Response("not an image", { + headers: { "content-type": "text/plain" }, + })); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.txt`; + assert.equal(await loadImageDataUri(url), smallPngDataUrl); + } finally { + await server.shutdown(); + } +}); + +test("loadImageDataUri falls back when image responses have no body", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => + new Response(null, { + headers: { "content-type": "image/png" }, + })); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal(await loadImageDataUri(url), smallPngDataUrl); + } finally { + await server.shutdown(); + } +}); + +test("loadImageDataUri rejects unsupported URL schemes", async () => { + assert.equal( + await loadImageDataUri("file:///tmp/avatar.png"), + smallPngDataUrl, + ); +}); + +test("loadImageDataUri falls back when remote images are too large", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => + new Response(Uint8Array.from([1, 2, 3]), { + headers: { "content-type": "image/png", "content-length": "3" }, + })); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal( + await loadImageDataUri(url, { maxBytes: 2 }), + smallPngDataUrl, + ); + } finally { + await server.shutdown(); + } +}); + +test("loadImageDataUri falls back when streamed images are too large", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2])); + controller.enqueue(Uint8Array.from([3])); + controller.close(); + }, + }), + { headers: { "content-type": "image/png" } }, + )); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal( + await loadImageDataUri(url, { maxBytes: 2 }), + smallPngDataUrl, + ); + } finally { + await server.shutdown(); + } +}); + +test("loadImageDataUri falls back when remote images time out", async () => { + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return new Response(smallPngBytes, { + headers: { "content-type": "image/png" }, + }); + }); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal( + await loadImageDataUri(url, { timeoutMs: 1 }), + smallPngDataUrl, + ); + } finally { + await server.shutdown(); + } +}); + +test("putProfileOgImage skips image fetches on cache hits", async () => { + const { disk, putKeys } = createOgTestDisk(); + const input = { + avatarKey: "avatar/profile.png", + avatarUrl: smallPngDataUrl, + bio: "Cached profile OG", + displayName: "Cached Profile", + handle: "@cached@localhost", + }; + const key = await putProfileOgImage(disk, null, input); + let requests = 0; + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, () => { + requests++; + return new Response(smallPngBytes, { + headers: { "content-type": "image/png" }, + }); + }); + try { + const url = `http://${server.addr.hostname}:${server.addr.port}/avatar.png`; + assert.equal( + await putProfileOgImage(disk, key, { ...input, avatarUrl: url }), + key, + ); + assert.equal(requests, 0); + assert.equal(putKeys.length, 1); + } finally { + await server.shutdown(); + } +}); + +test("putProfileOgImage keys avatars by stable identity", async () => { + const { disk, putKeys } = createOgTestDisk(); + const input = { + avatarKey: "avatar/profile.png", + avatarUrl: smallPngDataUrl, + bio: "Stable avatar cache identity", + displayName: "Stable Profile", + handle: "@stable@localhost", + }; + + const firstKey = await putProfileOgImage(disk, null, input); + const secondKey = await putProfileOgImage(disk, firstKey, { + ...input, + avatarUrl: "https://example.com/avatar.png?signature=changed", + }); + + assert.equal(secondKey, firstKey); + assert.equal(putKeys.length, 1); +}); + +test("putArticleOgImage keys avatars by stable identity", async () => { + const { disk, putKeys } = createOgTestDisk(); + const input = { + authorName: "Stable Author", + avatarKey: "avatar/article.png", + avatarUrl: smallPngDataUrl, + excerpt: "Stable article avatar cache identity", + handle: "@stable@localhost", + language: "en", + sourceId: "019de14c-1ef2-7728-99a8-60efa271a111", + title: "Stable article", + }; + + const firstKey = await putArticleOgImage(disk, null, input); + const secondKey = await putArticleOgImage(disk, firstKey, { + ...input, + avatarUrl: "https://example.com/avatar.png?signature=changed", + }); + + assert.equal(secondKey, firstKey); + assert.equal(putKeys.length, 1); +}); diff --git a/graphql/og.ts b/graphql/og.ts new file mode 100644 index 000000000..686dce224 --- /dev/null +++ b/graphql/og.ts @@ -0,0 +1,563 @@ +import { Resvg } from "@resvg/resvg-js"; +import { encodeBase64 } from "@std/encoding/base64"; +import { encodeHex } from "@std/encoding/hex"; +import { join } from "@std/path"; +import type { Disk } from "flydrive"; +import { canonicalize } from "json-canonicalize"; +import satori from "satori"; + +const OG_VERSION = "v2-5"; +const OG_NAMESPACE = "og/v2"; +const OG_SIZE = { width: 1200, height: 630 } as const; +const FALLBACK_IMAGE_DATA_URI = "data:image/png;base64," + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const MAX_REMOTE_IMAGE_BYTES = 2 * 1024 * 1024; +const REMOTE_IMAGE_TIMEOUT_MS = 3_000; + +type Weight = 400 | 600; +type FontStyle = "normal"; + +interface FontOptions { + data: ArrayBuffer; + name: string; + weight: Weight; + style: FontStyle; + lang?: string; +} + +type OgElement = { + type: string; + props: Record; +}; + +interface ProfileOgImageInput { + avatarKey: string; + avatarUrl: string; + bio: string; + displayName: string; + handle: string; +} + +interface ArticleOgImageInput { + authorName: string; + avatarKey: string; + avatarUrl: string; + excerpt: string; + handle: string; + language: string; + sourceId: string; + title: string; +} + +let fontsPromise: Promise | undefined; +let brandLogoDataUriPromise: Promise | undefined; + +async function loadFont(filename: string): Promise { + const data = await Deno.readFile(join( + import.meta.dirname!, + "..", + "web", + "fonts", + filename, + )); + return data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ); +} + +function loadFonts(): Promise { + fontsPromise ??= Promise.all([ + loadFont("NotoSans-Regular.ttf").then((data) => ({ + name: "Noto Sans", + data, + weight: 400 as const, + style: "normal" as const, + })), + loadFont("NotoSans-SemiBold.ttf").then((data) => ({ + name: "Noto Sans", + data, + weight: 600 as const, + style: "normal" as const, + })), + loadFont("NotoSansJP-Regular.ttf").then((data) => ({ + name: "Noto Sans JP", + data, + weight: 400 as const, + style: "normal" as const, + lang: "ja-JP", + })), + loadFont("NotoSansKR-Regular.ttf").then((data) => ({ + name: "Noto Sans KR", + data, + weight: 400 as const, + style: "normal" as const, + lang: "ko-KR", + })), + loadFont("NotoSansSC-Regular.ttf").then((data) => ({ + name: "Noto Sans SC", + data, + weight: 400 as const, + style: "normal" as const, + lang: "zh-CN", + })), + loadFont("NotoSansTC-Regular.ttf").then((data) => ({ + name: "Noto Sans TC", + data, + weight: 400 as const, + style: "normal" as const, + lang: "zh-TW", + })), + loadFont("NotoEmoji-Regular.ttf").then((data) => ({ + name: "Noto Emoji", + data, + weight: 400 as const, + style: "normal" as const, + })), + ]); + return fontsPromise; +} + +async function loadBrandLogoDataUri(): Promise { + brandLogoDataUriPromise ??= Deno.readFile( + join(import.meta.dirname!, "..", "web-next", "public", "logo-dark.svg"), + ).then((svg) => `data:image/svg+xml;base64,${encodeBase64(svg)}`); + return brandLogoDataUriPromise; +} + +interface ImageDataUriOptions { + maxBytes?: number; + timeoutMs?: number; +} + +export async function loadImageDataUri( + imageUrl: string, + options: ImageDataUriOptions = {}, +): Promise { + if (imageUrl.startsWith("data:")) return imageUrl; + let url: URL; + try { + url = new URL(imageUrl); + } catch { + return FALLBACK_IMAGE_DATA_URI; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return FALLBACK_IMAGE_DATA_URI; + } + const maxBytes = options.maxBytes ?? MAX_REMOTE_IMAGE_BYTES; + const timeoutMs = options.timeoutMs ?? REMOTE_IMAGE_TIMEOUT_MS; + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) return FALLBACK_IMAGE_DATA_URI; + const contentLength = response.headers.get("content-length"); + if ( + contentLength != null && + Number.parseInt(contentLength, 10) > maxBytes + ) { + return FALLBACK_IMAGE_DATA_URI; + } + const contentType = response.headers.get("content-type")?.split(";")[0] ?? + "application/octet-stream"; + if (!contentType.startsWith("image/")) return FALLBACK_IMAGE_DATA_URI; + const bytes = await readResponseBytes(response, maxBytes); + if (bytes == null) return FALLBACK_IMAGE_DATA_URI; + return `data:${contentType};base64,${encodeBase64(bytes)}`; + } catch { + return FALLBACK_IMAGE_DATA_URI; + } +} + +async function readResponseBytes( + response: Response, + maxBytes: number, +): Promise { + if (response.body == null) return null; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + if (total === 0) return null; + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function h( + type: string, + props: Record | null, + ...children: unknown[] +): OgElement { + return { + type, + props: { + ...(props ?? {}), + children: children.length === 1 ? children[0] : children, + }, + }; +} + +export function truncateText(text: string, maxLength: number): string { + const compact = text.replace(/\s+/g, " ").trim(); + const graphemes = typeof Intl.Segmenter === "function" + ? Array.from( + new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment( + compact, + ), + ({ segment }) => segment, + ) + : Array.from(compact); + if (graphemes.length <= maxLength) return compact; + return `${graphemes.slice(0, maxLength - 1).join("").trimEnd()}…`; +} + +function brandFooter(logo: string): OgElement { + return h( + "div", + { + style: { + alignItems: "center", + background: "#000000", + bottom: 0, + display: "flex", + flexDirection: "row", + height: "114px", + justifyContent: "flex-start", + left: 0, + padding: "0 82px", + position: "absolute", + right: 0, + }, + }, + h("img", { + src: logo, + width: 316, + height: 81, + style: { objectFit: "contain" }, + }), + ); +} + +async function profileOgElement( + input: ProfileOgImageInput, +): Promise { + const [logo, avatar] = await Promise.all([ + loadBrandLogoDataUri(), + loadImageDataUri(input.avatarUrl), + ]); + const bio = truncateText(input.bio, 170); + return h( + "div", + { + style: { + width: "1200px", + height: "630px", + background: "#ffffff", + color: "#111111", + display: "flex", + flexDirection: "column", + fontFamily: + "Noto Sans, Noto Sans JP, Noto Sans KR, Noto Sans SC, Noto Sans TC, Noto Emoji", + position: "relative", + }, + }, + h( + "div", + { + style: { + display: "flex", + flexDirection: "row", + gap: "46px", + padding: "76px 82px 62px", + width: "1200px", + height: "516px", + }, + }, + h("img", { + src: avatar, + width: 172, + height: 172, + style: { + borderRadius: "86px", + border: "1px solid #d4d4d4", + objectFit: "cover", + flexShrink: 0, + }, + }), + h( + "div", + { + style: { + display: "flex", + flexDirection: "column", + minWidth: 0, + paddingTop: "4px", + width: "800px", + }, + }, + h( + "div", + { + style: { + fontSize: "60px", + fontWeight: 600, + lineHeight: 1.16, + letterSpacing: "0", + maxHeight: "150px", + }, + }, + truncateText(input.displayName, 44), + ), + h( + "div", + { + style: { + color: "#737373", + fontSize: "31px", + lineHeight: 1.25, + marginTop: "18px", + }, + }, + input.handle, + ), + h( + "div", + { + style: { + color: "#262626", + display: bio === "" ? "none" : "flex", + fontSize: "34px", + lineHeight: 1.42, + marginTop: "38px", + maxHeight: "198px", + whiteSpace: "pre-wrap", + }, + }, + bio, + ), + ), + ), + brandFooter(logo), + ); +} + +async function articleOgElement( + input: ArticleOgImageInput, +): Promise { + const [logo, avatar] = await Promise.all([ + loadBrandLogoDataUri(), + loadImageDataUri(input.avatarUrl), + ]); + const excerpt = truncateText(input.excerpt, 132); + return h( + "div", + { + style: { + width: "1200px", + height: "630px", + background: "#ffffff", + color: "#111111", + display: "flex", + flexDirection: "column", + fontFamily: + "Noto Sans, Noto Sans JP, Noto Sans KR, Noto Sans SC, Noto Sans TC, Noto Emoji", + position: "relative", + }, + }, + h( + "div", + { + style: { + display: "flex", + flexDirection: "column", + padding: "68px 82px 56px", + width: "1200px", + height: "516px", + }, + }, + h( + "div", + { + style: { + alignItems: "center", + display: "flex", + flexDirection: "row", + gap: "22px", + height: "92px", + }, + }, + h("img", { + src: avatar, + width: 82, + height: 82, + style: { + borderRadius: "41px", + border: "1px solid #d4d4d4", + objectFit: "cover", + flexShrink: 0, + }, + }), + h( + "div", + { + style: { + display: "flex", + flexDirection: "column", + minWidth: 0, + }, + }, + h( + "div", + { + style: { + fontSize: "32px", + fontWeight: 600, + lineHeight: 1.15, + }, + }, + truncateText(input.authorName, 52), + ), + h( + "div", + { + style: { + color: "#737373", + fontSize: "23px", + lineHeight: 1.2, + marginTop: "7px", + }, + }, + input.handle, + ), + ), + ), + h( + "div", + { + lang: input.language, + style: { + fontSize: "58px", + fontWeight: 600, + lineHeight: 1.22, + letterSpacing: "0", + marginTop: "40px", + maxHeight: "216px", + width: "1018px", + }, + }, + truncateText(input.title, 78), + ), + h( + "div", + { + lang: input.language, + style: { + color: "#404040", + display: excerpt === "" ? "none" : "flex", + fontSize: "30px", + lineHeight: 1.42, + marginTop: "24px", + maxHeight: "88px", + whiteSpace: "pre-wrap", + width: "1018px", + }, + }, + excerpt, + ), + ), + brandFooter(logo), + ); +} + +async function renderPng(element: OgElement): Promise { + const svg = await satori(element as Parameters[0], { + ...OG_SIZE, + fonts: await loadFonts(), + }); + return new Resvg(svg, { + fitTo: { + mode: "width", + value: OG_SIZE.width, + }, + }).render().asPng(); +} + +async function putOgImage( + disk: Disk, + existingKey: string | null | undefined, + input: unknown, + createElement: () => Promise, +): Promise { + const canonicalInput = canonicalize({ + version: OG_VERSION, + size: OG_SIZE, + input, + }); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(canonicalInput), + ); + const key = `${OG_NAMESPACE}/${encodeHex(digest)}.png`; + if (existingKey === key) return key; + const png = await renderPng(await createElement()); + await disk.put(key, png); + return key; +} + +export async function putProfileOgImage( + disk: Disk, + existingKey: string | null | undefined, + input: ProfileOgImageInput, +): Promise { + const { avatarUrl: _avatarUrl, ...cacheInput } = input; + return await putOgImage( + disk, + existingKey, + { type: "profile", ...cacheInput }, + () => profileOgElement(input), + ); +} + +export async function putArticleOgImage( + disk: Disk, + existingKey: string | null | undefined, + input: ArticleOgImageInput, +): Promise { + const { avatarUrl: _avatarUrl, ...cacheInput } = input; + return await putOgImage( + disk, + existingKey, + { type: "article", ...cacheInput }, + () => articleOgElement(input), + ); +} + +export async function renderProfileOgImageForPreview( + input: ProfileOgImageInput, +): Promise { + return await renderPng(await profileOgElement(input)); +} + +export async function renderArticleOgImageForPreview( + input: ArticleOgImageInput, +): Promise { + return await renderPng(await articleOgElement(input)); +} diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 7eb52997e..4bab7ab4b 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { encodeGlobalID } from "@pothos/plugin-relay"; +import { eq } from "drizzle-orm"; import { execute, parse } from "graphql"; import type { UserContext } from "./builder.ts"; import { + accountTable, articleContentTable, articleDraftTable, articleSourceTable, @@ -16,6 +18,7 @@ import { createFedCtx, insertAccountWithActor, insertNotePost, + makeGuestContext, makeUserContext, toPlainJson, withRollback, @@ -83,6 +86,74 @@ const articleByYearAndSlugQuery = parse(` } `); +const articleContentOgImageUrlQuery = parse(` + query ArticleContentOgImageUrl( + $handle: String! + $idOrYear: String! + $slug: String! + $language: Locale! + ) { + articleByYearAndSlug(handle: $handle, idOrYear: $idOrYear, slug: $slug) { + contents(language: $language) { + language + ogImageUrl + } + } + } +`); + +const articleContentOgImageBulkQuery = parse(` + query ArticleContentOgImageBulk($handle: String!) { + actorByHandle(handle: $handle, allowLocalHandle: true) { + articles(first: 20) { + edges { + node { + contents { + ogImageUrl + } + } + } + } + } + } +`); + +const articleContentOgImageBulkByLanguageQuery = parse(` + query ArticleContentOgImageBulkByLanguage($handle: String!) { + actorByHandle(handle: $handle, allowLocalHandle: true) { + articles(first: 20) { + edges { + node { + contents(language: "en") { + ogImageUrl + } + } + } + } + } + } +`); + +const articleContentOgImageCollisionQuery = parse(` + query ArticleContentOgImageCollision( + $handle: String! + $idOrYear: String! + $firstSlug: String! + $secondSlug: String! + ) { + first: articleByYearAndSlug(handle: $handle, idOrYear: $idOrYear, slug: $firstSlug) { + contents(language: "en") { + ogImageUrl + } + } + second: articleByYearAndSlug(handle: $handle, idOrYear: $idOrYear, slug: $secondSlug) { + contents(language: "en") { + ogImageUrl + } + } + } +`); + const createNoteMutation = parse(` mutation CreateNote($input: CreateNoteInput!) { createNote(input: $input) { @@ -119,6 +190,38 @@ const postByUrlQuery = parse(` } `); +const smallPngDataUrl = "data:image/png;base64," + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + +function createOgTestDisk(): { + disk: UserContext["disk"]; + putKeys: string[]; + deleteKeys: string[]; +} { + const putKeys: string[] = []; + const deleteKeys: string[] = []; + return { + putKeys, + deleteKeys, + disk: { + getUrl(key: string) { + if (key === "article-avatar-og-test") { + return Promise.resolve(smallPngDataUrl); + } + return Promise.resolve(`http://localhost/media/${key}`); + }, + put(key: string) { + putKeys.push(key); + return Promise.resolve(undefined); + }, + delete(key: string) { + deleteKeys.push(key); + return Promise.resolve(undefined); + }, + } as unknown as UserContext["disk"], + }; +} + function makeTransactionalUserContext( tx: Parameters[0] extends (tx: infer T) => Promise ? T @@ -291,6 +394,253 @@ test("publishArticleDraft publishes an article and removes the draft", async () }); }); +test("ArticleContent.ogImageUrl keys do not collide across articles", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "articleogcollision", + name: "Article OG Collision", + email: "articleogcollision@example.com", + }); + await tx.update(accountTable) + .set({ avatarKey: "article-avatar-og-test" }) + .where(eq(accountTable.id, author.account.id)); + const published = new Date("2026-04-15T00:00:00.000Z"); + + const slugs = ["same-preview-a", "same-preview-b"]; + for (const slug of slugs) { + const sourceId = generateUuidV7(); + const postId = generateUuidV7(); + await tx.insert(articleSourceTable).values({ + id: sourceId, + accountId: author.account.id, + publishedYear: 2026, + slug, + tags: [], + allowLlmTranslation: false, + published, + updated: published, + }); + await tx.insert(articleContentTable).values({ + sourceId, + language: "en", + title: "Same Open Graph preview", + content: "Identical article body for cache key collision coverage.", + 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: "Same Open Graph preview", + contentHtml: + "

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} /> + + {(ogImageUrl) => ( + <> + + + + + + )} +
@@ -224,3 +235,13 @@ export default function ProfilePage() { ); } + +function profileOgImageUrl(actor: { + readonly local: boolean; + readonly url: string | null | undefined; +}) { + if (!actor.local || actor.url == null) return undefined; + const url = new URL(actor.url); + url.pathname = `${url.pathname.replace(/\/$/, "")}/og`; + return url.toString(); +} diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx index 126f508d7..9adf347a2 100644 --- a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx @@ -135,6 +135,7 @@ function ArticleMetaHead(props: ArticleMetaHeadProps) { } language iri + url published updated hashtags { @@ -159,6 +160,18 @@ function ArticleMetaHead(props: ArticleMetaHeadProps) { + + {(ogImageUrl) => ( + <> + + + + + )} + + + + { + const url = new URL(ogImageUrl); + url.searchParams.set("l", content.language); + return url.toString(); + }); +} + interface ArticleBodyProps { $article: Slug_body$key; $viewer?: Slug_viewer$key; diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/ogimage.tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/ogimage.tsx new file mode 100644 index 000000000..8a19df7a1 --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/ogimage.tsx @@ -0,0 +1,95 @@ +import type { RouteDefinition } from "@solidjs/router"; +import type { APIEvent } from "@solidjs/start/server"; +import type { IEnvironment } from "relay-runtime"; +import { fetchQuery, graphql } from "relay-runtime"; +import { createEnvironment } from "../../../../../RelayEnvironment.tsx"; +import type { ogimageLanguageQuery } from "./__generated__/ogimageLanguageQuery.graphql.ts"; +import type { ogimageQuery } from "./__generated__/ogimageQuery.graphql.ts"; + +export const route = { + matchFilters: { + handle: /^@[^@]+$/, + }, +} satisfies RouteDefinition; + +export async function GET({ params, request }: APIEvent) { + const { handle, idOrYear, slug } = params; + if (!handle || !idOrYear || !slug) { + return new Response("Not Found", { status: 404 }); + } + + const requestUrl = new URL(request.url); + const environment = createEnvironment(); + const requestedLanguage = requestUrl.searchParams.get("l")?.trim(); + const language = requestedLanguage || + await getDefaultLanguage(environment, handle, idOrYear, slug); + if (language == null) { + return new Response("Not Found", { status: 404 }); + } + + const response = await fetchQuery( + environment, + graphql` + query ogimageQuery( + $handle: String! + $idOrYear: String! + $slug: String! + $language: Locale + ) { + articleByYearAndSlug( + handle: $handle + idOrYear: $idOrYear + slug: $slug + ) { + contents(language: $language) { + ogImageUrl + } + } + } + `, + { handle, idOrYear, slug, language }, + ).toPromise(); + + const ogImageUrl = response?.articleByYearAndSlug?.contents[0]?.ogImageUrl; + if (ogImageUrl == null) { + return new Response("Not Found", { status: 404 }); + } + + return Response.redirect(ogImageUrl, 302); +} + +async function getDefaultLanguage( + environment: IEnvironment, + handle: string, + idOrYear: string, + slug: string, +) { + const response = await fetchQuery( + environment, + graphql` + query ogimageLanguageQuery( + $handle: String! + $idOrYear: String! + $slug: String! + ) { + articleByYearAndSlug( + handle: $handle + idOrYear: $idOrYear + slug: $slug + ) { + language + contents { + language + } + } + } + `, + { handle, idOrYear, slug }, + ).toPromise(); + + const article = response?.articleByYearAndSlug; + if (article == null) return null; + const contentLanguages = article.contents.map((content) => content.language); + return contentLanguages.find((language) => language === article.language) ?? + contentLanguages[0] ?? null; +} diff --git a/web-next/src/routes/(root)/[handle]/og.tsx b/web-next/src/routes/(root)/[handle]/og.tsx new file mode 100644 index 000000000..851fa76a4 --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/og.tsx @@ -0,0 +1,37 @@ +import type { RouteDefinition } from "@solidjs/router"; +import type { APIEvent } from "@solidjs/start/server"; +import { fetchQuery, graphql } from "relay-runtime"; +import { createEnvironment } from "../../../RelayEnvironment.tsx"; +import type { ogQuery } from "./__generated__/ogQuery.graphql.ts"; + +export const route = { + matchFilters: { + handle: /^@[^@]+$/, + }, +} satisfies RouteDefinition; + +export async function GET({ params }: APIEvent) { + const { handle } = params; + if (!handle) { + return new Response("Not Found", { status: 404 }); + } + + const response = await fetchQuery( + createEnvironment(), + graphql` + query ogQuery($username: String!) { + accountByUsername(username: $username) { + ogImageUrl + } + } + `, + { username: handle.slice(1) }, + ).toPromise(); + + const ogImageUrl = response?.accountByUsername?.ogImageUrl; + if (ogImageUrl == null) { + return new Response("Not Found", { status: 404 }); + } + + return Response.redirect(ogImageUrl, 302); +}