do not print a proxy's error page at somebody

first real test of the settings form returned openresty's 502 Bad Gateway
document, markup and all, into the panel. installing this plugin restarts
officer, and a request made inside that window reaches nginx proxy manager,
finds nothing behind it, and gets a web page back. useClient throws the
response body as the message, so the panel printed it.

worth keeping the irony: this plugin manages the proxy whose error page it
could not read.

the check is the body's SHAPE, not the status, because there is no status that
separates them — api/router.ts uses 502 for "NPM answered and refused", which
is a real answer we must not swallow. ours arrives as a sentence, theirs as a
document. gateway status AND html means the hop in front of officer answered;
html on its own still is not a sentence and says so without the tags.

all three panels go through it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 16:55:55 +00:00
co-authored by Claude Opus 5
parent ee8edd3709
commit 83a8558e60
4 changed files with 56 additions and 4 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react"; import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { readableError } from "./errors";
/** /**
* Certificates, ordered by how soon they expire. * Certificates, ordered by how soon they expire.
@@ -38,7 +39,7 @@ export const CertificatesPanel = () => {
} catch (caught) { } catch (caught) {
// Not `.body` — `useClient` throws `ApiError(status, message)` and has no body. See `asFailure` in // Not `.body` — `useClient` throws `ApiError(status, message)` and has no body. See `asFailure` in
// HostsPanel.tsx for the full account; this panel had the same dead read. // HostsPanel.tsx for the full account; this panel had the same dead read.
setError(caught instanceof Error ? caught.message : "Could not load certificates."); setError(readableError(caught, "Could not load certificates."));
setCertificates(null); setCertificates(null);
} }
}, []); }, []);
+4 -2
View File
@@ -3,6 +3,7 @@ import { CheckCircle2, Loader2, Plug, ShieldAlert, Trash2, TriangleAlert } from
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { readableError } from './errors';
/** /**
* The connection to Nginx Proxy Manager — both the setup wizard and the permanent settings page. * The connection to Nginx Proxy Manager — both the setup wizard and the permanent settings page.
@@ -94,9 +95,10 @@ const isForbidden = (error: unknown): boolean => (error as { status?: number })?
* `useClient` throws `ApiError(status, message)` and nothing else — there is no `.body` to read, which is * `useClient` throws `ApiError(status, message)` and nothing else — there is no `.body` to read, which is
* the bug `HostsPanel`'s `asFailure` documents at length. The message is already the server's own text: * the bug `HostsPanel`'s `asFailure` documents at length. The message is already the server's own text:
* since 2026-08-25 the platform answers `{ error }` and the client unwraps it before throwing. * since 2026-08-25 the platform answers `{ error }` and the client unwraps it before throwing.
*
* `readableError` guards the case where that text is not Officer's at all — see `errors.ts`.
*/ */
const errorText = (error: unknown): string => const errorText = (error: unknown): string => readableError(error);
error instanceof Error ? error.message : 'Something went wrong.';
export const ConnectionPanel = () => { export const ConnectionPanel = () => {
const client = useClient(); const client = useClient();
+2 -1
View File
@@ -3,6 +3,7 @@ import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, Settings, Shiel
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { ConnectionPanel } from "./ConnectionPanel"; import { ConnectionPanel } from "./ConnectionPanel";
import { readableError } from "./errors";
/** /**
* The proxy hosts, which is the thing anybody opens this plugin to see. * The proxy hosts, which is the thing anybody opens this plugin to see.
@@ -42,7 +43,7 @@ type Failure = { error: string; likelyAuth?: boolean; unreachable?: boolean; unc
*/ */
const asFailure = (error: unknown): Failure => { const asFailure = (error: unknown): Failure => {
const status = (error as { status?: number })?.status; const status = (error as { status?: number })?.status;
const message = error instanceof Error ? error.message : "Could not load hosts."; const message = readableError(error, "Could not load hosts.");
return { return {
error: message, error: message,
unconfigured: status === 409, unconfigured: status === 409,
+48
View File
@@ -0,0 +1,48 @@
/**
* Turning a thrown request error into a sentence a person can act on.
*
* ── The one this exists for ──
*
* `useClient` throws `ApiError(status, message)` where `message` is the response body as text. That is
* right when the body came from Officer. It is badly wrong when the body came from something BETWEEN the
* browser and Officer — because then the message is a whole HTML error page, and the panel renders it
* verbatim, tags and all.
*
* Seen on 2026-08-25, on this plugin's first real test: installing it restarts `officer`, and a request
* made during that window reached Nginx Proxy Manager, found nothing behind it, and came back as
* openresty's `502 Bad Gateway` page. The panel printed the markup.
*
* The irony is worth keeping: this plugin manages the proxy that produced the page it could not read.
*
* ── Why the check is "does it look like HTML" ──
*
* There is no status that separates the two. A 502 from the proxy and a 502 from this plugin's own router
* are the same number — `api/router.ts` uses 502 for "NPM answered and refused", which is a real, useful
* answer we must not swallow. What differs is the SHAPE of the body: ours is JSON and unwrapped to a
* sentence by the client, theirs is a document. So the body is the signal, and it is a cheap one.
*/
/** A body that is a document rather than a message. Cheap and deliberately loose. */
const looksLikeHtml = (text: string): boolean => /^\s*<(!doctype|html|head|body)/i.test(text);
/**
* A gateway status with an HTML body means the hop in front of Officer answered, not Officer.
*
* Both halves are required. A gateway status alone is ambiguous — this plugin's own router returns 502
* and 503 with real meaning — and HTML alone could be anything.
*/
const isProxyError = (status: number | undefined, message: string): boolean =>
(status === 502 || status === 503 || status === 504) && looksLikeHtml(message);
export const readableError = (error: unknown, fallback = 'Something went wrong.'): string => {
const status = (error as { status?: number })?.status;
const message = error instanceof Error ? error.message : '';
if (isProxyError(status, message)) {
return 'Officer itself did not answer — it is probably restarting. Wait a moment and try again.';
}
// A document from anywhere else is still not a sentence. Say what happened rather than printing markup.
if (looksLikeHtml(message)) return status ? `The server answered ${status} with a web page, not an answer.` : fallback;
return message || fallback;
};