feat: 工作台 proxy 深度改造(Muximux 模式)——/proxy/{slug}/ 路径前缀 + HTML 改写 + 运行时 shim + WebSocket + cookie 鉴权 + 按面板信任模型 sandbox,支持嵌入几乎所有 SPA
This commit is contained in:
@@ -21,6 +21,7 @@ import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
@@ -29,9 +30,35 @@ import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
const EMPTY = {
|
||||
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
||||
icon: '', category: '默认', version: '', sort_order: '0',
|
||||
slug: '', trusted: true, permissions: [], scale: '1',
|
||||
};
|
||||
|
||||
/** 面板链接管理:CRUD + 代理嵌入开关 + 面板代理白名单(迁移自 v1 面板链接卡片) */
|
||||
/* 面板代理配置字段的展示与编辑选项(与工作台 PanelFrame 的委托权限一致) */
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ value: 'camera', label: '摄像头' },
|
||||
{ value: 'microphone', label: '麦克风' },
|
||||
{ value: 'geolocation', label: '定位' },
|
||||
{ value: 'clipboard-read', label: '剪贴板读取' },
|
||||
{ value: 'clipboard-write', label: '剪贴板写入' },
|
||||
{ value: 'payment', label: '支付' },
|
||||
{ value: 'usb', label: 'USB' },
|
||||
{ value: 'serial', label: '串口' },
|
||||
{ value: 'notifications', label: '通知' },
|
||||
];
|
||||
|
||||
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
|
||||
function parsePermissions(raw) {
|
||||
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 面板链接管理:CRUD + 代理嵌入开关 + 信任模式/缩放/权限 + 面板代理白名单 */
|
||||
export default function Links() {
|
||||
const [links, setLinks] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
@@ -60,6 +87,10 @@ export default function Links() {
|
||||
url: l.url,
|
||||
embed_url: l.embed_url || '',
|
||||
use_proxy: !!l.use_proxy,
|
||||
slug: l.slug || '',
|
||||
trusted: !(l.trusted === 0 || l.trusted === '0'),
|
||||
permissions: parsePermissions(l.permissions),
|
||||
scale: String(parseFloat(l.scale) || 1),
|
||||
description: l.description || '',
|
||||
icon: l.icon || '',
|
||||
category: l.category || '默认',
|
||||
@@ -76,6 +107,10 @@ export default function Links() {
|
||||
url: form.url.trim(),
|
||||
embed_url: form.embed_url.trim(),
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
slug: form.slug.trim(),
|
||||
trusted: form.trusted ? 1 : 0,
|
||||
permissions: JSON.stringify(form.permissions),
|
||||
scale: parseFloat(form.scale) || 1,
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
@@ -132,24 +167,43 @@ export default function Links() {
|
||||
<TableCell>版本</TableCell>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>嵌入URL</TableCell>
|
||||
<TableCell>代理配置</TableCell>
|
||||
<TableCell>分类</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : links.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
|
||||
) : links.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.sort_order}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ fontWeight: 600 }}>{l.title}</Box>
|
||||
{l.slug && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', fontSize: 12, fontFamily: 'monospace' }}>/{l.slug}/</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 180 }}>
|
||||
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{l.use_proxy ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={l.trusted === 0 || l.trusted === '0' ? '代理·安全' : '代理·可信'}
|
||||
color={l.trusted === 0 || l.trusted === '0' ? 'default' : 'success'}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : <Chip size="small" label="直嵌" variant="outlined" />}
|
||||
{parseFloat(l.scale) > 0 && parseFloat(l.scale) !== 1 && (
|
||||
<Box component="span" sx={{ ml: 0.75, color: 'text.secondary', fontSize: 12 }}>缩放 {parseFloat(l.scale)}x</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||
@@ -189,6 +243,44 @@ export default function Links() {
|
||||
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
|
||||
<TextField fullWidth label="嵌入 URL(iframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
|
||||
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
|
||||
{form.use_proxy && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理路径标识 slug"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
|
||||
margin="dense"
|
||||
placeholder="留空按标题自动生成"
|
||||
helperText="仅小写字母/数字/连字符,≤32 字符;iframe 走 /proxy/{slug}/"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.trusted} onChange={(e) => setForm((p) => ({ ...p, trusted: e.target.checked }))} />}
|
||||
label="可信模式(保留 allow-same-origin,目标站 cookie 登录态可用)"
|
||||
/>
|
||||
<TextField
|
||||
label="缩放(0.1–3)"
|
||||
type="number"
|
||||
value={form.scale}
|
||||
onChange={(e) => setForm((p) => ({ ...p, scale: e.target.value }))}
|
||||
margin="dense"
|
||||
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
|
||||
sx={{ maxWidth: 160 }}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
fullWidth
|
||||
size="small"
|
||||
options={PERMISSION_OPTIONS}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
|
||||
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="委托权限(可选,iframe allow 属性)" margin="dense" placeholder="摄像头 / 麦克风 / 定位等" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
|
||||
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||
|
||||
@@ -21,7 +21,8 @@ import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePan
|
||||
/* ============================================================
|
||||
* AddPanelDialog:添加面板
|
||||
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
||||
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API)
|
||||
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托;
|
||||
* 保存到 admin_links(复用现有 API,trusted/scale/permissions 为代理新字段)
|
||||
* ============================================================ */
|
||||
|
||||
const MODE_META = [
|
||||
@@ -30,6 +31,19 @@ const MODE_META = [
|
||||
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
|
||||
];
|
||||
|
||||
/* iframe 委托权限候选(对应 Permissions Policy 的 allow 属性 token) */
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ value: 'camera', label: '摄像头' },
|
||||
{ value: 'microphone', label: '麦克风' },
|
||||
{ value: 'geolocation', label: '定位' },
|
||||
{ value: 'clipboard-read', label: '剪贴板读取' },
|
||||
{ value: 'clipboard-write', label: '剪贴板写入' },
|
||||
{ value: 'payment', label: '支付' },
|
||||
{ value: 'usb', label: 'USB' },
|
||||
{ value: 'serial', label: '串口' },
|
||||
{ value: 'notifications', label: '通知' },
|
||||
];
|
||||
|
||||
function normalizeUrl(raw) {
|
||||
const s = (raw || '').trim();
|
||||
if (!s) return '';
|
||||
@@ -40,9 +54,16 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
const [form, setForm] = useState({
|
||||
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||
use_proxy: false, embed_url: '',
|
||||
trusted: true, scale: '1', permissions: [],
|
||||
});
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const resetForm = () => setForm({
|
||||
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||
use_proxy: false, embed_url: '',
|
||||
trusted: true, scale: '1', permissions: [],
|
||||
});
|
||||
|
||||
const host = useMemo(() => {
|
||||
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
|
||||
}, [form.url]);
|
||||
@@ -61,6 +82,9 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
embed_url: form.embed_url.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
trusted: form.trusted ? 1 : 0,
|
||||
scale: parseFloat(form.scale) || 1,
|
||||
permissions: JSON.stringify(form.permissions),
|
||||
description: '',
|
||||
icon: '',
|
||||
sort_order: 0,
|
||||
@@ -68,7 +92,7 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
});
|
||||
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
||||
setErr('');
|
||||
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
|
||||
resetForm();
|
||||
onSaved(panel);
|
||||
} catch (e) {
|
||||
setErr(e.message || '保存失败');
|
||||
@@ -176,6 +200,53 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
margin="dense"
|
||||
placeholder="留空则使用上面的链接"
|
||||
/>
|
||||
|
||||
{/* 代理新字段:信任模式 / 缩放 / 权限委托 */}
|
||||
<Box sx={{ mt: 2, pt: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: 'text.secondary' }}>
|
||||
信任模式(影响 iframe 沙箱)
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
size="small"
|
||||
value={form.trusted ? 'trusted' : 'safe'}
|
||||
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, trusted: v === 'trusted' })); }}
|
||||
aria-label="信任模式"
|
||||
>
|
||||
<ToggleButton value="trusted" sx={{ py: 0.75 }}>可信(保留登录态)</ToggleButton>
|
||||
<ToggleButton value="safe" sx={{ py: 0.75 }}>安全(隔离沙箱)</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
|
||||
{form.trusted
|
||||
? '可信:iframe 保留 allow-same-origin,目标站 cookie 登录态可用'
|
||||
: '安全:剥离 allow-same-origin(opaque origin),被代理页无法访问本站'}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="缩放(0.1–3,默认 1)"
|
||||
type="number"
|
||||
value={form.scale}
|
||||
onChange={set('scale')}
|
||||
margin="dense"
|
||||
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
|
||||
helperText="小于 1 缩小(看得更多),大于 1 放大(看得更清)"
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
multiple
|
||||
fullWidth
|
||||
size="small"
|
||||
options={PERMISSION_OPTIONS}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
|
||||
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="委托权限(可选)" margin="dense" placeholder="选择 iframe 可调用的设备权限" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -17,21 +17,33 @@ import { getToken } from '../../../api/client.js';
|
||||
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
|
||||
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
|
||||
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
||||
* - proxy 模式:use_proxy=1 或 HTTPS 页内嵌 HTTP 目标时走
|
||||
* /api/proxy/fetch?url=&token=(proxy 剥 X-Frame-Options/CSP)。
|
||||
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
|
||||
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
|
||||
* - 站内 URL(/ 开头)与 proxy 模式均为本站同源伺服内容:沙箱剥离 allow-same-origin
|
||||
* (opaque origin),防其访问父页面 DOM / localStorage / 以本站身份发请求;
|
||||
* 跨源面板(src 直指外部域)保留 allow-same-origin(多数站点依赖 cookie 登录态)。
|
||||
* - 代理模式(use_proxy=1 或 HTTPS 页内嵌 HTTP 目标):走
|
||||
* /proxy/{slug}/ 前缀代理(后端 HTML 改写 + shim 注入 + WebSocket
|
||||
* + 全方法透传)。鉴权由后端处理:首次加载用 Authorization header
|
||||
* 预热一次(触发下发 HttpOnly 的 rwp_{slug} 面板 cookie,Path 限定
|
||||
* /proxy/{slug}/,12h 有效),iframe src 本身不带任何 token,
|
||||
* 被代理页脚本读不到凭据。旧面板无 slug 时回退 /api/proxy/fetch
|
||||
* 单 URL 透传(保留兼容,短 TTL token 5 分钟)。
|
||||
* - 信任模型(admin_links.trusted)→ sandbox:
|
||||
* trusted=1(默认):保留 allow-same-origin(保目标站 cookie 登录态),
|
||||
* 追加 allow-modals / allow-downloads;
|
||||
* trusted=0:剥离 allow-same-origin(opaque origin 安全模式)。
|
||||
* 站内 URL(/ 开头)直嵌一律剥离 allow-same-origin——本站同源伺服,
|
||||
* 保留即等同 sandbox 逃逸,与信任开关无关。
|
||||
* - permissions 委托:面板配置的权限数组 → iframe allow 属性
|
||||
* - scale 缩放:外盒 1/scale + transform: scale 实现任意缩放(0.1-3)
|
||||
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
|
||||
* ============================================================ */
|
||||
|
||||
const LOAD_TIMEOUT_MS = 15000;
|
||||
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 5 分钟有效)
|
||||
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
|
||||
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
|
||||
const WARM_TTL_MS = 10 * 60 * 60 * 1000; // 面板 cookie 12h 有效,预热缓存 10h 内复用
|
||||
const TOKEN_TTL_MS = 4 * 60 * 1000; // 旧 /fetch 兼容:短 token 缓存 4 分钟
|
||||
|
||||
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
|
||||
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
|
||||
const warmCache = new Map();
|
||||
|
||||
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
|
||||
let proxyTokenCache = { promise: null, expiresAt: 0 };
|
||||
|
||||
function fetchProxyToken() {
|
||||
@@ -54,6 +66,36 @@ function fetchProxyToken() {
|
||||
return p;
|
||||
}
|
||||
|
||||
/* 预热 /proxy/{slug}/:带主 token 的 fetch 触发后端下发 rwp_{slug} 面板 cookie
|
||||
* (HttpOnly,面板脚本不可读),之后 iframe 同源加载自动携带该 cookie 鉴权。 */
|
||||
function warmProxy(slug) {
|
||||
const now = Date.now();
|
||||
const hit = warmCache.get(slug);
|
||||
if (hit && now - hit.at < WARM_TTL_MS) return hit.p;
|
||||
const p = fetch('/proxy/' + slug + '/', {
|
||||
headers: { Authorization: 'Bearer ' + (getToken() || '') },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('面板代理鉴权失败');
|
||||
return true;
|
||||
})
|
||||
.catch((e) => { warmCache.delete(slug); throw e; });
|
||||
warmCache.set(slug, { p, at: now });
|
||||
return p;
|
||||
}
|
||||
|
||||
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
|
||||
function parsePermissions(raw) {
|
||||
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
|
||||
const [mounted, setMounted] = useState(active);
|
||||
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
|
||||
@@ -67,37 +109,65 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
const targetUrl = panel.embed_url || panel.url || '';
|
||||
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
|
||||
// 站内 URL(/ 开头)与 proxy 模式(外部 HTML 经 /api/proxy/fetch 在本站同源伺服):
|
||||
// 沙箱均剥离 allow-same-origin → 内容变为 opaque origin,无法访问父页面 DOM / localStorage /
|
||||
// 以本站身份发请求 / 开本站 WebSocket(H2 修复,阻断被代理页偷 JWT、冒充 admin、开 /ws/terminal)。
|
||||
// 权衡:proxy 目标若依赖 cookie/登录态会受影响(部分面板如此),安全优先。
|
||||
// 跨源直嵌(src 直接指向外部域)才保留 allow-same-origin:此时 iframe 与父页面本就不同源,
|
||||
// 不构成同源逃逸,且多数外部面板依赖 cookie 登录态。
|
||||
const isInternal = targetUrl.startsWith('/');
|
||||
const stripSameOrigin = isInternal || needsProxy;
|
||||
|
||||
// slug:后端自动生成(小写 [a-z0-9-],≤32)。旧库行可能为空 → 回退旧 /fetch
|
||||
const slug = String(panel.slug || '').toLowerCase();
|
||||
const hasSlug = /^[a-z0-9-]{1,32}$/.test(slug);
|
||||
const proxyPrefix = hasSlug ? '/proxy/' + slug + '/' : '';
|
||||
|
||||
// 信任模型:admin_links.trusted(1=可信,0=安全)。缺失按可信处理
|
||||
const trusted = !(panel.trusted === 0 || panel.trusted === '0');
|
||||
|
||||
// ── sandbox 按信任模型配置 ────────────────────────────────
|
||||
// 代理模式:trusted=1 保 allow-same-origin(保目标站 cookie 登录态);
|
||||
// trusted=0 剥离(opaque origin,被代理页无法以本站身份发请求/读 DOM)
|
||||
// 站内直嵌(/ 开头,本站同源伺服):一律剥离(同源逃逸风险,与信任无关)
|
||||
// 外部直嵌:跨源天然隔离,trusted=1 保 allow-same-origin(站点 cookie 依赖)
|
||||
const stripSameOrigin = needsProxy
|
||||
? !trusted
|
||||
: (targetUrl.startsWith('/') || !trusted);
|
||||
const sandbox = stripSameOrigin
|
||||
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
|
||||
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
||||
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads';
|
||||
|
||||
// ── permissions 委托:默认能力 + 面板配置的权限 → allow 属性 ──
|
||||
const baseAllow = 'fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
||||
const delegated = parsePermissions(panel.permissions);
|
||||
const allowAttr = delegated.length ? [baseAllow, ...delegated].join('; ') : baseAllow;
|
||||
|
||||
// ── scale 缩放(0.1-3,默认 1):1/scale 外盒 + transform scale ──
|
||||
const scale = (() => {
|
||||
const s = parseFloat(panel.scale);
|
||||
return Number.isFinite(s) ? Math.min(3, Math.max(0.1, s)) : 1;
|
||||
})();
|
||||
|
||||
// 代理模式启动:预热(slug 模式)或取短 token(旧模式),成功后就绪 src。
|
||||
// alive 守卫避免卸载后 setState;预热失败进入超时回退态。
|
||||
const bootProxy = useCallback((alive = () => true) => {
|
||||
if (!needsProxy) { setProxySrc(targetUrl); return; }
|
||||
setProxySrc('');
|
||||
if (hasSlug) {
|
||||
warmProxy(slug)
|
||||
.then(() => { if (alive()) setProxySrc(proxyPrefix); })
|
||||
.catch(() => {
|
||||
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||
});
|
||||
} else {
|
||||
fetchProxyToken()
|
||||
.then((tok) => {
|
||||
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||
});
|
||||
}
|
||||
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
|
||||
|
||||
// proxy 模式:先取 5 分钟短 TTL token 再拼 iframe URL(失败进入超时回退态)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
|
||||
setProxySrc('');
|
||||
fetchProxyToken()
|
||||
.then((tok) => {
|
||||
if (alive) {
|
||||
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) {
|
||||
statusRef.current = 'timeout';
|
||||
setStatus('timeout');
|
||||
}
|
||||
});
|
||||
bootProxy(() => alive);
|
||||
return () => { alive = false; };
|
||||
}, [needsProxy, targetUrl]);
|
||||
}, [bootProxy]);
|
||||
|
||||
const src = needsProxy ? proxySrc : targetUrl;
|
||||
|
||||
@@ -126,18 +196,30 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [mounted, src]);
|
||||
|
||||
// 刷新信号(工具栏 ↻):仅对激活面板 reload,且重新进入加载态
|
||||
// 统一刷新入口:从未就绪(预热/token 失败)时重新走启动路径;
|
||||
// 已就绪则先重新预热(cookie 过期时刷新也能恢复),再 reload。
|
||||
const performReload = () => {
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
setShowHint(false);
|
||||
startTimer();
|
||||
if (!src) { bootProxy(); return; }
|
||||
const reloadNow = () => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
};
|
||||
if (needsProxy && hasSlug) warmProxy(slug).then(reloadNow).catch(reloadNow);
|
||||
else reloadNow();
|
||||
};
|
||||
|
||||
// 刷新信号(工具栏 ↻):仅对激活面板执行统一刷新
|
||||
const lastNonce = useRef(refreshNonce);
|
||||
useEffect(() => {
|
||||
if (!active || refreshNonce === lastNonce.current) return;
|
||||
lastNonce.current = refreshNonce;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
performReload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshNonce]);
|
||||
|
||||
@@ -155,13 +237,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
performReload();
|
||||
};
|
||||
|
||||
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
|
||||
@@ -174,15 +250,25 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
}}
|
||||
>
|
||||
{mounted && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={panel.title || '面板'}
|
||||
src={src}
|
||||
onLoad={handleLoad}
|
||||
sandbox={sandbox}
|
||||
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||
/>
|
||||
/* scale 缩放:外盒宽高取 1/scale(相对容器),transform scale(scale) 后
|
||||
* 恰好铺满容器;scale=1 时恒等,无副作用 */
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: `${100 / scale}%`, height: `${100 / scale}%`,
|
||||
transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={panel.title || '面板'}
|
||||
src={src}
|
||||
onLoad={handleLoad}
|
||||
sandbox={sandbox}
|
||||
allow={allowAttr}
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
|
||||
@@ -7,6 +7,11 @@ import { listAdminLinks } from '../../../api/adminLinks.js';
|
||||
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
||||
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
||||
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStorage(key: workbench.*)。
|
||||
*
|
||||
* 面板对象直接来自后端 admin_links 全行(SELECT *),已含代理新字段:
|
||||
* slug(唯一标识)/ trusted(信任模型 0|1)/ permissions(JSON 数组字符串)/
|
||||
* scale(缩放 0.1-3)。加载与 panelOverride 场景统一经 normalizePanel 归一化,
|
||||
* 兜底旧库行缺失字段,保证 PanelFrame 拿到的字段类型稳定。
|
||||
* ============================================================ */
|
||||
|
||||
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
|
||||
@@ -33,6 +38,20 @@ function write(key, value) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
|
||||
}
|
||||
|
||||
/* 面板字段归一化:旧库行可能缺代理新字段,统一兜底默认值
|
||||
* (slug 空串、trusted 默认 1、permissions 原样保留、scale 默认 1) */
|
||||
function normalizePanel(p) {
|
||||
if (!p || typeof p !== 'object') return p;
|
||||
const scale = parseFloat(p.scale);
|
||||
return {
|
||||
...p,
|
||||
slug: String(p.slug || ''),
|
||||
trusted: p.trusted === 0 || p.trusted === '0' ? 0 : 1,
|
||||
permissions: p.permissions || '',
|
||||
scale: Number.isFinite(scale) ? Math.min(3, Math.max(0.1, scale)) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export default function usePanels() {
|
||||
const [links, setLinks] = useState(null); // null = 加载中
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -82,7 +101,7 @@ export default function usePanels() {
|
||||
setLinks(null);
|
||||
return listAdminLinks()
|
||||
.then((ls) => {
|
||||
const arr = Array.isArray(ls) ? ls : [];
|
||||
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
|
||||
setLinks(arr);
|
||||
const valid = new Set(arr.map((p) => String(p.id)));
|
||||
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
|
||||
@@ -118,7 +137,7 @@ export default function usePanels() {
|
||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||
const openPanel = useCallback((id, panelOverride) => {
|
||||
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
|
||||
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
|
||||
const panel = normalizePanel(panelOverride || (links && links.find((p) => String(p.id) === id)));
|
||||
if (!panel) return false;
|
||||
touch(id);
|
||||
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||
|
||||
Reference in New Issue
Block a user