Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions app/api/stories/[uuid]/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
camilb marked this conversation as resolved.

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}`, {
Comment thread
camilb marked this conversation as resolved.
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;
}
}
25 changes: 20 additions & 5 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Comment thread
camilb marked this conversation as resolved.
headers: { 'retry-after': '300' },
});
}
throw error;
}

const [defaultLocale] = locales; // default is expected to always be the first in the list

Expand Down
34 changes: 34 additions & 0 deletions next-logger.config.js
Original file line number Diff line number Diff line change
@@ -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 };
114 changes: 114 additions & 0 deletions src/adapters/server/api-error.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
Comment thread
camilb marked this conversation as resolved.

/**
* 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<T extends object>(client: T, requestHeaders: Headers): T {
Comment thread
camilb marked this conversation as resolved.
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using request.headers.get('host') as the primary fallback instead of x-forwarded-host, since the latter can be spoofed by clients. The host header is more reliable in most deployment scenarios.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The oldest variable could theoretically be undefined if the Map is empty, but this is protected by the size check above. Consider adding a comment explaining this invariant for clarity.

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;
}
}
1 change: 1 addition & 0 deletions src/adapters/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './analytics';
export * from './api-error';
export * from './app';
export * from './environment';
export * from './intl';
Expand Down
8 changes: 7 additions & 1 deletion src/adapters/server/prezly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,5 +50,10 @@ export function initPrezlyClient(
},
);

return adapter.usePrezlyClient();
const { client, contentDelivery } = adapter.usePrezlyClient();

return {
client,
contentDelivery: reportApiAuthErrors(contentDelivery, requestHeaders),
};
}
34 changes: 16 additions & 18 deletions src/modules/Story/Share/utils/getStoryPdfUrl.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
const response = await fetch(`/api/stories/${uuid}/pdf`);
Comment thread
camilb marked this conversation as resolved.

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;
}
2 changes: 0 additions & 2 deletions src/modules/Story/Share/utils/htmlToText.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use server';

import { convert } from 'html-to-text';

export async function htmlToText(html: string) {
Expand Down
Loading