mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
fix(client): recover loopback RPC 403 via localhost
This commit is contained in:
parent
9e39333ff6
commit
d999cb511f
6 changed files with 425 additions and 18 deletions
147
lib/client.js
147
lib/client.js
|
|
@ -35,6 +35,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|||
var index_exports = {};
|
||||
__export(index_exports, {
|
||||
IMSettingsTab: () => IMSettingsTab,
|
||||
LoopbackRecoveryNotice: () => LoopbackRecoveryNotice,
|
||||
apply: () => apply,
|
||||
inject: () => inject,
|
||||
name: () => name
|
||||
|
|
@ -245,6 +246,10 @@ var EN = Object.freeze({
|
|||
"\u672C\u673A\u6682\u65F6\u65E0\u6CD5\u8BBF\u95EE AI Office\u3002": "AI Office cannot currently be reached from this machine.",
|
||||
"AI Office \u8FDE\u63A5\u5DF2\u4E2D\u65AD\u3002": "The AI Office connection was interrupted.",
|
||||
"\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub": "Help & feedback \xB7 Open GitHub",
|
||||
"\u8BF7\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00": "Reopen with localhost",
|
||||
"\u9875\u9762\u4F1A\u5728\u5F53\u524D\u7AEF\u53E3\u91CD\u65B0\u6253\u5F00\uFF0C\u673A\u5668\u4EBA\u914D\u7F6E\u4E0D\u4F1A\u6539\u53D8\u3002": "The page will reopen on the current port. Your bot configuration will not change.",
|
||||
"\u4F7F\u7528 localhost \u91CD\u65B0\u6253\u5F00": "Reopen with localhost",
|
||||
"\u5F53\u524D\u5730\u5740\u4E0E\u6D4F\u89C8\u5668\u7684\u672C\u673A\u8BF7\u6C42\u6821\u9A8C\u4E0D\u517C\u5BB9\u3002\u8BF7\u4F7F\u7528\u4E0A\u65B9\u6309\u94AE\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00\u3002": "This address is incompatible with the browser\u2019s local-request checks. Use the button above to reopen with localhost.",
|
||||
"\u5FAE\u4FE1": "WeChat",
|
||||
"\u98DE\u4E66": "Feishu",
|
||||
"\u9489\u9489": "DingTalk",
|
||||
|
|
@ -10014,6 +10019,61 @@ function WhatsappSettingsTab({ rpcCall }) {
|
|||
));
|
||||
}
|
||||
|
||||
// plugin-src/client/loopback-recovery.js
|
||||
var TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/;
|
||||
var LOOPBACK_RECOVERY_ERROR_CODE = "loopback-recovery-required";
|
||||
var LOOPBACK_RECOVERY_ERROR_MESSAGE = "\u5F53\u524D\u5730\u5740\u4E0E\u6D4F\u89C8\u5668\u7684\u672C\u673A\u8BF7\u6C42\u6821\u9A8C\u4E0D\u517C\u5BB9\u3002\u8BF7\u4F7F\u7528\u4E0A\u65B9\u6309\u94AE\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00\u3002";
|
||||
function isIpv4Loopback(hostname) {
|
||||
const parts = hostname.split(".");
|
||||
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||
}
|
||||
function createLoopbackRecovery(error, location) {
|
||||
if (!TRANSPORT_FORBIDDEN.test(error?.message ?? "")) return null;
|
||||
if (typeof location?.href !== "string") return null;
|
||||
try {
|
||||
const current = new URL(location.href);
|
||||
if (current.protocol !== "http:" || !isIpv4Loopback(current.hostname)) return null;
|
||||
current.hostname = "localhost";
|
||||
return Object.freeze({
|
||||
url: current.href,
|
||||
origin: current.origin
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function createLoopbackAwareRpcCall(rpcCall, {
|
||||
location,
|
||||
onRecovery
|
||||
} = {}) {
|
||||
if (typeof rpcCall !== "function") throw new TypeError("rpcCall must be a function");
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await rpcCall(...args);
|
||||
} catch (error) {
|
||||
const recovery = createLoopbackRecovery(error, location);
|
||||
if (!recovery) throw error;
|
||||
onRecovery?.(recovery);
|
||||
const presented = new Error(LOOPBACK_RECOVERY_ERROR_MESSAGE);
|
||||
presented.code = LOOPBACK_RECOVERY_ERROR_CODE;
|
||||
presented.cause = error;
|
||||
presented.recoveryUrl = recovery.url;
|
||||
throw presented;
|
||||
}
|
||||
};
|
||||
}
|
||||
function createLoopbackAwareRpcCalls(rpcCalls, options) {
|
||||
return Object.freeze(Object.fromEntries(
|
||||
Object.entries(rpcCalls).map(([name2, rpcCall]) => [
|
||||
name2,
|
||||
typeof rpcCall === "function" ? createLoopbackAwareRpcCall(rpcCall, options) : rpcCall
|
||||
])
|
||||
));
|
||||
}
|
||||
function replacePageLocation(url, location = globalThis.location) {
|
||||
location?.replace?.(url);
|
||||
}
|
||||
|
||||
// plugin-src/client/styles.js
|
||||
var IM_STYLE_ID = "xmanrui-dsh-im-settings";
|
||||
var CSS11 = String.raw`
|
||||
|
|
@ -10073,6 +10133,14 @@ var CSS11 = String.raw`
|
|||
.dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
|
||||
.dim-panel { min-width: 0; container-type: inline-size; }
|
||||
.dim-loopbackRecovery { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-loopbackRecoveryCopy { min-width: 0; }
|
||||
.dim-loopbackRecoveryCopy strong { display: block; font-size: 14px; line-height: 20px; font-weight: 650; }
|
||||
.dim-loopbackRecoveryCopy p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.dim-loopbackRecoveryCopy code { display: block; overflow: hidden; margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-loopbackRecoveryAction { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
|
||||
.dim-loopbackRecoveryAction:hover { border-color: #0958d9; background: #0958d9; }
|
||||
.dim-loopbackRecoveryAction:focus-visible { outline: 2px solid color-mix(in srgb, #1677ff 62%, white); outline-offset: 2px; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: stretch; gap: 8px; }
|
||||
|
|
@ -10282,6 +10350,8 @@ var CSS11 = String.raw`
|
|||
.dim-title p { white-space: normal; }
|
||||
.dim-githubTooltip { right: auto; left: 0; }
|
||||
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
||||
.dim-loopbackRecoveryAction { width: 100%; }
|
||||
.dim-directoryPickerBackdrop { padding: 10px; }
|
||||
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
||||
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
||||
|
|
@ -10398,6 +10468,27 @@ function ChannelLogo({ channel: channel4 }) {
|
|||
if (channel4 === "whatsapp") return h2(WhatsappLogo);
|
||||
return h2(OfficeLogo);
|
||||
}
|
||||
function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) {
|
||||
return h2(
|
||||
"div",
|
||||
{
|
||||
className: "dim-loopbackRecovery",
|
||||
role: "alert"
|
||||
},
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-loopbackRecoveryCopy" },
|
||||
h2("strong", null, "\u8BF7\u6539\u7528 localhost \u91CD\u65B0\u6253\u5F00"),
|
||||
h2("p", null, "\u9875\u9762\u4F1A\u5728\u5F53\u524D\u7AEF\u53E3\u91CD\u65B0\u6253\u5F00\uFF0C\u673A\u5668\u4EBA\u914D\u7F6E\u4E0D\u4F1A\u6539\u53D8\u3002"),
|
||||
h2("code", null, recovery.origin)
|
||||
),
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-loopbackRecoveryAction",
|
||||
onClick: () => onNavigate(recovery.url)
|
||||
}, "\u4F7F\u7528 localhost \u91CD\u65B0\u6253\u5F00")
|
||||
);
|
||||
}
|
||||
function IMSettingsTab({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
|
|
@ -10409,11 +10500,45 @@ function IMSettingsTab({
|
|||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
workspaceDirectoryPicker
|
||||
workspaceDirectoryPicker,
|
||||
browserLocation = globalThis.location,
|
||||
navigateToRecoveryUrl = replacePageLocation
|
||||
}) {
|
||||
const [selected, setSelected] = React20.useState("weixin");
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React20.useState(null);
|
||||
const githubTooltipId = React20.useId();
|
||||
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
|
||||
const reportLoopbackRecovery = React20.useCallback((recovery) => {
|
||||
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
|
||||
}, []);
|
||||
const rpcCalls = React20.useMemo(() => createLoopbackAwareRpcCalls({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
qqRpcCall,
|
||||
slackRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
officeRpcCall
|
||||
}, {
|
||||
location: browserLocation,
|
||||
onRecovery: reportLoopbackRecovery
|
||||
}), [
|
||||
browserLocation,
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
officeRpcCall,
|
||||
qqRpcCall,
|
||||
reportLoopbackRecovery,
|
||||
slackRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall
|
||||
]);
|
||||
return h2(
|
||||
WorkspaceDirectoryPickerContext.Provider,
|
||||
{ value: workspaceDirectoryPicker },
|
||||
|
|
@ -10480,12 +10605,20 @@ function IMSettingsTab({
|
|||
))
|
||||
),
|
||||
h2("div", { className: "dim-divider", "aria-hidden": "true" }),
|
||||
h2("main", {
|
||||
className: "dim-panel",
|
||||
role: "tabpanel",
|
||||
id: `dim-panel-${active.id}`,
|
||||
"aria-labelledby": `dim-tab-${active.id}`
|
||||
}, active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: discordRpcCall }) : active.id === "whatsapp" ? h2(WhatsappSettingsTab, { rpcCall: whatsappRpcCall }) : h2(OfficeSettingsTab, { rpcCall: officeRpcCall }))
|
||||
h2(
|
||||
"main",
|
||||
{
|
||||
className: "dim-panel",
|
||||
role: "tabpanel",
|
||||
id: `dim-panel-${active.id}`,
|
||||
"aria-labelledby": `dim-tab-${active.id}`
|
||||
},
|
||||
loopbackRecovery ? h2(LoopbackRecoveryNotice, {
|
||||
recovery: loopbackRecovery,
|
||||
onNavigate: navigateToRecoveryUrl
|
||||
}) : null,
|
||||
active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall }) : active.id === "whatsapp" ? h2(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall }) : h2(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ const EN = Object.freeze({
|
|||
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
|
||||
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
|
||||
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
|
||||
'请改用 localhost 重新打开': 'Reopen with localhost',
|
||||
'页面会在当前端口重新打开,机器人配置不会改变。': 'The page will reopen on the current port. Your bot configuration will not change.',
|
||||
'使用 localhost 重新打开': 'Reopen with localhost',
|
||||
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。': 'This address is incompatible with the browser’s local-request checks. Use the button above to reopen with localhost.',
|
||||
'微信': 'WeChat',
|
||||
'飞书': 'Feishu',
|
||||
'钉钉': 'DingTalk',
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
|
|||
import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
||||
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
||||
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
|
||||
import {
|
||||
createLoopbackAwareRpcCalls,
|
||||
replacePageLocation,
|
||||
} from './loopback-recovery.js';
|
||||
import { installImStyles } from './styles.js';
|
||||
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
||||
|
||||
|
|
@ -122,6 +126,22 @@ function ChannelLogo({ channel }) {
|
|||
return h(OfficeLogo);
|
||||
}
|
||||
|
||||
export function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) {
|
||||
return h('div', {
|
||||
className: 'dim-loopbackRecovery',
|
||||
role: 'alert',
|
||||
},
|
||||
h('div', { className: 'dim-loopbackRecoveryCopy' },
|
||||
h('strong', null, '请改用 localhost 重新打开'),
|
||||
h('p', null, '页面会在当前端口重新打开,机器人配置不会改变。'),
|
||||
h('code', null, recovery.origin)),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'dim-loopbackRecoveryAction',
|
||||
onClick: () => onNavigate(recovery.url),
|
||||
}, '使用 localhost 重新打开'));
|
||||
}
|
||||
|
||||
export function IMSettingsTab({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
|
|
@ -134,10 +154,44 @@ export function IMSettingsTab({
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
browserLocation = globalThis.location,
|
||||
navigateToRecoveryUrl = replacePageLocation,
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState('weixin');
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
|
||||
const githubTooltipId = React.useId();
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
const reportLoopbackRecovery = React.useCallback((recovery) => {
|
||||
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
|
||||
}, []);
|
||||
const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
qqRpcCall,
|
||||
slackRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
}, {
|
||||
location: browserLocation,
|
||||
onRecovery: reportLoopbackRecovery,
|
||||
}), [
|
||||
browserLocation,
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
officeRpcCall,
|
||||
qqRpcCall,
|
||||
reportLoopbackRecovery,
|
||||
slackRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
]);
|
||||
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
|
||||
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
||||
h('header', { className: 'dim-title' },
|
||||
|
|
@ -184,25 +238,32 @@ export function IMSettingsTab({
|
|||
role: 'tabpanel',
|
||||
id: `dim-panel-${active.id}`,
|
||||
'aria-labelledby': `dim-tab-${active.id}`,
|
||||
}, active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
|
||||
},
|
||||
loopbackRecovery
|
||||
? h(LoopbackRecoveryNotice, {
|
||||
recovery: loopbackRecovery,
|
||||
onNavigate: navigateToRecoveryUrl,
|
||||
})
|
||||
: null,
|
||||
active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
|
||||
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
|
||||
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
|
||||
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: qqRpcCall })
|
||||
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
||||
: active.id === 'slack'
|
||||
? h(SlackSettingsTab, { rpcCall: slackRpcCall })
|
||||
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
||||
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
||||
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
|
||||
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
75
plugin-src/client/loopback-recovery.js
Normal file
75
plugin-src/client/loopback-recovery.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
const TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/;
|
||||
|
||||
export const LOOPBACK_RECOVERY_ERROR_CODE = 'loopback-recovery-required';
|
||||
export const LOOPBACK_RECOVERY_ERROR_MESSAGE =
|
||||
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。';
|
||||
|
||||
function isIpv4Loopback(hostname) {
|
||||
const parts = hostname.split('.');
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe localhost navigation target for the known loopback transport failure.
|
||||
*/
|
||||
export function createLoopbackRecovery(error, location) {
|
||||
if (!TRANSPORT_FORBIDDEN.test(error?.message ?? '')) return null;
|
||||
if (typeof location?.href !== 'string') return null;
|
||||
|
||||
try {
|
||||
const current = new URL(location.href);
|
||||
if (current.protocol !== 'http:' || !isIpv4Loopback(current.hostname)) return null;
|
||||
current.hostname = 'localhost';
|
||||
return Object.freeze({
|
||||
url: current.href,
|
||||
origin: current.origin,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate one IM RPC caller with a narrowly scoped localhost recovery signal.
|
||||
*/
|
||||
export function createLoopbackAwareRpcCall(rpcCall, {
|
||||
location,
|
||||
onRecovery,
|
||||
} = {}) {
|
||||
if (typeof rpcCall !== 'function') throw new TypeError('rpcCall must be a function');
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await rpcCall(...args);
|
||||
} catch (error) {
|
||||
const recovery = createLoopbackRecovery(error, location);
|
||||
if (!recovery) throw error;
|
||||
onRecovery?.(recovery);
|
||||
const presented = new Error(LOOPBACK_RECOVERY_ERROR_MESSAGE);
|
||||
presented.code = LOOPBACK_RECOVERY_ERROR_CODE;
|
||||
presented.cause = error;
|
||||
presented.recoveryUrl = recovery.url;
|
||||
throw presented;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the same recovery behavior to every RPC caller in the combined settings page.
|
||||
*/
|
||||
export function createLoopbackAwareRpcCalls(rpcCalls, options) {
|
||||
return Object.freeze(Object.fromEntries(
|
||||
Object.entries(rpcCalls).map(([name, rpcCall]) => [
|
||||
name,
|
||||
typeof rpcCall === 'function'
|
||||
? createLoopbackAwareRpcCall(rpcCall, options)
|
||||
: rpcCall,
|
||||
]),
|
||||
));
|
||||
}
|
||||
|
||||
/** Navigate without leaving the known-broken loopback address in browser history. */
|
||||
export function replacePageLocation(url, location = globalThis.location) {
|
||||
location?.replace?.(url);
|
||||
}
|
||||
|
|
@ -57,6 +57,14 @@ const CSS = String.raw`
|
|||
.dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
|
||||
.dim-panel { min-width: 0; container-type: inline-size; }
|
||||
.dim-loopbackRecovery { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-loopbackRecoveryCopy { min-width: 0; }
|
||||
.dim-loopbackRecoveryCopy strong { display: block; font-size: 14px; line-height: 20px; font-weight: 650; }
|
||||
.dim-loopbackRecoveryCopy p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.dim-loopbackRecoveryCopy code { display: block; overflow: hidden; margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-loopbackRecoveryAction { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
|
||||
.dim-loopbackRecoveryAction:hover { border-color: #0958d9; background: #0958d9; }
|
||||
.dim-loopbackRecoveryAction:focus-visible { outline: 2px solid color-mix(in srgb, #1677ff 62%, white); outline-offset: 2px; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: stretch; gap: 8px; }
|
||||
|
|
@ -266,6 +274,8 @@ const CSS = String.raw`
|
|||
.dim-title p { white-space: normal; }
|
||||
.dim-githubTooltip { right: auto; left: 0; }
|
||||
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
||||
.dim-loopbackRecoveryAction { width: 100%; }
|
||||
.dim-directoryPickerBackdrop { padding: 10px; }
|
||||
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
||||
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
||||
|
|
|
|||
124
test/loopback-recovery.test.mjs
Normal file
124
test/loopback-recovery.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { LoopbackRecoveryNotice } from '../plugin-src/client/index.js';
|
||||
import {
|
||||
createLoopbackAwareRpcCall,
|
||||
createLoopbackAwareRpcCalls,
|
||||
createLoopbackRecovery,
|
||||
LOOPBACK_RECOVERY_ERROR_CODE,
|
||||
LOOPBACK_RECOVERY_ERROR_MESSAGE,
|
||||
replacePageLocation,
|
||||
} from '../plugin-src/client/loopback-recovery.js';
|
||||
|
||||
const WEIXIN_FORBIDDEN = new Error(
|
||||
'transport failure for /weixin/connection.status: HTTP 403',
|
||||
);
|
||||
|
||||
test('creates a localhost recovery target for the known loopback transport 403', () => {
|
||||
const recovery = createLoopbackRecovery(WEIXIN_FORBIDDEN, {
|
||||
href: 'http://127.0.0.1:3080/settings?tab=plugins#im',
|
||||
});
|
||||
|
||||
assert.deepEqual(recovery, {
|
||||
url: 'http://localhost:3080/settings?tab=plugins#im',
|
||||
origin: 'http://localhost:3080',
|
||||
});
|
||||
assert.deepEqual(createLoopbackRecovery(WEIXIN_FORBIDDEN, {
|
||||
href: 'http://127.8.9.10:4111/',
|
||||
}), {
|
||||
url: 'http://localhost:4111/',
|
||||
origin: 'http://localhost:4111',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not reinterpret unrelated 403s, protocols, or hosts', () => {
|
||||
const cases = [
|
||||
[new Error('transport failure for /weixin/connection.status: HTTP 404'), 'http://127.0.0.1:3080/'],
|
||||
[new Error('request failed: HTTP 403'), 'http://127.0.0.1:3080/'],
|
||||
[WEIXIN_FORBIDDEN, 'http://localhost:3080/'],
|
||||
[WEIXIN_FORBIDDEN, 'http://192.168.1.20:3080/'],
|
||||
[WEIXIN_FORBIDDEN, 'https://127.0.0.1:3080/'],
|
||||
[WEIXIN_FORBIDDEN, 'not a URL'],
|
||||
];
|
||||
|
||||
for (const [error, href] of cases) {
|
||||
assert.equal(createLoopbackRecovery(error, { href }), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('RPC wrapper reports the recovery and presents a safe actionable error', async () => {
|
||||
const original = new Error(
|
||||
'transport failure for /whatsapp/connection.status: HTTP 403',
|
||||
);
|
||||
let observed;
|
||||
const rpcCall = createLoopbackAwareRpcCall(async () => {
|
||||
throw original;
|
||||
}, {
|
||||
location: { href: 'http://127.0.0.1:3080/settings' },
|
||||
onRecovery: (recovery) => { observed = recovery; },
|
||||
});
|
||||
|
||||
await assert.rejects(rpcCall('connection.status', {}), (error) => {
|
||||
assert.equal(error.code, LOOPBACK_RECOVERY_ERROR_CODE);
|
||||
assert.equal(error.message, LOOPBACK_RECOVERY_ERROR_MESSAGE);
|
||||
assert.equal(error.cause, original);
|
||||
assert.equal(error.recoveryUrl, 'http://localhost:3080/settings');
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
url: 'http://localhost:3080/settings',
|
||||
origin: 'http://localhost:3080',
|
||||
});
|
||||
});
|
||||
|
||||
test('RPC wrappers preserve successful results, unrelated errors, and missing optional calls', async () => {
|
||||
const result = { ok: true, value: { connected: true } };
|
||||
const successful = createLoopbackAwareRpcCall(async (...args) => ({ result, args }), {
|
||||
location: { href: 'http://127.0.0.1:3080/' },
|
||||
});
|
||||
assert.deepEqual(await successful('connection.status', { botId: 'bot-1' }), {
|
||||
result,
|
||||
args: ['connection.status', { botId: 'bot-1' }],
|
||||
});
|
||||
|
||||
const original = new Error('transport failure for /weixin/connection.status: HTTP 500');
|
||||
const failing = createLoopbackAwareRpcCall(async () => { throw original; }, {
|
||||
location: { href: 'http://127.0.0.1:3080/' },
|
||||
});
|
||||
await assert.rejects(failing(), (error) => error === original);
|
||||
|
||||
const calls = createLoopbackAwareRpcCalls({
|
||||
weixinRpcCall: async () => result,
|
||||
optionalRpcCall: undefined,
|
||||
}, {
|
||||
location: { href: 'http://127.0.0.1:3080/' },
|
||||
});
|
||||
assert.equal(calls.optionalRpcCall, undefined);
|
||||
assert.equal(await calls.weixinRpcCall(), result);
|
||||
});
|
||||
|
||||
test('recovery notice exposes one localized navigation action', () => {
|
||||
const recovery = {
|
||||
url: 'http://localhost:3080/settings?tab=plugins#im',
|
||||
origin: 'http://localhost:3080',
|
||||
};
|
||||
const element = LoopbackRecoveryNotice({ recovery, onNavigate() {} });
|
||||
const markup = renderToStaticMarkup(element);
|
||||
|
||||
assert.match(markup, /role="alert"/);
|
||||
assert.match(markup, /请改用 localhost 重新打开/);
|
||||
assert.match(markup, /http:\/\/localhost:3080/);
|
||||
assert.match(markup, /<button[^>]*>使用 localhost 重新打开<\/button>/);
|
||||
});
|
||||
|
||||
test('recovery navigation replaces the current history entry', () => {
|
||||
let replacedWith;
|
||||
replacePageLocation('http://localhost:3080/settings', {
|
||||
replace(url) { replacedWith = url; },
|
||||
});
|
||||
assert.equal(replacedWith, 'http://localhost:3080/settings');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue