From 28439aa35ca744606da73a2621c959503c716f05 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 15 May 2026 20:17:51 +0300 Subject: [PATCH 1/2] Move social share avatar to public folder --- public/assets/images/kevin-brown.webp | Bin 0 -> 4174 bytes .../api/social-card/__tests__/index.spec.ts | 46 +++++++++++++++--- src/pages/api/social-card/index.ts | 36 +++++++++----- 3 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 public/assets/images/kevin-brown.webp diff --git a/public/assets/images/kevin-brown.webp b/public/assets/images/kevin-brown.webp new file mode 100644 index 0000000000000000000000000000000000000000..cf918db25fa9916c800566ee48dfde43e1490d73 GIT binary patch literal 4174 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 ({ - default: { - src: '/_astro/kevin-brown.test.webp', - fsPath: '/virtual/assets/kevin-brown.webp', - }, +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const accessMock = vi.hoisted(() => vi.fn()) +const mkdirMock = vi.hoisted(() => vi.fn()) +const writeFileMock = vi.hoisted(() => vi.fn()) +const fetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:fs/promises', () => ({ + access: accessMock, + mkdir: mkdirMock, + writeFile: writeFileMock, })) type CollectionFixture = Array<{ @@ -73,9 +79,23 @@ const seedCollections = () => { } describe('Social Card API - GET /api/social-card', () => { + const expectedAvatarPath = join(tmpdir(), 'webstackbuilders-social-cards', 'kevin-brown.webp') + beforeEach(() => { generateOpenGraphImageMock.mockReset() generateOpenGraphImageMock.mockResolvedValue(Buffer.from('mock-image')) + accessMock.mockReset() + mkdirMock.mockReset() + writeFileMock.mockReset() + fetchMock.mockReset() + accessMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + mkdirMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + fetchMock.mockResolvedValue({ + ok: true, + arrayBuffer: vi.fn(async () => Uint8Array.from([1, 2, 3]).buffer), + }) + vi.stubGlobal('fetch', fetchMock) seedCollections() }) @@ -133,10 +153,22 @@ describe('Social Card API - GET /api/social-card', () => { expect(generateOpenGraphImageMock).toHaveBeenCalledWith( expect.objectContaining({ logo: expect.objectContaining({ - path: '/virtual/assets/kevin-brown.webp', + path: expectedAvatarPath, }), }) ) + + expect(fetchMock).toHaveBeenCalledWith(new URL('/assets/images/kevin-brown.webp', 'http://localhost/api/social-card?slug=home')) + expect(writeFileMock).toHaveBeenCalledWith(expectedAvatarPath, expect.any(Buffer)) + }) + + it('reuses the cached avatar file when it already exists locally', async () => { + accessMock.mockResolvedValueOnce(undefined) + + await buildRequest('http://localhost/api/social-card?slug=home') + + expect(fetchMock).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() }) it('returns an error response when generation fails', async () => { diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index 11c22dd1f..667b19465 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -1,21 +1,22 @@ import type { APIRoute } from 'astro' import { getCollection } from 'astro:content' import { generateOpenGraphImage } from 'astro-og-canvas' -import kevinBrownAvatar from '@assets/images/avatars/kevin-brown.webp' +import { access, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false const ROUTE = '/api/social-card' +const PUBLIC_AVATAR_PATH = '/assets/images/kevin-brown.webp' +const CACHED_AVATAR_DIRECTORY = join(tmpdir(), 'webstackbuilders-social-cards') +const CACHED_AVATAR_PATH = join(CACHED_AVATAR_DIRECTORY, 'kevin-brown.webp') const DEFAULT_TITLE = 'Platform Engineering by Kevin Brown' const DEFAULT_DESCRIPTION = 'Platform engineer helping teams harden delivery, modernize cloud platforms, and improve developer experience.' -type ImageMetadataWithFsPath = typeof kevinBrownAvatar & { - fsPath?: string -} - type CollectionKey = 'articles' | 'caseStudies' | 'services' | 'downloads' type PaletteKey = 'articles' | 'case-studies' | 'services' | 'downloads' | 'default' @@ -60,14 +61,27 @@ const gradientPalette: Record = { ], } -const getAvatarFilePath = (): string => { - const avatarFilePath = (kevinBrownAvatar as ImageMetadataWithFsPath).fsPath +const getAvatarFilePath = async (requestUrl: string): Promise => { + try { + await access(CACHED_AVATAR_PATH) + return CACHED_AVATAR_PATH + } catch { + // Cache miss; fetch from the public asset URL and persist locally for astro-og-canvas. + } - if (!avatarFilePath) { - throw new Error('Kevin Brown avatar asset is missing a local file path') + const avatarUrl = new URL(PUBLIC_AVATAR_PATH, requestUrl) + const response = await fetch(avatarUrl) + + if (!response.ok) { + throw new Error(`Unable to fetch Kevin Brown avatar from ${avatarUrl.toString()}`) } - return avatarFilePath + const avatarBuffer = Buffer.from(await response.arrayBuffer()) + + await mkdir(CACHED_AVATAR_DIRECTORY, { recursive: true }) + await writeFile(CACHED_AVATAR_PATH, avatarBuffer) + + return CACHED_AVATAR_PATH } /** Normalize slug parameters to a consistent format */ @@ -133,7 +147,7 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const slug = normalizeSlug(url.searchParams.get('slug')) const titleOverride = url.searchParams.get('title') const descriptionOverride = url.searchParams.get('description') - const avatarPath = getAvatarFilePath() + const avatarPath = await getAvatarFilePath(request.url) const contentIndex = await buildContentIndex() const matchedEntry = contentIndex[slug] From 924145aae0c6196c7800117c91b2a16feaec6bf0 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 15 May 2026 20:45:39 +0300 Subject: [PATCH 2/2] Fix CodeQL errors --- .../api/social-card/__tests__/index.spec.ts | 42 ++----------------- src/pages/api/social-card/index.ts | 36 +++------------- 2 files changed, 9 insertions(+), 69 deletions(-) diff --git a/src/pages/api/social-card/__tests__/index.spec.ts b/src/pages/api/social-card/__tests__/index.spec.ts index 688e1b2a2..824e6d444 100644 --- a/src/pages/api/social-card/__tests__/index.spec.ts +++ b/src/pages/api/social-card/__tests__/index.spec.ts @@ -1,18 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { GET } from '@pages/api/social-card' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const accessMock = vi.hoisted(() => vi.fn()) -const mkdirMock = vi.hoisted(() => vi.fn()) -const writeFileMock = vi.hoisted(() => vi.fn()) -const fetchMock = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs/promises', () => ({ - access: accessMock, - mkdir: mkdirMock, - writeFile: writeFileMock, -})) +import { fileURLToPath } from 'node:url' type CollectionFixture = Array<{ id: string @@ -79,23 +67,13 @@ const seedCollections = () => { } describe('Social Card API - GET /api/social-card', () => { - const expectedAvatarPath = join(tmpdir(), 'webstackbuilders-social-cards', 'kevin-brown.webp') + const expectedAvatarPath = fileURLToPath( + new URL('../../../../assets/images/avatars/kevin-brown.webp', import.meta.url) + ) beforeEach(() => { generateOpenGraphImageMock.mockReset() generateOpenGraphImageMock.mockResolvedValue(Buffer.from('mock-image')) - accessMock.mockReset() - mkdirMock.mockReset() - writeFileMock.mockReset() - fetchMock.mockReset() - accessMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - mkdirMock.mockResolvedValue(undefined) - writeFileMock.mockResolvedValue(undefined) - fetchMock.mockResolvedValue({ - ok: true, - arrayBuffer: vi.fn(async () => Uint8Array.from([1, 2, 3]).buffer), - }) - vi.stubGlobal('fetch', fetchMock) seedCollections() }) @@ -157,18 +135,6 @@ describe('Social Card API - GET /api/social-card', () => { }), }) ) - - expect(fetchMock).toHaveBeenCalledWith(new URL('/assets/images/kevin-brown.webp', 'http://localhost/api/social-card?slug=home')) - expect(writeFileMock).toHaveBeenCalledWith(expectedAvatarPath, expect.any(Buffer)) - }) - - it('reuses the cached avatar file when it already exists locally', async () => { - accessMock.mockResolvedValueOnce(undefined) - - await buildRequest('http://localhost/api/social-card?slug=home') - - expect(fetchMock).not.toHaveBeenCalled() - expect(writeFileMock).not.toHaveBeenCalled() }) it('returns an error response when generation fails', async () => { diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index 667b19465..cc63069bb 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -1,18 +1,16 @@ import type { APIRoute } from 'astro' import { getCollection } from 'astro:content' import { generateOpenGraphImage } from 'astro-og-canvas' -import { access, mkdir, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false const ROUTE = '/api/social-card' -const PUBLIC_AVATAR_PATH = '/assets/images/kevin-brown.webp' -const CACHED_AVATAR_DIRECTORY = join(tmpdir(), 'webstackbuilders-social-cards') -const CACHED_AVATAR_PATH = join(CACHED_AVATAR_DIRECTORY, 'kevin-brown.webp') +const AVATAR_FILE_PATH = fileURLToPath( + new URL('../../../assets/images/avatars/kevin-brown.webp', import.meta.url) +) const DEFAULT_TITLE = 'Platform Engineering by Kevin Brown' const DEFAULT_DESCRIPTION = 'Platform engineer helping teams harden delivery, modernize cloud platforms, and improve developer experience.' @@ -61,29 +59,6 @@ const gradientPalette: Record = { ], } -const getAvatarFilePath = async (requestUrl: string): Promise => { - try { - await access(CACHED_AVATAR_PATH) - return CACHED_AVATAR_PATH - } catch { - // Cache miss; fetch from the public asset URL and persist locally for astro-og-canvas. - } - - const avatarUrl = new URL(PUBLIC_AVATAR_PATH, requestUrl) - const response = await fetch(avatarUrl) - - if (!response.ok) { - throw new Error(`Unable to fetch Kevin Brown avatar from ${avatarUrl.toString()}`) - } - - const avatarBuffer = Buffer.from(await response.arrayBuffer()) - - await mkdir(CACHED_AVATAR_DIRECTORY, { recursive: true }) - await writeFile(CACHED_AVATAR_PATH, avatarBuffer) - - return CACHED_AVATAR_PATH -} - /** Normalize slug parameters to a consistent format */ const normalizeSlug = (value: string | null): string => { if (!value) return 'home' @@ -147,7 +122,6 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const slug = normalizeSlug(url.searchParams.get('slug')) const titleOverride = url.searchParams.get('title') const descriptionOverride = url.searchParams.get('description') - const avatarPath = await getAvatarFilePath(request.url) const contentIndex = await buildContentIndex() const matchedEntry = contentIndex[slug] @@ -167,7 +141,7 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { bgGradient: gradientPalette[palette], padding: 80, logo: { - path: avatarPath, + path: AVATAR_FILE_PATH, size: [140, 140], }, font: {