feat: support display HTML format in API error responses (#7127)

#### What type of PR is this?

/kind feature
/area ui
/milestone 2.20.x

#### What this PR does / why we need it:

Add supports for display HTML format in API error responses

See #7115 

Examples:

<img width="917" alt="image" src="https://github.com/user-attachments/assets/1ab4531c-3238-4e7d-ba24-d2425184a757">

<img width="942" alt="image" src="https://github.com/user-attachments/assets/54621b31-0629-4772-95fd-8587a7704ca3">


#### Which issue(s) this PR fixes:

Fixes #7115 

#### Special notes for your reviewer:

Nginx mock example:

```nginx
server {
    listen 80;
    server_name localhost;

    error_page   500 502 503 504  /50x.html;

    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    location / {
      proxy_pass http://localhost:8090;
      proxy_set_header HOST $host;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location ^~ /apis/content.halo.run/v1alpha1/posts/ {
        return 403;
    }
}
```

#### Does this PR introduce a user-facing change?

```release-note
支持显示来自反向代理或者 WAF 的请求错误信息
```
pull/7123/head^2
Ryan Wang 2024-12-16 10:46:08 +08:00 committed by GitHub
parent 0e9466d29c
commit 41ea81cddd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 158 additions and 3 deletions

View File

@ -86,6 +86,7 @@
"fuse.js": "^6.6.2",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21",
"object-hash": "^3.0.0",
"overlayscrollbars": "^2.5.0",
"overlayscrollbars-vue": "^0.5.7",
"path-browserify": "^1.0.1",
@ -114,6 +115,7 @@
"@types/jsdom": "^20.0.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.11.19",
"@types/object-hash": "^3.0.6",
"@types/qs": "^6.9.7",
"@types/randomstring": "^1.1.8",
"@types/ua-parser-js": "^0.7.39",

View File

@ -158,6 +158,9 @@ importers:
lodash-es:
specifier: ^4.17.21
version: 4.17.21
object-hash:
specifier: ^3.0.0
version: 3.0.0
overlayscrollbars:
specifier: ^2.5.0
version: 2.5.0
@ -237,6 +240,9 @@ importers:
'@types/node':
specifier: ^18.11.19
version: 18.13.0
'@types/object-hash':
specifier: ^3.0.6
version: 3.0.6
'@types/qs':
specifier: ^6.9.7
version: 6.9.7
@ -4315,6 +4321,9 @@ packages:
'@types/normalize-package-data@2.4.1':
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
'@types/object-hash@3.0.6':
resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==}
'@types/pretty-hrtime@1.0.3':
resolution: {integrity: sha512-nj39q0wAIdhwn7DGUyT9irmsKK1tV0bd5WFEhgpqNTMFZ8cE+jieuTphCW0tfdm47S2zVT5mr09B28b1chmQMA==}
@ -6280,6 +6289,7 @@ packages:
eslint@8.43.0:
resolution: {integrity: sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
espree@9.5.2:
@ -15309,6 +15319,8 @@ snapshots:
'@types/normalize-package-data@2.4.1': {}
'@types/object-hash@3.0.6': {}
'@types/pretty-hrtime@1.0.3': {}
'@types/prop-types@15.7.11': {}

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { i18n } from "@/locales";
import type { ProblemDetail } from "@/setup/setupApiClient";
import { createHTMLContentModal } from "@/utils/modal";
import { Toast } from "@halo-dev/components";
import type { Restrictions } from "@uppy/core";
import Uppy, { type SuccessResponse } from "@uppy/core";
@ -13,7 +14,8 @@ import zh_CN from "@uppy/locales/lib/zh_CN";
import zh_TW from "@uppy/locales/lib/zh_TW";
import { Dashboard } from "@uppy/vue";
import XHRUpload from "@uppy/xhr-upload";
import { computed, onUnmounted } from "vue";
import objectHash from "object-hash";
import { computed, h, onUnmounted } from "vue";
const props = withDefaults(
defineProps<{
@ -91,6 +93,40 @@ const uppy = computed(() => {
const responseBody = response as XMLHttpRequest;
const { status, statusText } = responseBody;
const defaultMessage = [status, statusText].join(": ");
// Catch error requests where the response is text/html,
// which usually comes from a reverse proxy or WAF
// fixme: Because there is no responseType in the response, we can only judge it in this way for now.
const parser = new DOMParser();
const doc = parser.parseFromString(
responseBody.response,
"text/html"
);
if (
Array.from(doc.body.childNodes).some((node) => node.nodeType === 1)
) {
createHTMLContentModal({
uniqueId: objectHash(responseBody.response || ""),
title: responseBody.status.toString(),
width: 700,
height: "calc(100vh - 20px)",
centered: true,
content: h("iframe", {
srcdoc: responseBody.response,
sandbox: "",
referrerpolicy: "no-referrer",
loading: "lazy",
style: {
width: "100%",
height: "100%",
},
}),
});
return new Error(defaultMessage);
}
Toast.error(defaultMessage, { duration: 5000 });
return new Error(defaultMessage);
}

View File

@ -1,7 +1,10 @@
import { i18n } from "@/locales";
import { createHTMLContentModal } from "@/utils/modal";
import { axiosInstance } from "@halo-dev/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import type { AxiosError } from "axios";
import objectHash from "object-hash";
import { h } from "vue";
export interface ProblemDetail {
detail: string;
@ -16,7 +19,7 @@ export function setupApiClient() {
(response) => {
return response;
},
async (error: AxiosError<ProblemDetail>) => {
async (error: AxiosError) => {
if (error.code === "ERR_CANCELED") {
return Promise.reject(error);
}
@ -41,7 +44,7 @@ export function setupApiClient() {
}
const { status } = errorResponse;
const { title, detail } = errorResponse.data;
const { title, detail } = errorResponse.data as ProblemDetail;
if (status === 401) {
Dialog.warning({
@ -64,6 +67,33 @@ export function setupApiClient() {
return Promise.reject(error);
}
// Catch error requests where the response is text/html,
// which usually comes from a reverse proxy or WAF
const contentType = error.response?.headers["content-type"];
if (contentType === "text/html") {
createHTMLContentModal({
uniqueId: objectHash(error.response?.data || ""),
title: error.response?.status.toString(),
width: 700,
height: "calc(100vh - 20px)",
centered: true,
content: h("iframe", {
srcdoc: error.response?.data?.toString(),
sandbox: "",
referrerpolicy: "no-referrer",
loading: "lazy",
style: {
width: "100%",
height: "100%",
},
}),
});
return Promise.reject(error);
}
if (title || detail) {
Toast.error(detail || title);
return Promise.reject(error);

75
ui/src/utils/modal.ts Normal file
View File

@ -0,0 +1,75 @@
import { i18n } from "@/locales";
import { VButton, VModal } from "@halo-dev/components";
import { type Component, createApp, h } from "vue";
interface ModalOptions {
uniqueId?: string;
title?: string;
width?: number;
height?: string;
centered?: boolean;
content: Component;
}
export function createHTMLContentModal(options: ModalOptions) {
if (options.uniqueId) {
const existingModal = document.getElementById(`modal-${options.uniqueId}`);
if (existingModal) {
return;
}
}
const container = document.createElement("div");
if (options.uniqueId) {
container.id = `modal-${options.uniqueId}`;
}
document.body.appendChild(container);
const app = createApp({
setup() {
const handleClose = () => {
app.unmount();
container.remove();
};
return () =>
h(
VModal,
{
title: options.title,
width: options.width || 500,
height: options.height,
centered: options.centered ?? true,
onClose: handleClose,
"onUpdate:visible": (value: boolean) => {
if (!value) handleClose();
},
},
{
default: () => options.content,
footer: () =>
h(
VButton,
{
onClick: handleClose,
},
{
default: () =>
h("div", i18n.global.t("core.common.buttons.close")),
}
),
}
);
},
});
app.mount(container);
return {
close: () => {
app.unmount();
container.remove();
},
};
}