From 7eb5cebcb66b1474486ad577ea985ef3afc9da66 Mon Sep 17 00:00:00 2001 From: wuxs Date: Mon, 27 Jul 2026 01:23:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=BA=94=E7=94=A8=EF=BC=88Chrome=20?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC=E5=A4=9A=E6=A0=87=E7=AD=BE=E9=A1=B5=20+=20if?= =?UTF-8?q?rame=20=E6=99=BA=E8=83=BD=E6=B7=B7=E5=90=88=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 (pkg/console): - handlers_browser.go: 反向代理剥离 X-Frame-Options/CSP 等嵌套限制头并注入 ; /probe 端点 HEAD 检测目标能否被 iframe 直连(前端受同源策略限制无法自行检测) - browser_store.go: 书签/历史 JSON 原子持久化(RWMutex + tmp+rename) - server.go / config.go / main.go: 接入 Config 字段 + Server 字段 + 路由 前端 (console-ui): - pages/Browser.jsx: Chrome 风格 UI(多标签页 / 地址栏导航 / 书签 / 历史 / 新标签页) - 智能混合: 导航乐观 iframe 直连原始 URL,后台 probe 检测到拦截头才切代理 - hooks/useApi.js: useBookmarks/useHistory + 增删/probe helper 已知限制: 强反点击劫持的外部大站(frame-ancestors 'none' + JS 检测)即便走代理仍可能空白 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/devbox/main.go | 2 + console-ui/src/components/AppShell.jsx | 2 + console-ui/src/data/systemApps.js | 1 + console-ui/src/hooks/useApi.js | 59 +++ console-ui/src/pages/Browser.jsx | 436 ++++++++++++++++++ pkg/config/config.go | 6 + pkg/console/browser_store.go | 173 +++++++ pkg/console/browser_store_test.go | 107 +++++ pkg/console/dist/assets/index-BPLAoxgS.js | 173 ------- pkg/console/dist/assets/index-C5nokkIU.js | 181 ++++++++ ...{index-BKESsNP_.css => index-DCcHkGpE.css} | 2 +- pkg/console/dist/index.html | 4 +- pkg/console/handlers_browser.go | 427 +++++++++++++++++ pkg/console/handlers_browser_test.go | 210 +++++++++ pkg/console/server.go | 11 + 15 files changed, 1618 insertions(+), 176 deletions(-) create mode 100644 console-ui/src/pages/Browser.jsx create mode 100644 pkg/console/browser_store.go create mode 100644 pkg/console/browser_store_test.go delete mode 100644 pkg/console/dist/assets/index-BPLAoxgS.js create mode 100644 pkg/console/dist/assets/index-C5nokkIU.js rename pkg/console/dist/assets/{index-BKESsNP_.css => index-DCcHkGpE.css} (65%) create mode 100644 pkg/console/handlers_browser.go create mode 100644 pkg/console/handlers_browser_test.go diff --git a/cmd/devbox/main.go b/cmd/devbox/main.go index 462dc8e..b9b8a6f 100644 --- a/cmd/devbox/main.go +++ b/cmd/devbox/main.go @@ -85,6 +85,8 @@ func main() { AuthPassword: cfg.Auth.Password, AuthSessionTTL: cfg.Auth.SessionTTL, LinksPath: cfg.Console.LinksPath, + BrowserDataPath: cfg.Console.BrowserDataPath, + BrowserInsecureTLS: cfg.Console.BrowserInsecureTLS, }, col, appMgr, storeMgr) go func() { diff --git a/console-ui/src/components/AppShell.jsx b/console-ui/src/components/AppShell.jsx index a632824..00882d4 100644 --- a/console-ui/src/components/AppShell.jsx +++ b/console-ui/src/components/AppShell.jsx @@ -13,6 +13,7 @@ import DiskManager from '../pages/DiskManager' import NetworkConnections from '../pages/NetworkConnections' import MonitoringApp from '../pages/Monitoring' import AIActivity from '../pages/AIActivity' +import BrowserFace from '../pages/Browser' // Reusable face header function FaceHeader({ accent = T.blue, title, subtitle, version, kb, onMgmt, extra, errorMode }) { @@ -103,6 +104,7 @@ function AppShell({ appId, app, authed, onRequireAuth, onOpenManagement }) { if (appId === 'network-connections') return ; if (appId === 'monitoring') return ; if (appId === 'ai-activity') return ; + if (appId === 'browser') return ; // Generic iframe fallback for any installed app with a HostPort if (app) return ; return null; diff --git a/console-ui/src/data/systemApps.js b/console-ui/src/data/systemApps.js index 49f6d64..dc13824 100644 --- a/console-ui/src/data/systemApps.js +++ b/console-ui/src/data/systemApps.js @@ -27,4 +27,5 @@ export const SYSTEM_APPS = [ { id: 'alerts', kind: 'system', name: '告警中心', icon: 'bell', color: T.amber, bg: 'linear-gradient(160deg,#fbbf24,#d97706)' }, { id: 'audit', kind: 'system', name: '操作日志', icon: 'shield', color: '#7c3aed', bg: 'linear-gradient(160deg,#a78bfa,#5b21b6)' }, { id: 'settings', kind: 'system', name: '系统设置', icon: 'gear', color: T.slate, bg: 'linear-gradient(160deg,#64748b,#334155)' }, + { id: 'browser', kind: 'system', name: '浏览器', icon: 'globe', color: T.blue, bg: 'linear-gradient(160deg,#3b82f6,#1d4ed8)' }, ] diff --git a/console-ui/src/hooks/useApi.js b/console-ui/src/hooks/useApi.js index ecce2bd..5434c0d 100644 --- a/console-ui/src/hooks/useApi.js +++ b/console-ui/src/hooks/useApi.js @@ -598,3 +598,62 @@ export async function getLogs(appId, tail = 100) { return ''; } } + +// ─── Browser 应用:书签 / 历史 ─────────────────────────────────── +// 后端持久化在 /etc/devbox/browser.json(单机单用户一份)。增删后手动调 +// 返回的 refresh() 重拉(不轮询——浏览器交互是用户驱动的,不需要定时刷)。 + +export function useBookmarks() { + return usePoll('/browser/bookmarks', { fallback: [] }); +} + +export function useHistory() { + return usePoll('/browser/history', { fallback: [] }); +} + +export async function addBookmark(title, url) { + const r = await authFetch(`${API}/browser/bookmarks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, url }), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json().catch(() => null); +} + +export async function removeBookmark(id) { + const r = await authFetch(`${API}/browser/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json().catch(() => null); +} + +// 记录访问:fire-and-forget,失败不影响导航 +export async function addHistory(url, title) { + try { + await authFetch(`${API}/browser/history`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, title }), + }); + } catch { /* ignore */ } +} + +export async function clearHistory() { + const r = await authFetch(`${API}/browser/history`, { method: 'DELETE' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json().catch(() => null); +} + +// 探测目标 URL 能否被 iframe 直连(后端 HEAD 检测 X-Frame-Options / CSP frame-ancestors)。 +// 导航前调:能直连就 iframe 直连(快、无副作用),检测到拦截头才走代理。 +// 任何探测失败/异常默认 direct=true(交回前端直连,由浏览器自然报错)。 +export async function probeDirectEmbed(url) { + try { + const r = await authFetch(`${API}/browser/probe?url=${encodeURIComponent(url)}`); + if (!r.ok) return { direct: true, reason: 'probe-failed' }; + const d = await r.json(); + return d && typeof d.direct === 'boolean' ? d : { direct: true, reason: 'unknown' }; + } catch { + return { direct: true, reason: 'error' }; + } +} diff --git a/console-ui/src/pages/Browser.jsx b/console-ui/src/pages/Browser.jsx new file mode 100644 index 0000000..6164e1d --- /dev/null +++ b/console-ui/src/pages/Browser.jsx @@ -0,0 +1,436 @@ +// 浏览器应用 —— Chrome 风格的多标签页浏览器,内容区用 iframe 加载经过后端代理的网页。 +// +// 后端 /api/v1/browser/proxy?url= 会剥离 X-Frame-Options / CSP 等嵌套限制头并 +// 注入 ,让相对资源解析回原站。详见 pkg/console/handlers_browser.go。 +// +// 已知限制(iframe 固有,非 bug): +// - 父页面读不到 iframe 的 document.title,tab 标题只能用 host 占位; +// - 强反点击劫持的外部大站(Google/GitHub 等 frame-ancestors 'none')仍可能空白; +// - 被嵌页面的 cookie/login 域与 console 不同源,需在 iframe 内自行登录。 + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { T } from '../tokens'; +import { Icon } from '../icons'; +import { useBookmarks, useHistory, addBookmark, removeBookmark, addHistory, clearHistory, getAuthToken, probeDirectEmbed } from '../hooks/useApi'; + +const MONO = { fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace' }; + +// ─── helpers ──────────────────────────────────────────────────── + +function randId() { + return (crypto.randomUUID && crypto.randomUUID()) || String(Math.random()).slice(2); +} + +function titleForUrl(url) { + if (!url) return '新标签页'; + try { return new URL(url).host || url; } catch { return url; } +} + +// 地址栏输入 → 可导航 URL:已带 scheme 原样;localhost/IP/含点号补 http://;其余补 https://。 +function normalizeUrl(input) { + const v = (input || '').trim(); + if (!v) return ''; + if (/^https?:\/\//i.test(v)) return v; + const looksLikeHost = /^(localhost|\[?[0-9a-fA-F:]+\]?|(\d{1,3}\.){3}\d{1,3})/.test(v) || v.includes('.'); + return (looksLikeHost ? 'http://' : 'https://') + v; +} + +// iframe 无法注入 Authorization header,所以通过 query token 鉴权 +// (auth.Middleware 支持 r.URL.Query().Get("token"),见 pkg/auth/auth.go)。 +function proxySrc(url) { + const t = getAuthToken(); + return '/api/v1/browser/proxy?url=' + encodeURIComponent(url) + (t ? '&token=' + encodeURIComponent(t) : ''); +} + +function newTab(url = '') { + return { + id: randId(), + entries: url ? [url] : [], + index: url ? 0 : -1, + title: titleForUrl(url), + loading: !!url, + mode: 'direct', // 'direct'(iframe 直连原始 URL)| 'proxy'(走后端代理剥离嵌套限制头) + _nonce: randId(), + }; +} + +// ─── atoms ────────────────────────────────────────────────────── + +function IconBtn({ name, size = 16, stroke = 1.8, disabled, active, title, onClick, style }) { + return ( + + ); +} + +// ─── 主组件 ───────────────────────────────────────────────────── + +export default function BrowserFace() { + const { data: bookmarks, refresh: refreshBookmarks } = useBookmarks(); + const { data: history, refresh: refreshHistory } = useHistory(); + + const [tabs, setTabs] = useState(() => [newTab()]); + const [activeId, setActiveId] = useState(() => tabs[0].id); + const [address, setAddress] = useState(''); + const [panel, setPanel] = useState(null); // null | 'bookmarks' | 'history' + + const active = useMemo(() => tabs.find(t => t.id === activeId) || tabs[0], [tabs, activeId]); + const currentUrl = active && active.index >= 0 ? active.entries[active.index] : ''; + + // 切换 tab 或导航后,地址栏同步到当前 URL + useEffect(() => { setAddress(currentUrl); }, [activeId, currentUrl]); + + // ─── tab 状态更新(index 游标,避免后退分叉) ─────────────── + const patchTab = useCallback((id, fn) => { + setTabs(ts => ts.map(t => (t.id === id ? fn(t) : t))); + }, []); + + const navigate = useCallback((url) => { + if (!url) return; + patchTab(active.id, t => { + const next = t.entries.slice(0, t.index + 1); // 截断分叉 + next.push(url); + return { ...t, entries: next, index: next.length - 1, title: titleForUrl(url), loading: true, mode: 'direct', _nonce: randId() }; + }); + addHistory(url, titleForUrl(url)).then(refreshHistory); + setPanel(null); + }, [active.id, patchTab, refreshHistory]); + + const commitAddress = useCallback(() => { + const u = normalizeUrl(address); + if (u) { setAddress(u); navigate(u); } + }, [address, navigate]); + + const goBack = useCallback(() => { + patchTab(active.id, t => t.index > 0 ? { ...t, index: t.index - 1, loading: true, mode: 'direct', _nonce: randId() } : t); + }, [active.id, patchTab]); + const goFwd = useCallback(() => { + patchTab(active.id, t => t.index < t.entries.length - 1 ? { ...t, index: t.index + 1, loading: true, mode: 'direct', _nonce: randId() } : t); + }, [active.id, patchTab]); + const reload = useCallback(() => { + patchTab(active.id, t => ({ ...t, loading: true, _nonce: randId() })); + }, [active.id, patchTab]); + const goHome = useCallback(() => { + patchTab(active.id, t => ({ ...t, entries: [], index: -1, title: '新标签页', loading: false })); + setAddress(''); + }, [active.id, patchTab]); + + // 智能混合:导航乐观直连(mode='direct'),后台 probe 检测目标是否禁止 iframe 嵌套, + // 设了 X-Frame-Options / frame-ancestors 才切到代理(mode='proxy')。 + // 前端无法自行检测 iframe 是否被拒(同源策略),所以这步放后端 HEAD 探。 + useEffect(() => { + if (!active || !currentUrl) return; + const tabId = active.id; + const url = currentUrl; + probeDirectEmbed(url).then(res => { + if (res && !res.direct) { + // 防竞态:仅当该 tab 当前仍指向同一 url 时才切代理 + patchTab(tabId, t => (t.entries[t.index] === url ? { ...t, mode: 'proxy' } : t)); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active?.id, currentUrl]); + + const closeTab = useCallback((id, e) => { + if (e) e.stopPropagation(); + setTabs(ts => { + const filtered = ts.filter(t => t.id !== id); + const next = filtered.length ? filtered : [newTab()]; + if (id === activeId) setActiveId(next[next.length - 1].id); + return next; + }); + }, [activeId]); + + const openNewTab = useCallback((url = '') => { + const t = newTab(url); + setTabs(ts => [...ts, t]); + setActiveId(t.id); + }, []); + + const canBack = active && active.index > 0; + const canFwd = active && active.index < active.entries.length - 1; + + // ─── 书签 ───────────────────────────────────────────────────── + const isBookmarked = useMemo( + () => !!currentUrl && (bookmarks || []).some(b => b.url === currentUrl), + [bookmarks, currentUrl] + ); + const toggleBookmark = useCallback(async () => { + if (!currentUrl) return; + const existing = (bookmarks || []).find(b => b.url === currentUrl); + if (existing) { + await removeBookmark(existing.id); + } else { + await addBookmark(titleForUrl(currentUrl), currentUrl); + } + refreshBookmarks(); + }, [currentUrl, bookmarks, refreshBookmarks]); + + const onClearHistory = useCallback(async () => { + await clearHistory(); + refreshHistory(); + }, [refreshHistory]); + + return ( +
+ {/* ─── 标签条 ─── */} +
+ {tabs.map(t => { + const isActive = t.id === activeId; + return ( +
setActiveId(t.id)} + className="edge-press" + style={{ + display: 'flex', alignItems: 'center', gap: 6, height: 30, + padding: '0 6px 0 10px', borderRadius: T.radius.md, + background: isActive ? T.surface : 'transparent', + border: `1px solid ${isActive ? T.border : 'transparent'}`, + fontSize: 12, color: T.ink, whiteSpace: 'nowrap', cursor: 'pointer', + flexShrink: 0, maxWidth: 220, + }} + > + + {t.title} + +
+ ); + })} + +
+ + {/* ─── 工具条 ─── */} +
+ + + + + + {/* 地址栏 */} +
+ + setAddress(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') commitAddress(); }} + placeholder="输入网址(如 192.168.1.10:3000 或 example.com)" + spellCheck={false} + style={{ + flex: 1, border: 'none', background: 'transparent', outline: 'none', + fontSize: 12.5, color: T.ink, ...MONO, + }} + /> + {active && active.loading && ( + + )} +
+ + + setPanel(p => p === 'history' ? null : 'history')} /> +
+ + {/* ─── 内容区 ─── */} + {currentUrl ? ( +