mirror of https://github.com/halo-dev/halo
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
parent
0e9466d29c
commit
41ea81cddd
|
@ -86,6 +86,7 @@
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"object-hash": "^3.0.0",
|
||||||
"overlayscrollbars": "^2.5.0",
|
"overlayscrollbars": "^2.5.0",
|
||||||
"overlayscrollbars-vue": "^0.5.7",
|
"overlayscrollbars-vue": "^0.5.7",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
|
@ -114,6 +115,7 @@
|
||||||
"@types/jsdom": "^20.0.1",
|
"@types/jsdom": "^20.0.1",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^18.11.19",
|
"@types/node": "^18.11.19",
|
||||||
|
"@types/object-hash": "^3.0.6",
|
||||||
"@types/qs": "^6.9.7",
|
"@types/qs": "^6.9.7",
|
||||||
"@types/randomstring": "^1.1.8",
|
"@types/randomstring": "^1.1.8",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
|
|
@ -158,6 +158,9 @@ importers:
|
||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
|
object-hash:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
overlayscrollbars:
|
overlayscrollbars:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
|
@ -237,6 +240,9 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^18.11.19
|
specifier: ^18.11.19
|
||||||
version: 18.13.0
|
version: 18.13.0
|
||||||
|
'@types/object-hash':
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6
|
||||||
'@types/qs':
|
'@types/qs':
|
||||||
specifier: ^6.9.7
|
specifier: ^6.9.7
|
||||||
version: 6.9.7
|
version: 6.9.7
|
||||||
|
@ -4315,6 +4321,9 @@ packages:
|
||||||
'@types/normalize-package-data@2.4.1':
|
'@types/normalize-package-data@2.4.1':
|
||||||
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
|
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
|
||||||
|
|
||||||
|
'@types/object-hash@3.0.6':
|
||||||
|
resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==}
|
||||||
|
|
||||||
'@types/pretty-hrtime@1.0.3':
|
'@types/pretty-hrtime@1.0.3':
|
||||||
resolution: {integrity: sha512-nj39q0wAIdhwn7DGUyT9irmsKK1tV0bd5WFEhgpqNTMFZ8cE+jieuTphCW0tfdm47S2zVT5mr09B28b1chmQMA==}
|
resolution: {integrity: sha512-nj39q0wAIdhwn7DGUyT9irmsKK1tV0bd5WFEhgpqNTMFZ8cE+jieuTphCW0tfdm47S2zVT5mr09B28b1chmQMA==}
|
||||||
|
|
||||||
|
@ -6280,6 +6289,7 @@ packages:
|
||||||
eslint@8.43.0:
|
eslint@8.43.0:
|
||||||
resolution: {integrity: sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==}
|
resolution: {integrity: sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
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
|
hasBin: true
|
||||||
|
|
||||||
espree@9.5.2:
|
espree@9.5.2:
|
||||||
|
@ -15309,6 +15319,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/normalize-package-data@2.4.1': {}
|
'@types/normalize-package-data@2.4.1': {}
|
||||||
|
|
||||||
|
'@types/object-hash@3.0.6': {}
|
||||||
|
|
||||||
'@types/pretty-hrtime@1.0.3': {}
|
'@types/pretty-hrtime@1.0.3': {}
|
||||||
|
|
||||||
'@types/prop-types@15.7.11': {}
|
'@types/prop-types@15.7.11': {}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { i18n } from "@/locales";
|
import { i18n } from "@/locales";
|
||||||
import type { ProblemDetail } from "@/setup/setupApiClient";
|
import type { ProblemDetail } from "@/setup/setupApiClient";
|
||||||
|
import { createHTMLContentModal } from "@/utils/modal";
|
||||||
import { Toast } from "@halo-dev/components";
|
import { Toast } from "@halo-dev/components";
|
||||||
import type { Restrictions } from "@uppy/core";
|
import type { Restrictions } from "@uppy/core";
|
||||||
import Uppy, { type SuccessResponse } 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 zh_TW from "@uppy/locales/lib/zh_TW";
|
||||||
import { Dashboard } from "@uppy/vue";
|
import { Dashboard } from "@uppy/vue";
|
||||||
import XHRUpload from "@uppy/xhr-upload";
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -91,6 +93,40 @@ const uppy = computed(() => {
|
||||||
const responseBody = response as XMLHttpRequest;
|
const responseBody = response as XMLHttpRequest;
|
||||||
const { status, statusText } = responseBody;
|
const { status, statusText } = responseBody;
|
||||||
const defaultMessage = [status, statusText].join(": ");
|
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 });
|
Toast.error(defaultMessage, { duration: 5000 });
|
||||||
return new Error(defaultMessage);
|
return new Error(defaultMessage);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { i18n } from "@/locales";
|
import { i18n } from "@/locales";
|
||||||
|
import { createHTMLContentModal } from "@/utils/modal";
|
||||||
import { axiosInstance } from "@halo-dev/api-client";
|
import { axiosInstance } from "@halo-dev/api-client";
|
||||||
import { Dialog, Toast } from "@halo-dev/components";
|
import { Dialog, Toast } from "@halo-dev/components";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
|
import objectHash from "object-hash";
|
||||||
|
import { h } from "vue";
|
||||||
|
|
||||||
export interface ProblemDetail {
|
export interface ProblemDetail {
|
||||||
detail: string;
|
detail: string;
|
||||||
|
@ -16,7 +19,7 @@ export function setupApiClient() {
|
||||||
(response) => {
|
(response) => {
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
async (error: AxiosError<ProblemDetail>) => {
|
async (error: AxiosError) => {
|
||||||
if (error.code === "ERR_CANCELED") {
|
if (error.code === "ERR_CANCELED") {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
@ -41,7 +44,7 @@ export function setupApiClient() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = errorResponse;
|
const { status } = errorResponse;
|
||||||
const { title, detail } = errorResponse.data;
|
const { title, detail } = errorResponse.data as ProblemDetail;
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
Dialog.warning({
|
Dialog.warning({
|
||||||
|
@ -64,6 +67,33 @@ export function setupApiClient() {
|
||||||
return Promise.reject(error);
|
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) {
|
if (title || detail) {
|
||||||
Toast.error(detail || title);
|
Toast.error(detail || title);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue