mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
feat(plugins): confirm external links from management pages (#635)
Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -692,7 +692,12 @@ keep、replace 或 delete;全部完成后宿主才原子提交新配置与状
|
||||
宿主注入只读的 `window.codexProxyPlugin`。`request({ method, path, query?, contentType?, body? })` 只能访问当前版本
|
||||
已注册的管理路由,返回 `{ status, contentType, body: ArrayBuffer }`;`callbackTicket({ path, ttlSeconds })` 为已注册
|
||||
回调签发 1–600 秒的一次性地址;`resourceUrl(path)` 只解析同一制品已注册的非 HTML 资源。`theme` 为当前
|
||||
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。页面不能指定其他实例、制品、版本、URL 或请求 header
|
||||
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。管理请求不能指定其他实例、制品、版本、URL 或请求 header
|
||||
|
||||
需要跳转到外部页面时,在插件页面的用户点击处理器中调用 `openExternal(url): Promise<void>`。
|
||||
地址必须是绝对 HTTPS URL,不能包含用户名或密码。宿主展示目标主机名和非默认端口,管理员可打开新标签页、复制链接或取消。
|
||||
Promise 成功只表示宿主已接受确认请求,不代表链接已打开;无用户操作、地址无效或已有链接待确认时,请求会被拒绝。
|
||||
切换页面会关闭确认框。兼容较早的 v2 宿主时,先检查 `typeof window.codexProxyPlugin.openExternal === 'function'`
|
||||
|
||||
普通模型请求使用标准 `Response` 接口:
|
||||
|
||||
|
||||
@@ -3232,6 +3232,7 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据
|
||||
`If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。
|
||||
两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe,
|
||||
仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。
|
||||
插件页面可通过宿主桥请求打开 HTTPS 外链,管理员在宿主确认后才打开新标签页。
|
||||
页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见
|
||||
[SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli)
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ flowchart LR
|
||||
|
||||
| 功能 | 使用方式 |
|
||||
| --- | --- |
|
||||
| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 |
|
||||
| 管理页面 | 从插件详情或左侧插件导航打开;插件请求打开外链时,核对目标域名后可打开、复制或取消 |
|
||||
| 自动维护 | 启用、重启及账号等配置变化后自动对账;失败重试,周期补漏 |
|
||||
| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE |
|
||||
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "Model response read failed",
|
||||
"duplicateManagementRequest": "Duplicate plugin management request ID",
|
||||
"managementConcurrencyLimit": "Plugin management concurrency limit reached",
|
||||
"openExternalLink": "Open external link",
|
||||
"openLink": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"externalLinkPending": "An external link is already awaiting confirmation",
|
||||
"externalLinkRequiresClick": "Click a link or button on the plugin page",
|
||||
"invalidExternalLink": "Use an absolute HTTPS URL without a username or password",
|
||||
"invalidEntryPoint": "Plugin page entry point is not a registered HTML resource",
|
||||
"unregisteredAsset": "Plugin page references an unregistered resource: {path}",
|
||||
"htmlSubresource": "Plugin HTML resources cannot be loaded as subresources",
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "模型响应读取失败",
|
||||
"duplicateManagementRequest": "插件管理请求序号重复",
|
||||
"managementConcurrencyLimit": "插件管理请求已达并发上限",
|
||||
"openExternalLink": "打开外部链接",
|
||||
"openLink": "打开",
|
||||
"copyLink": "复制链接",
|
||||
"externalLinkPending": "请先处理当前外部链接",
|
||||
"externalLinkRequiresClick": "请在插件页面中点击链接或按钮",
|
||||
"invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址",
|
||||
"invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源",
|
||||
"unregisteredAsset": "插件页面引用了未注册资源:{path}",
|
||||
"htmlSubresource": "插件 HTML 资源不能作为子资源加载",
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "模型響應讀取失敗",
|
||||
"duplicateManagementRequest": "插件管理請求序號重複",
|
||||
"managementConcurrencyLimit": "插件管理請求已達併發上限",
|
||||
"openExternalLink": "開啟外部連結",
|
||||
"openLink": "開啟",
|
||||
"copyLink": "複製連結",
|
||||
"externalLinkPending": "請先處理目前的外部連結",
|
||||
"externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕",
|
||||
"invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址",
|
||||
"invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源",
|
||||
"unregisteredAsset": "插件頁面引用了未註冊資源:{path}",
|
||||
"htmlSubresource": "插件 HTML 資源不能作爲子資源加載",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ZButton, ZDialog } from '@codex-proxy/ui'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCopyText } from '@/composables/useCopyText'
|
||||
|
||||
const props = defineProps<{ link: URL }>()
|
||||
defineEmits<{ closed: [] }>()
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const copyText = useCopyText()
|
||||
const open = shallowRef(false)
|
||||
|
||||
// 确认框按请求挂载,挂载后打开才会触发入场动画
|
||||
onMounted(() => {
|
||||
open.value = true
|
||||
})
|
||||
|
||||
function openLink() {
|
||||
window.open(props.link.href, '_blank', 'noopener,noreferrer')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZDialog
|
||||
v-model="open"
|
||||
:title="t('plugins.frame.openExternalLink')"
|
||||
width="28rem"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #description>
|
||||
<bdi dir="ltr" class="break-all">{{ link.host }}</bdi>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ZButton @click="open = false">
|
||||
{{ t('common.actions.cancel') }}
|
||||
</ZButton>
|
||||
<ZButton @click="copyText(link.href)">
|
||||
{{ t('plugins.frame.copyLink') }}
|
||||
</ZButton>
|
||||
<ZButton type="primary" @click="openLink">
|
||||
{{ t('plugins.frame.openLink') }}
|
||||
</ZButton>
|
||||
</template>
|
||||
</ZDialog>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { useEventListener, useResizeObserver } from '@vueuse/core'
|
||||
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useThemeStore } from '@/stores/modules/theme'
|
||||
import { usePluginFrameSession } from '../composables/usePluginFrameSession'
|
||||
import PluginExternalLinkDialog from './PluginExternalLinkDialog.vue'
|
||||
import PluginPageLoading from './PluginPageLoading.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -20,7 +21,7 @@ const themeStore = useThemeStore()
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('container')
|
||||
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
|
||||
const viewportHeight = shallowRef(0)
|
||||
const { srcdoc, loading, loadError, contentHeight, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
|
||||
const { srcdoc, loading, loadError, contentHeight, externalLink, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
|
||||
view: () => props.view,
|
||||
page: () => props.page,
|
||||
iframe: iframeRef,
|
||||
@@ -73,5 +74,6 @@ onMounted(pushLayout)
|
||||
referrerpolicy="no-referrer"
|
||||
@load="handleFrameLoad"
|
||||
/>
|
||||
<PluginExternalLinkDialog v-if="externalLink" :link="externalLink" @closed="externalLink = undefined" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function usePluginFrameSession(options: {
|
||||
ZNotification.error({ message, deduplicate: true })
|
||||
})
|
||||
const contentHeight = shallowRef(0)
|
||||
const externalLink = shallowRef<URL>()
|
||||
const session = shallowRef<FrameSession>()
|
||||
let loadController: AbortController | undefined
|
||||
let previousViewportHeight = -1
|
||||
@@ -62,6 +63,7 @@ export function usePluginFrameSession(options: {
|
||||
loadController = undefined
|
||||
const current = session.value
|
||||
session.value = undefined
|
||||
externalLink.value = undefined
|
||||
srcdoc.value = ''
|
||||
contentHeight.value = 0
|
||||
previousViewportHeight = -1
|
||||
@@ -290,6 +292,20 @@ export function usePluginFrameSession(options: {
|
||||
}, '*', transfer)
|
||||
}
|
||||
|
||||
function requestExternalLink(value: unknown) {
|
||||
if (externalLink.value)
|
||||
throw new Error(t('plugins.frame.externalLinkPending'))
|
||||
// 短暂激活只能用于限制提示,打开地址仍须管理员在宿主内确认
|
||||
if (!navigator.userActivation?.isActive || document.activeElement !== options.iframe.value)
|
||||
throw new Error(t('plugins.frame.externalLinkRequiresClick'))
|
||||
const url = typeof value === 'string'
|
||||
? URL.parse(value)
|
||||
: null
|
||||
if (!url || url.protocol !== 'https:' || url.username || url.password)
|
||||
throw new Error(t('plugins.frame.invalidExternalLink'))
|
||||
externalLink.value = url
|
||||
}
|
||||
|
||||
async function handleBridgeMessage(event: MessageEvent) {
|
||||
const current = session.value
|
||||
const source = event.source
|
||||
@@ -324,7 +340,7 @@ export function usePluginFrameSession(options: {
|
||||
current.models.cancel(frame, event.data.id)
|
||||
return
|
||||
}
|
||||
if (!['request', 'callback-ticket'].includes(event.data.type))
|
||||
if (!['request', 'callback-ticket', 'open-external'].includes(event.data.type))
|
||||
return
|
||||
if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) {
|
||||
postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest'))
|
||||
@@ -336,9 +352,16 @@ export function usePluginFrameSession(options: {
|
||||
}
|
||||
current.requestIds.add(event.data.id)
|
||||
try {
|
||||
if (event.data.type === 'request')
|
||||
if (event.data.type === 'request') {
|
||||
await handleRouteRequest(current, frame, event.data)
|
||||
else await handleCallbackTicket(current, frame, event.data)
|
||||
}
|
||||
else if (event.data.type === 'callback-ticket') {
|
||||
await handleCallbackTicket(current, frame, event.data)
|
||||
}
|
||||
else {
|
||||
requestExternalLink(event.data.payload)
|
||||
postResult(current, frame, event.data.id, true)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (session.value !== current || current.controller.signal.aborted)
|
||||
@@ -412,6 +435,7 @@ export function usePluginFrameSession(options: {
|
||||
loading,
|
||||
loadError,
|
||||
contentHeight,
|
||||
externalLink,
|
||||
frameKey,
|
||||
handleFrameLoad,
|
||||
pushLayout,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4
|
||||
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
|
||||
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
|
||||
|
||||
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
|
||||
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'open-external' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
|
||||
export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error'
|
||||
|
||||
export interface PluginBridgeMessage {
|
||||
@@ -63,6 +63,7 @@ export interface PluginHostBridge {
|
||||
readonly theme: 'light' | 'dark'
|
||||
request: (input: PluginManagementRequest) => Promise<PluginManagementReply>
|
||||
callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise<PluginCallbackTicket>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
resourceUrl: (path: string) => string
|
||||
readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise<Response> }>
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
|
||||
isDisposed: () => disposed,
|
||||
})
|
||||
|
||||
function invoke<T>(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise<T> {
|
||||
function invoke<T>(kind: 'request' | 'callback-ticket' | 'open-external', payload: unknown, transfer: Transferable[] = []): Promise<T> {
|
||||
if (disposed)
|
||||
return Promise.reject(new Error('Plugin page is closed'))
|
||||
if (pending.size >= maximumInFlight)
|
||||
@@ -254,6 +254,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
|
||||
get theme() { return currentTheme },
|
||||
request,
|
||||
callbackTicket,
|
||||
openExternal: url => invoke<void>('open-external', url),
|
||||
models: Object.freeze({ responses: modelBridge.responses }),
|
||||
resourceUrl(path: string) {
|
||||
const key = String(path)
|
||||
|
||||
Reference in New Issue
Block a user