-
Notifications
You must be signed in to change notification settings - Fork 17
Handle tenant API auth failures gracefully and drop Server Actions #1464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
||
| 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}`, { | ||
|
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; | ||
| } | ||
| } | ||
| 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 }; |
| 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>(); | ||
|
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 { | ||
|
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'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider using |
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| 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; | ||
| } | ||
| } | ||
| 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`); | ||
|
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; | ||
| } | ||
| 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) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.