feat: 内置 Web 浏览器页与服务器站点入口
侧栏浏览器页 iframe 预览 http(s) 站点;服务器列表「站点」由 domain 跳转,禁止嵌入时可新标签打开。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 审计 — 内置 Web 浏览器
|
||||
|
||||
## 范围(文件清单)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/BrowserPage.vue` | iframe 浏览器页 |
|
||||
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航历史 |
|
||||
| `frontend/src/utils/browserUrl.ts` | URL 白名单校验 |
|
||||
| `frontend/src/router/index.ts` | `/browser` 路由 |
|
||||
| `frontend/src/App.vue` | 侧栏 + 全屏布局 |
|
||||
| `frontend/src/pages/ServersPage.vue` | 站点入口 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 无 SSRF(无后端 fetch) | PASS |
|
||||
| H2 | 仅 http/https | PASS — normalizeBrowserUrl |
|
||||
| H3 | iframe sandbox | PASS |
|
||||
| H4 | 鉴权 | PASS — 走路由 guard |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | PASS | 纯前端 iframe |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] type-check
|
||||
- [x] changelog
|
||||
|
||||
## 验证
|
||||
|
||||
`#/browser` 与服务器「站点」按钮;禁止嵌入站点用新标签打开。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 2026-06-11 内置 Web 浏览器
|
||||
|
||||
## 摘要
|
||||
|
||||
新增侧栏「浏览器」页:地址栏 + iframe 预览 http(s) 站点;服务器列表增加「站点」快捷入口(由 domain 推导 `https://{host}`)。
|
||||
|
||||
## 动机
|
||||
|
||||
运维需在 Nexus 内快速查看子机网站,无需切换外部浏览器;与终端、文件管理形成并列运维入口。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `docs/design/specs/2026-06-11-embedded-browser-design.md` | 设计说明 |
|
||||
| `frontend/src/pages/BrowserPage.vue` | 浏览器页 |
|
||||
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航与 session 历史 |
|
||||
| `frontend/src/utils/browserUrl.ts` | URL 校验与域名推导 |
|
||||
| `frontend/src/router/index.ts` | 路由 `/browser` |
|
||||
| `frontend/src/App.vue` | 侧栏入口与全屏布局 |
|
||||
| `frontend/src/pages/ServersPage.vue` | 「站点」按钮 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 部分站点 `X-Frame-Options` 禁止嵌入 → 使用「新标签打开」
|
||||
- 首版无 HTTP 反向代理,不解决所有站点嵌入问题
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅前端构建部署,无需后端重启或数据库迁移
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run type-check && npx vite build
|
||||
```
|
||||
|
||||
浏览器:`#/browser` 输入 URL;服务器列表点「站点」应打开对应域名。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 内置 Web 浏览器 — 设计说明
|
||||
|
||||
## 背景与目标
|
||||
|
||||
运维需在 Nexus 内快速预览子机站点(域名),不必切换外部浏览器;与终端、文件管理并列的运维入口。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **iframe 嵌入** | 无后端、实现快、无 SSRF | 部分站点 `X-Frame-Options` 禁止嵌入 |
|
||||
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
|
||||
| 仅外链新标签 | 零风险 | 非「内置」 |
|
||||
|
||||
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
||||
|
||||
## 接口与路由
|
||||
|
||||
- 前端 `#/browser?url=https://example.com`
|
||||
- 服务器列表「站点」→ 带 `url`(由 `domain` 推导 `https://{host}`,不含 SSH 端口)
|
||||
|
||||
## 安全
|
||||
|
||||
- 仅允许 `http:` / `https:`;拒绝 `javascript:`、`data:`、凭据 URL
|
||||
- iframe `sandbox`:`allow-scripts allow-same-origin allow-forms allow-popups allow-downloads`
|
||||
- 无后端 fetch,不引入 SSRF
|
||||
- 不记录浏览审计(被动预览,非 CUD)
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 侧栏「浏览器」进入全屏 iframe 页
|
||||
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
|
||||
- [ ] 服务器列表「站点」跳转并加载域名
|
||||
- [ ] 非法 URL 拒绝并提示
|
||||
+28
-1
@@ -327,7 +327,8 @@ provide('nexusDrawer', drawer)
|
||||
|
||||
const mainLayoutClass = computed(() => ({
|
||||
'nexus-terminal-main': route.path === '/terminal',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||
'nexus-browser-main': route.path === '/browser',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/browser' && route.path !== '/login',
|
||||
}))
|
||||
|
||||
const scriptExecBarOffset = computed(() => {
|
||||
@@ -343,6 +344,7 @@ const opsItems = [
|
||||
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
|
||||
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
|
||||
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
|
||||
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
|
||||
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
|
||||
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
|
||||
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
|
||||
@@ -415,6 +417,31 @@ window.$snackbar = (text: string, color = 'success') => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-application .v-main.nexus-browser-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding-top: var(--v-layout-top, 64px) !important;
|
||||
padding-bottom: 0 !important;
|
||||
transition:
|
||||
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.v-application .v-main.nexus-browser-main > .v-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
|
||||
.v-application .v-main.nexus-page-main {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
|
||||
const HISTORY_KEY = 'nexus_browser_history_v1'
|
||||
const MAX_HISTORY = 32
|
||||
|
||||
function readHistory(): string[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HISTORY_KEY)
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((u): u is string => typeof u === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(items: string[]) {
|
||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(-MAX_HISTORY)))
|
||||
}
|
||||
|
||||
export function useEmbeddedBrowser(initialUrl?: string) {
|
||||
const history = ref<string[]>(readHistory())
|
||||
const historyIndex = ref(-1)
|
||||
const addressDraft = ref('')
|
||||
const frameUrl = ref<string | null>(null)
|
||||
const frameKey = ref(0)
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
function pushHistory(url: string) {
|
||||
const list = history.value.slice(0, historyIndex.value + 1)
|
||||
if (list[list.length - 1] !== url) list.push(url)
|
||||
history.value = list
|
||||
historyIndex.value = list.length - 1
|
||||
writeHistory(list)
|
||||
}
|
||||
|
||||
function navigate(input: string, replace = false): boolean {
|
||||
const normalized = normalizeBrowserUrl(input)
|
||||
if (!normalized) {
|
||||
lastError.value = '请输入有效的 http 或 https 地址'
|
||||
return false
|
||||
}
|
||||
lastError.value = null
|
||||
addressDraft.value = normalized
|
||||
frameUrl.value = normalized
|
||||
frameKey.value += 1
|
||||
if (!replace) {
|
||||
pushHistory(normalized)
|
||||
} else if (history.value.length === 0) {
|
||||
history.value = [normalized]
|
||||
historyIndex.value = 0
|
||||
writeHistory(history.value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!frameUrl.value) return
|
||||
frameKey.value += 1
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (historyIndex.value <= 0) return
|
||||
historyIndex.value -= 1
|
||||
const url = history.value[historyIndex.value]
|
||||
if (!url) return
|
||||
addressDraft.value = url
|
||||
frameUrl.value = url
|
||||
frameKey.value += 1
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (historyIndex.value >= history.value.length - 1) return
|
||||
historyIndex.value += 1
|
||||
const url = history.value[historyIndex.value]
|
||||
if (!url) return
|
||||
addressDraft.value = url
|
||||
frameUrl.value = url
|
||||
frameKey.value += 1
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
const canGoBack = computed(() => historyIndex.value > 0)
|
||||
const canGoForward = computed(() => historyIndex.value >= 0 && historyIndex.value < history.value.length - 1)
|
||||
|
||||
function openExternal() {
|
||||
if (!frameUrl.value) return
|
||||
window.open(frameUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
if (initialUrl) {
|
||||
navigate(initialUrl, true)
|
||||
}
|
||||
|
||||
watch(frameUrl, (url) => {
|
||||
if (url) addressDraft.value = url
|
||||
})
|
||||
|
||||
return {
|
||||
addressDraft,
|
||||
frameUrl,
|
||||
frameKey,
|
||||
lastError,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
navigate,
|
||||
refresh,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-0 d-flex flex-column browser-root">
|
||||
<v-toolbar density="compact" color="surface" class="browser-toolbar border-b">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
:disabled="!canGoBack"
|
||||
aria-label="后退"
|
||||
@click="goBack"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-right"
|
||||
variant="text"
|
||||
:disabled="!canGoForward"
|
||||
aria-label="前进"
|
||||
@click="goForward"
|
||||
/>
|
||||
<v-btn icon="mdi-refresh" variant="text" :disabled="!frameUrl" aria-label="刷新" @click="refresh" />
|
||||
|
||||
<v-text-field
|
||||
v-model="addressDraft"
|
||||
class="browser-url-field mx-2"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="https://example.com"
|
||||
prepend-inner-icon="mdi-web"
|
||||
@keydown.enter.prevent="onGo"
|
||||
/>
|
||||
|
||||
<v-btn color="primary" variant="flat" class="text-none" @click="onGo">打开</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-open-in-new"
|
||||
variant="text"
|
||||
:disabled="!frameUrl"
|
||||
aria-label="新标签打开"
|
||||
@click="openExternal"
|
||||
/>
|
||||
</v-toolbar>
|
||||
|
||||
<v-alert
|
||||
v-if="lastError"
|
||||
type="warning"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="ma-2 mb-0"
|
||||
closable
|
||||
@click:close="clearError"
|
||||
>
|
||||
{{ lastError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="!frameUrl" class="browser-empty flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis">
|
||||
<v-icon icon="mdi-web" size="64" class="mb-4 opacity-50" />
|
||||
<p class="text-body-1 mb-4">输入地址预览站点,或从服务器列表点击「站点」</p>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-server" @click="router.push('/servers')">
|
||||
选择服务器
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-else class="browser-frame-wrap flex-grow-1">
|
||||
<iframe
|
||||
:key="frameKey"
|
||||
:src="frameUrl"
|
||||
class="browser-frame"
|
||||
title="内置浏览器"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<p class="browser-frame-hint text-caption text-medium-emphasis pa-2">
|
||||
若页面空白,可能该站点禁止 iframe 嵌入,请使用右上角「新标签打开」。
|
||||
</p>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEmbeddedBrowser } from '@/composables/useEmbeddedBrowser'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const initial = typeof route.query.url === 'string' ? route.query.url : ''
|
||||
|
||||
const {
|
||||
addressDraft,
|
||||
frameUrl,
|
||||
frameKey,
|
||||
lastError,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
navigate,
|
||||
refresh,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
} = useEmbeddedBrowser()
|
||||
|
||||
function onGo() {
|
||||
const ok = navigate(addressDraft.value)
|
||||
if (ok && frameUrl.value) {
|
||||
router.replace({ path: '/browser', query: { url: frameUrl.value } }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (initial) {
|
||||
const normalized = normalizeBrowserUrl(initial)
|
||||
if (normalized) navigate(normalized, true)
|
||||
else lastError.value = '链接参数无效,请重新输入地址'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.browser-root {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.browser-toolbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.browser-url-field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.browser-frame-wrap {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.browser-frame-hint {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
@@ -245,6 +245,15 @@
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
|
||||
<v-btn
|
||||
v-if="item.domain"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
@click.stop="openBrowser(item)"
|
||||
>
|
||||
站点
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
|
||||
@@ -662,6 +671,7 @@ import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||
import { normalizeServerIds } from '@/utils/serverSelection'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
|
||||
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -1397,6 +1407,14 @@ async function refreshStatCards() {
|
||||
function openTerminal(item: ServerApiItem) {
|
||||
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
|
||||
}
|
||||
function openBrowser(item: ServerApiItem) {
|
||||
const url = guessSiteUrlFromDomain(item.domain || '')
|
||||
if (!url) {
|
||||
snackbar('该服务器无有效域名', 'warning')
|
||||
return
|
||||
}
|
||||
router.push({ path: '/browser', query: { url } })
|
||||
}
|
||||
function openFiles(item: ServerApiItem) {
|
||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
|
||||
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
|
||||
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
|
||||
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
|
||||
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Normalize user input to a safe http(s) URL for embedded iframe navigation. */
|
||||
export function normalizeBrowserUrl(input: string): string | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const withProto = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
const parsed = new URL(withProto)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||
if (parsed.username || parsed.password) return null
|
||||
return parsed.href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a site URL from server domain (SSH host; strip trailing :port). */
|
||||
export function guessSiteUrlFromDomain(domain: string): string {
|
||||
const raw = domain.trim()
|
||||
if (!raw) return ''
|
||||
const host = raw.includes(':') ? raw.split(':')[0]!.trim() : raw
|
||||
return normalizeBrowserUrl(host) ?? `https://${host}`
|
||||
}
|
||||
Reference in New Issue
Block a user