diff --git a/app/api/stories/[uuid]/pdf/route.ts b/app/api/stories/[uuid]/pdf/route.ts new file mode 100644 index 000000000..f33a565ba --- /dev/null +++ b/app/api/stories/[uuid]/pdf/route.ts @@ -0,0 +1,65 @@ +import { routing } from '@prezly/sdk'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { environment, logApiAuthFailure } from '@/adapters/server'; + +// Default when the `X-Prezly-Env` tenant config does not override it via +// `PREZLY_API_BASEURL` (same convention as the SDK adapter). +const PREZLY_API_URL = 'https://api.prezly.com'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface Props { + params: Promise<{ uuid: string }>; +} + +export async function GET(request: NextRequest, { params }: Props) { + const { uuid } = await params; + + if (!UUID_PATTERN.test(uuid)) { + return new NextResponse('Bad Request', { status: 400 }); + } + + const env = environment(request.headers); + + const { PREZLY_ACCESS_TOKEN, PREZLY_API_BASEURL = PREZLY_API_URL } = env; + const STORIES_ENDPOINT = `${PREZLY_API_BASEURL}${routing.storiesUrl}`; + + const response = await fetch(`${STORIES_ENDPOINT}/${uuid}`, { + headers: { + Authorization: `Bearer ${PREZLY_ACCESS_TOKEN}`, + Accept: 'application/pdf', + }, + redirect: 'manual', + }); + + if (response.status === 401 || response.status === 403) { + logApiAuthFailure(response.status, request.headers); + } + + const url = parsePdfUrl(response.headers.get('location')); + + if (!url) { + // On success the API responds with a redirect (3xx + Location) to the + // rendered PDF. Anything else is an upstream failure: pass a missing + // story through as 404; everything else (including auth failures, + // which are a tenant configuration problem, not the visitor's) is a + // bad gateway. + return new NextResponse(null, { status: response.status === 404 ? 404 : 502 }); + } + + return NextResponse.json({ url }); +} + +function parsePdfUrl(location: string | null): string | null { + if (!location) { + return null; + } + + try { + const url = new URL(location); + return url.protocol === 'https:' ? url.toString() : null; + } catch { + return null; + } +} diff --git a/middleware.ts b/middleware.ts index d663e28b6..34b3fab88 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,8 @@ import { Locale } from '@prezly/theme-kit-nextjs'; import { IntlMiddleware } from '@prezly/theme-kit-nextjs/middleware'; -import type { NextRequest } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; -import { configureAppRouter, initPrezlyClient } from '@/adapters/server'; +import { configureAppRouter, initPrezlyClient, isApiAuthError } from '@/adapters/server'; function parseNewsroomLocalesFromHeaders(headers: Headers): Locale.Code[] | undefined { const header = headers.get('X-Newsroom-Locales'); @@ -39,9 +39,24 @@ async function retrieveNewsroomLocalesFromApi(headers: Headers) { } export async function middleware(request: NextRequest) { - const locales = - parseNewsroomLocalesFromHeaders(request.headers) ?? - (await retrieveNewsroomLocalesFromApi(request.headers)); + let locales: Locale.Code[]; + + try { + locales = + parseNewsroomLocalesFromHeaders(request.headers) ?? + (await retrieveNewsroomLocalesFromApi(request.headers)); + } catch (error) { + // A rejected tenant token means every upstream API call will fail — bail out + // before rendering starts. 503 (not 404) so search engines treat the outage + // as temporary instead of deindexing a site that is only misconfigured. + if (isApiAuthError(error)) { + return new NextResponse('Service Temporarily Unavailable', { + status: 503, + headers: { 'retry-after': '300' }, + }); + } + throw error; + } const [defaultLocale] = locales; // default is expected to always be the first in the list diff --git a/next-logger.config.js b/next-logger.config.js new file mode 100644 index 000000000..18a9215e1 --- /dev/null +++ b/next-logger.config.js @@ -0,0 +1,34 @@ +const pino = require('pino'); + +/** + * The Prezly SDK attaches the full API response headers (including a multi-KB + * CSP policy) to every `ApiError` it throws. They carry no diagnostic signal + * but multiply the size of every logged error, so strip them before the error + * is serialized. Scoped to HTTP-response-shaped errors (`status` + `headers`) + * so headers carried by unrelated error types are left untouched. + */ +function serializeError(error) { + const serialized = pino.stdSerializers.err(error); + + if ( + serialized && + typeof serialized === 'object' && + 'headers' in serialized && + 'status' in serialized + ) { + const { headers, ...rest } = serialized; + return rest; + } + + return serialized; +} + +const logger = (defaultConfig) => + pino({ + ...defaultConfig, + serializers: { + err: serializeError, + }, + }); + +module.exports = { logger }; diff --git a/src/adapters/server/api-error.ts b/src/adapters/server/api-error.ts new file mode 100644 index 000000000..a19293704 --- /dev/null +++ b/src/adapters/server/api-error.ts @@ -0,0 +1,114 @@ +import { environment } from './environment'; + +/* + * Rejected tenant credentials (revoked or rotated newsroom token) surface as + * SDK `ApiError`s on every request, but the framework-logged error does not + * identify the tenant. The helpers here detect those errors and log them with + * the request host and newsroom UUID, so the broken tenant can be found from + * the logs. + */ + +const LOG_THROTTLE_MS = 60_000; +const MAX_TRACKED_TENANTS = 1_000; + +const lastLoggedAt = new Map(); + +/** + * Detected by shape rather than `instanceof ApiError` — multiple copies of + * `@prezly/sdk` can end up in the server bundle, each with its own class. + */ +export function isApiAuthError(error: unknown): error is Error & { status: number } { + return ( + error instanceof Error && + 'status' in error && + (error.status === 401 || error.status === 403) + ); +} + +/** + * Wraps a client object so that API auth failures thrown from any of its + * methods are logged with tenant identification (at most once per newsroom + * per minute). The error is rethrown untouched. + */ +export function reportApiAuthErrors(client: T, requestHeaders: Headers): T { + return new Proxy(client, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + + if (typeof value !== 'function') { + return value; + } + + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args); + + if (result instanceof Promise) { + return result.catch((error: unknown) => { + if (isApiAuthError(error)) { + logApiAuthFailure(error.status, requestHeaders); + } + throw error; + }); + } + + return result; + }; + }, + }); +} + +/** + * Logs an API auth failure with tenant identification, at most once per + * newsroom per minute. + */ +export function logApiAuthFailure(status: number, requestHeaders: Headers) { + const host = requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? 'unknown'; + const newsroom = identifyNewsroom(requestHeaders) ?? 'unknown'; + + if (!shouldLog(`${newsroom}:${status}`, Date.now())) { + return; + } + + console.warn( + `Prezly API rejected the access token (${status}) for newsroom=${newsroom} host=${host}. ` + + 'The token configured for this site is likely revoked or rotated.', + ); +} + +function shouldLog(key: string, now: number): boolean { + const loggedAt = lastLoggedAt.get(key); + + if (loggedAt !== undefined && now - loggedAt < LOG_THROTTLE_MS) { + return false; + } + + if (lastLoggedAt.size >= MAX_TRACKED_TENANTS) { + for (const [trackedKey, trackedAt] of lastLoggedAt) { + if (now - trackedAt >= LOG_THROTTLE_MS) { + lastLoggedAt.delete(trackedKey); + } + } + // If every tracked entry is still within the throttle window, drop the + // oldest one (Map iterates in insertion order) to keep the size bounded. + if (lastLoggedAt.size >= MAX_TRACKED_TENANTS) { + const oldest = lastLoggedAt.keys().next().value; + if (oldest !== undefined) { + lastLoggedAt.delete(oldest); + } + } + } + + // Delete before set, so refreshed keys move to the back of the eviction order. + lastLoggedAt.delete(key); + lastLoggedAt.set(key, now); + + return true; +} + +function identifyNewsroom(requestHeaders: Headers): string | undefined { + try { + return environment(requestHeaders).PREZLY_NEWSROOM_UUID; + } catch { + return undefined; + } +} diff --git a/src/adapters/server/index.ts b/src/adapters/server/index.ts index 6147c7641..3ee5bdd40 100644 --- a/src/adapters/server/index.ts +++ b/src/adapters/server/index.ts @@ -1,4 +1,5 @@ export * from './analytics'; +export * from './api-error'; export * from './app'; export * from './environment'; export * from './intl'; diff --git a/src/adapters/server/prezly.ts b/src/adapters/server/prezly.ts index e1b9b3cb1..973b3cf2f 100644 --- a/src/adapters/server/prezly.ts +++ b/src/adapters/server/prezly.ts @@ -2,6 +2,7 @@ import { Story } from '@prezly/sdk'; import { PrezlyAdapter } from '@prezly/theme-kit-nextjs/server'; import { headers, type UnsafeUnwrappedHeaders } from 'next/headers'; +import { reportApiAuthErrors } from './api-error'; import { environment } from './environment'; // @ts-expect-error @@ -49,5 +50,10 @@ export function initPrezlyClient( }, ); - return adapter.usePrezlyClient(); + const { client, contentDelivery } = adapter.usePrezlyClient(); + + return { + client, + contentDelivery: reportApiAuthErrors(contentDelivery, requestHeaders), + }; } diff --git a/src/modules/Story/Share/utils/getStoryPdfUrl.ts b/src/modules/Story/Share/utils/getStoryPdfUrl.ts index b2d580a89..cdb94cf58 100644 --- a/src/modules/Story/Share/utils/getStoryPdfUrl.ts +++ b/src/modules/Story/Share/utils/getStoryPdfUrl.ts @@ -1,24 +1,22 @@ -'use server'; +import type { Story } from '@prezly/sdk'; -import { routing, type Story } from '@prezly/sdk'; -import { headers } from 'next/headers'; +/** + * Browser-side helper — resolves the story PDF export URL through this site's + * own `/api/stories/[uuid]/pdf` route. Must only be called from client code + * (the relative URL has no meaning during server rendering). + */ +export async function getStoryPdfUrl(uuid: Story['uuid']): Promise { + const response = await fetch(`/api/stories/${uuid}/pdf`); -import { environment } from '@/adapters/server'; + if (!response.ok) { + return null; + } -const PREZLY_API_URL = 'https://api.prezly.com'; + const data: unknown = await response.json().catch(() => null); -export async function getStoryPdfUrl(uuid: Story['uuid']) { - const requestHeaders = await headers(); - const env = environment(requestHeaders); + if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') { + return data.url; + } - const { PREZLY_ACCESS_TOKEN, PREZLY_API_BASEURL = PREZLY_API_URL } = env; - const STORIES_ENDPOINT = `${PREZLY_API_BASEURL}${routing.storiesUrl}`; - - return fetch(`${STORIES_ENDPOINT}/${uuid}`, { - headers: { - Authorization: `Bearer ${PREZLY_ACCESS_TOKEN}`, - Accept: 'application/pdf', - }, - redirect: 'manual', - }).then((response) => response.headers.get('location')); + return null; } diff --git a/src/modules/Story/Share/utils/htmlToText.ts b/src/modules/Story/Share/utils/htmlToText.ts index 6afc06cae..13d268012 100644 --- a/src/modules/Story/Share/utils/htmlToText.ts +++ b/src/modules/Story/Share/utils/htmlToText.ts @@ -1,5 +1,3 @@ -'use server'; - import { convert } from 'html-to-text'; export async function htmlToText(html: string) {