Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 35 additions & 0 deletions app/api/stories/[uuid]/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { routing } from '@prezly/sdk';
import { type NextRequest, NextResponse } from 'next/server';

import { environment } from '@/adapters/server';

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',
});

return NextResponse.json({ url: response.headers.get('location') });
Comment thread
camilb marked this conversation as resolved.
Outdated
}
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
28 changes: 28 additions & 0 deletions next-logger.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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.
*/
function serializeError(error) {
const serialized = pino.stdSerializers.err(error);

if (serialized && typeof serialized === 'object' && 'headers' in serialized) {
Comment thread
camilb marked this conversation as resolved.
Outdated
const { headers, ...rest } = serialized;
return rest;
}

return serialized;
}

const logger = (defaultConfig) =>
pino({
...defaultConfig,
serializers: {
err: serializeError,
},
});

module.exports = { logger };
86 changes: 86 additions & 0 deletions src/adapters/server/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 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) => {
logApiAuthError(error, requestHeaders);
throw error;
});
}

return result;
};
},
});
}

function logApiAuthError(error: unknown, requestHeaders: Headers) {
if (!isApiAuthError(error)) {
return;
}

const host = requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? 'unknown';
const newsroom = identifyNewsroom(requestHeaders) ?? 'unknown';

const key = `${newsroom}:${error.status}`;
const now = Date.now();
const loggedAt = lastLoggedAt.get(key);

if (loggedAt !== undefined && now - loggedAt < LOG_THROTTLE_MS) {
return;
}
lastLoggedAt.set(key, now);

console.warn(
`Prezly API rejected the access token (${error.status}) for newsroom=${newsroom} host=${host}. ` +
'The token configured for this site is likely revoked or rotated.',
);
}

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),
};
}
27 changes: 8 additions & 19 deletions src/modules/Story/Share/utils/getStoryPdfUrl.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
'use server';
import type { Story } from '@prezly/sdk';

import { routing, type Story } from '@prezly/sdk';
import { headers } from 'next/headers';
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 { url } = (await response.json()) as { url: string | null };
Comment thread
camilb marked this conversation as resolved.
Outdated

export async function getStoryPdfUrl(uuid: Story['uuid']) {
const requestHeaders = await headers();
const env = environment(requestHeaders);

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 url;
}
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