Skip to content

Commit 19067ae

Browse files
committed
feat(web): web端中英文双语支持 v0.7.20
- 客户端与报告支持中/EN:顶部切换+?lang=+Accept-Language自动判定 - 首页/错误页双语,报告跟随语言,扫描链接携带lang - test-web 增 i18n 用例;全套318测试
1 parent 66cb906 commit 19067ae

5 files changed

Lines changed: 85 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.7.20] - 2026-06-23
9+
10+
### Added — web 端中英文双语
11+
- web 客户端与报告支持**中英文**:顶部 中文 / EN 切换;按 `?lang=zh|en` 或浏览器 Accept-Language 自动判定
12+
- 首页、错误页全部双语;报告渲染跟随语言;路径扫描/演示链接携带 `lang`,报告语言一致
13+
- `test-web.ts` 增 i18n 用例;全套 **318 测试**全绿
14+
815
## [0.7.19] - 2026-06-23
916

1017
### Changed — 消除浏览器"上传到此网站"弹框

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
[![npm](https://img.shields.io/npm/v/shellward?color=cb0000&label=npm)](https://www.npmjs.com/package/shellward)
1010
[![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11-
[![tests](https://img.shields.io/badge/tests-315%20passing-brightgreen)](#performance)
11+
[![tests](https://img.shields.io/badge/tests-318%20passing-brightgreen)](#performance)
1212
[![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
1313

1414
**🌐 官网: https://jnmetacode.github.io/shellward/**

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shellward",
3-
"version": "0.7.19",
3+
"version": "0.7.20",
44
"mcpName": "io.github.jnMetaCode/shellward",
55
"description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
66
"keywords": [

src/web/scan-server.ts

Lines changed: 69 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -44,54 +44,65 @@ export function validateRepoUrl(input: string): { ok: true; url: string } | { ok
4444
// 报告「返回」链接:本地模式用绝对地址(上传报告经 blob: URL 打开,相对 '/' 会失效)
4545
let SERVER_BASE = '/'
4646

47+
/** 决定本次请求语言:?lang=en|zh 优先,其次 Accept-Language,最后服务端默认 */
48+
function langOf(req: any, fallback: 'zh' | 'en'): 'zh' | 'en' {
49+
const m = /[?&]lang=(en|zh)\b/.exec(req.url || '')
50+
if (m) return m[1] as 'zh' | 'en'
51+
const al = String(req.headers?.['accept-language'] || '')
52+
if (/\ben\b/i.test(al) && !/zh/i.test(al)) return 'en'
53+
return fallback
54+
}
55+
4756
export function startWebServer(opts: WebServerOptions): void {
4857
const locale = resolveLocale(DEFAULT_CONFIG)
4958
const host = opts.local ? '127.0.0.1' : '0.0.0.0'
5059
SERVER_BASE = opts.local ? `http://localhost:${opts.port}/` : '/'
5160
let active = 0
5261

5362
const server = createServer(async (req, res) => {
63+
const lang = langOf(req, locale)
64+
const T = (zh: string, en: string) => (lang === 'en' ? en : zh)
5465
try {
5566
const u = new URL(req.url || '/', `http://localhost:${opts.port}`)
5667
if (u.pathname === '/' || u.pathname === '') {
57-
return send(res, 200, 'text/html', formPage(!!opts.local))
68+
return send(res, 200, 'text/html', formPage(!!opts.local, lang))
5869
}
5970
// 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
6071
if (u.pathname === '/scan-files' && req.method === 'POST') {
61-
if (!opts.local) return send(res, 403, 'text/html', errorPage('公网模式不支持上传;请用「公开仓库 URL」。'))
62-
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
63-
return await handleUpload(req, res, locale, () => { active++ }, () => { active-- })
72+
if (!opts.local) return send(res, 403, 'text/html', errorPage(T('公网模式不支持上传;请用「公开仓库 URL」。', 'Upload not supported in public mode; use a public repo URL.'), lang))
73+
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage(T('服务繁忙,请稍后再试', 'Server busy, try again later'), lang))
74+
return await handleUpload(req, res, lang, () => { active++ }, () => { active-- })
6475
}
6576
// 本地目录浏览(仅本地模式):服务端直接列子目录,让用户点选要扫的文件夹(零上传)
6677
if (u.pathname === '/browse') {
67-
if (!opts.local) return send(res, 403, 'application/json', JSON.stringify({ error: '仅本地模式可用' }))
78+
if (!opts.local) return send(res, 403, 'application/json', JSON.stringify({ error: 'local mode only' }))
6879
return handleBrowse(res, u.searchParams.get('dir'))
6980
}
7081
// 演示:扫一个内置的「含风险样例项目」——证明"秒出≠没检查"(满屏发现 + 行号)
7182
if (u.pathname === '/demo') {
72-
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
73-
return handleDemo(res, locale, () => { active++ }, () => { active-- })
83+
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage(T('服务繁忙,请稍后再试', 'Server busy, try again later'), lang))
84+
return handleDemo(res, lang, () => { active++ }, () => { active-- })
7485
}
7586
if (u.pathname === '/scan') {
7687
if (active >= MAX_CONCURRENT) {
77-
return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'))
88+
return send(res, 503, 'text/html', errorPage(T('服务繁忙,请稍后再试(并发上限)', 'Server busy (concurrency limit)'), lang))
7889
}
7990
const repo = u.searchParams.get('repo')
8091
const path = u.searchParams.get('path')
8192

8293
// 本地路径扫描:仅本地模式开放
8394
if (path) {
84-
if (!opts.local) return send(res, 403, 'text/html', errorPage('公网模式不支持本地路径扫描;请用「公开仓库 URL」,私有代码请用本地 CLI:npx shellward scan'))
85-
return await handleLocal(res, path, locale, () => { active++ }, () => { active-- })
95+
if (!opts.local) return send(res, 403, 'text/html', errorPage(T('公网模式不支持本地路径扫描;请用「公开仓库 URL」。', 'Local path scan not supported in public mode; use a public repo URL.'), lang))
96+
return await handleLocal(res, path, lang, () => { active++ }, () => { active-- })
8697
}
8798
if (repo) {
88-
return await handleRepo(res, repo, locale, () => { active++ }, () => { active-- })
99+
return await handleRepo(res, repo, lang, () => { active++ }, () => { active-- })
89100
}
90-
return send(res, 400, 'text/html', errorPage('缺少参数:repo(仓库 URL)' + (opts.local ? ' 或 path(本地路径)' : '')))
101+
return send(res, 400, 'text/html', errorPage(T('缺少参数:repo 或 path', 'Missing parameter: repo or path'), lang))
91102
}
92-
send(res, 404, 'text/html', errorPage('页面不存在'))
103+
send(res, 404, 'text/html', errorPage(T('页面不存在', 'Not found'), lang))
93104
} catch (e: any) {
94-
send(res, 500, 'text/html', errorPage('内部错误:' + esc(e?.message || String(e))))
105+
send(res, 500, 'text/html', errorPage(T('内部错误:', 'Internal error: ') + esc(e?.message || String(e)), lang))
95106
}
96107
})
97108

@@ -262,66 +273,71 @@ function send(res: any, code: number, type: string, body: string) {
262273
res.end(body)
263274
}
264275

265-
function formPage(local: boolean): string {
276+
function formPage(local: boolean, lang: 'zh' | 'en' = 'zh'): string {
277+
const t = (z: string, e: string) => (lang === 'en' ? e : z)
278+
const lq = `lang=${lang}`
266279
const urlForm = `
267-
<form action="/scan" method="get" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='扫描中…(大仓库需 10–60 秒,请勿重复点击)';">
268-
<label>公开仓库地址</label>
280+
<form action="/scan" method="get" onsubmit="var b=this.querySelector('button.go');b.disabled=true;b.textContent='${t('扫描中…(大仓库需 10–60 秒)', 'Scanning… (large repos 10–60s)')}';">
281+
<input type="hidden" name="lang" value="${lang}">
282+
<label>${t('公开仓库地址', 'Public repository URL')}</label>
269283
<input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
270-
<button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
271-
<p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。大仓库可能超时——${local ? '此时用上方「上传文件夹」更稳。' : '<b>大仓库 / 私有代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
284+
<button type="submit" class="go">${t(local ? '体检该仓库 →' : '开始体检 →', 'Scan →')}</button>
285+
<p class="hint">${t('仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。大仓库可能超时;私有代码请用本地客户端或 <code>npx shellward scan</code>(不上传)。', 'Public repos only (GitHub/GitLab/Gitee/Bitbucket). Large repos may time out; for private code use the local client or <code>npx shellward scan</code> (no upload).')}</p>
272286
</form>`
273287

274-
// 本地模式:主推命令行 --open(最干净);网页内用路径输入(无浏览器上传弹框)
275288
const localForms = local ? `
276-
<div class="rec">💡 <b>最干净的方式</b>:在你的项目目录运行 <code>npx shellward scan --open</code> —— 自动出报告、在浏览器打开,<b>无需上传、无弹框</b>。或在下方直接体检:</div>
277-
<label>体检本地项目(服务端直读本机 · 零上传 · 无弹框)</label>
289+
<div class="rec">💡 <b>${t('最干净的方式', 'Cleanest way')}</b>:${t('在项目目录运行', 'run in your project dir')} <code>npx shellward scan --open</code> —— ${t('自动出报告、浏览器打开,无需上传、无弹框。或在下方直接体检:', 'auto-opens the report in your browser. No upload, no dialog. Or scan below:')}</div>
290+
<label>${t('体检本地项目(服务端直读本机 · 零上传 · 无弹框)', 'Scan a local project (read directly · no upload · no dialog)')}</label>
278291
<div class="pathrow">
279-
<input id="pathbar" placeholder="粘贴项目绝对路径,或在下方点选文件夹" spellcheck="false" autocomplete="off">
280-
<button id="scanbtn" type="button">体检 →</button>
292+
<input id="pathbar" placeholder="${t('粘贴项目绝对路径,或在下方点选文件夹', 'Paste an absolute project path, or browse below')}" spellcheck="false" autocomplete="off">
293+
<button id="scanbtn" type="button">${t('体检 →', 'Scan →')}</button>
281294
</div>
282295
<div class="browser"><ul class="dirs" id="dirs"></ul></div>
283-
<p class="hint">粘贴路径直接体检,或点文件夹进入;自动跳过 node_modules。私有代码<b>不上传、不出本机、无浏览器弹框</b>。</p>
284-
<details class="alt"><summary>或:体检公开仓库 URL</summary>${urlForm}</details>` : ''
296+
<p class="hint">${t('粘贴路径直接体检,或点文件夹进入;自动跳过 node_modules。私有代码<b>不上传、不出本机、无浏览器弹框</b>。', 'Paste a path or click into folders; node_modules auto-skipped. Private code <b>never uploaded, never leaves your machine, no browser dialog</b>.')}</p>
297+
<details class="alt"><summary>${t('或:体检公开仓库 URL', 'Or: scan a public repo URL')}</summary>${urlForm}</details>` : ''
285298

286-
return page('ShellWard 合规体检', `
299+
return page(t('ShellWard 合规体检', 'ShellWard Compliance Scan'), `
287300
<nav class="nav">
288-
<div class="logo">🛡️ Shell<span>Ward</span> <em>合规网关</em></div>
289-
<a class="ghbtn" href="https://github.com/jnMetaCode/shellward" target="_blank">★ GitHub Star</a>
301+
<div class="logo">🛡️ Shell<span>Ward</span> <em>${t('合规网关', 'Compliance Gateway')}</em></div>
302+
<div class="navr">
303+
<a class="lang ${lang === 'zh' ? 'on' : ''}" href="/?lang=zh">中文</a><a class="lang ${lang === 'en' ? 'on' : ''}" href="/?lang=en">EN</a>
304+
<a class="ghbtn" href="https://github.com/jnMetaCode/shellward" target="_blank">★ Star</a>
305+
</div>
290306
</nav>
291307
<main class="wrap">
292308
<header class="hd">
293-
<div class="tag">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识</div>
294-
<h1>30 秒,查出你的 AI 项目<br>踩了哪些 <span class="hl">中国合规红线</span></h1>
295-
<p class="sub">数据出境 · 硬编码密钥 · 个人信息暴露 —— 精确到 <code>文件:行</code>,并给出境内替代建议。${local ? '<b>私有代码不出本机。</b>' : ''}</p>
309+
<div class="tag">${t('网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识', 'CSL 2026 · PIPL · MLPS 2.0 · Cross-border · AI labeling')}</div>
310+
<h1>${t('30 秒,查出你的 AI 项目<br>踩了哪些 <span class="hl">中国合规红线</span>', 'Find which <span class="hl">China compliance lines</span><br>your AI project crosses — in 30s')}</h1>
311+
<p class="sub">${t('数据出境 · 硬编码密钥 · 个人信息暴露 —— 精确到 <code>文件:行</code>,并给出境内替代建议。', 'Data export · hardcoded secrets · PII — pinpointed to <code>file:line</code>, with domestic alternatives.')}${local ? t('<b>私有代码不出本机。</b>', ' <b>Private code stays local.</b>') : ''}</p>
296312
</header>
297313
298314
<section class="card">
299315
${local ? localForms : urlForm}
300316
</section>
301317
302318
<section class="checks">
303-
<div class="chk"><span>🌐</span><b>数据出境</b><i>境外大模型端点/SDK</i></div>
304-
<div class="chk"><span>🔑</span><b>硬编码密钥</b><i>OpenAI/GitHub/AWS…</i></div>
305-
<div class="chk"><span>🪪</span><b>个人信息</b><i>身份证/手机/银行卡</i></div>
306-
<div class="chk"><span>📂</span><b>.env 暴露</b><i>权限/明文密钥</i></div>
319+
<div class="chk"><span>🌐</span><b>${t('数据出境', 'Data export')}</b><i>${t('境外大模型端点/SDK', 'overseas LLM endpoints/SDK')}</i></div>
320+
<div class="chk"><span>🔑</span><b>${t('硬编码密钥', 'Hardcoded keys')}</b><i>OpenAI/GitHub/AWS…</i></div>
321+
<div class="chk"><span>🪪</span><b>${t('个人信息', 'PII')}</b><i>${t('身份证/手机/银行卡', 'ID/phone/bank card')}</i></div>
322+
<div class="chk"><span>📂</span><b>.env</b><i>${t('权限/明文密钥', 'perms/plaintext keys')}</i></div>
307323
</section>
308324
309-
<p class="demo">🤔 想先看效果? <a href="/demo">▶ 看一个含风险的示例报告</a></p>
325+
<p class="demo">🤔 ${t('想先看效果?', 'Want to see it first?')} <a href="/demo?${lq}">▶ ${t('看一个含风险的示例报告', 'See a sample report with risks')}</a></p>
310326
311327
<section class="trust">
312-
<div><b>🔒 ${local ? '不出本机' : '不存储代码'}</b>${local ? '服务端直读本机,零上传' : '公开仓库浅克隆,用完即删'}</div>
313-
<div><b>📦 零依赖</b>可在信创/离线环境跑</div>
314-
<div><b>⚖️ 开源</b>Apache-2.0,代码全公开</div>
315-
<div><b>🇨🇳 中文优先</b>为中国监管而生</div>
328+
<div><b>🔒 ${local ? t('不出本机', 'Stays local') : t('不存储代码', 'No code stored')}</b>${local ? t('服务端直读本机,零上传', 'read locally, zero upload') : t('浅克隆,用完即删', 'shallow clone, deleted after')}</div>
329+
<div><b>📦 ${t('零依赖', 'Zero deps')}</b>${t('信创/离线可跑', 'runs offline')}</div>
330+
<div><b>⚖️ ${t('开源', 'Open source')}</b>Apache-2.0</div>
331+
<div><b>🇨🇳 ${t('中文优先', 'China-first')}</b>${t('为中国监管而生', 'built for CN regs')}</div>
316332
</section>
317333
318334
<footer class="ft">
319-
公众号「AI不止语」· 技术问答 · 实战文章&nbsp;&nbsp;|&nbsp;&nbsp;
335+
${t('公众号「AI不止语」', 'WeChat: AI不止语')}&nbsp;&nbsp;|&nbsp;&nbsp;
320336
<a href="https://github.com/jnMetaCode/shellward" target="_blank">github.com/jnMetaCode/shellward</a> · Apache-2.0
321337
</footer>
322338
</main>
323-
<div id="overlay" class="overlay"><div class="spin"></div><div id="ovtext">扫描中…</div></div>
324-
${local ? BROWSE_SCRIPT : ''}`)
339+
<div id="overlay" class="overlay"><div class="spin"></div><div id="ovtext">${t('扫描中…', 'Scanning…')}</div></div>
340+
${local ? `<script>window.__SWLANG=${JSON.stringify(lang)}</script>` + BROWSE_SCRIPT : ''}`)
325341
}
326342

327343
// 本地:统一路径栏 + 目录浏览器。粘贴路径 / 点选填充 → 服务端直接扫(零上传,跳过 node_modules)
@@ -338,7 +354,7 @@ const BROWSE_SCRIPT = `<script>
338354
li.onclick=function(){ load((pb.value||'').replace(/\\/+$/,'')+'/'+name) }; ul.appendChild(li); });
339355
}
340356
function load(dir){ fetch('/browse?dir='+encodeURIComponent(dir||'')).then(function(r){return r.json()}).then(render).catch(function(e){ ul.innerHTML='<li class="empty">错误:'+e+'</li>'; }); }
341-
function scan(){ var p=(pb.value||'').trim(); if(!p){ pb.focus(); return; } document.getElementById('overlay').style.display='flex'; window.location.href='/scan?path='+encodeURIComponent(p); }
357+
function scan(){ var p=(pb.value||'').trim(); if(!p){ pb.focus(); return; } document.getElementById('overlay').style.display='flex'; window.location.href='/scan?path='+encodeURIComponent(p)+'&lang='+(window.__SWLANG||'zh'); }
342358
sb.onclick=scan;
343359
pb.addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); load(pb.value); } });
344360
load(''); // 从家目录起
@@ -384,10 +400,11 @@ const UPLOAD_SCRIPT = `<script>
384400
})();
385401
</script>`
386402

387-
function errorPage(msg: string): string {
388-
return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
389-
<h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
390-
<p><a class="back" href="/">← 返回重试</a></p></div>`)
403+
function errorPage(msg: string, lang: 'zh' | 'en' = 'zh'): string {
404+
const t = (z: string, e: string) => (lang === 'en' ? e : z)
405+
return page(t('出错了', 'Error'), `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
406+
<h1>⚠️ ${t('无法完成', 'Could not complete')}</h1><p class="sub">${esc(msg)}</p>
407+
<p><a class="back" href="/?lang=${lang}">← ${t('返回重试', 'Back')}</a></p></div>`)
391408
}
392409

393410
function page(title: string, body: string): string {
@@ -403,6 +420,9 @@ a{color:#cb0000;text-decoration:none}
403420
.logo em{font-style:normal;color:#94a3b8;font-weight:600;font-size:13px;margin-left:5px}
404421
.ghbtn{border:1px solid #cbd5e1;border-radius:8px;padding:7px 14px;font-size:13px;font-weight:700;color:#0f172a;background:#fff;transition:.15s}
405422
.ghbtn:hover{border-color:#cb0000;color:#cb0000}
423+
.navr{display:flex;align-items:center;gap:6px}
424+
.lang{font-size:13px;font-weight:600;color:#94a3b8;padding:5px 8px;border-radius:7px}
425+
.lang.on{color:#cb0000;background:#fef2f2}.lang:hover{color:#cb0000}
406426
.wrap{max-width:760px;margin:0 auto;padding:0 24px 48px}
407427
.hd{text-align:center;padding:22px 0 26px}
408428
.tag{display:inline-block;background:#fef2f2;color:#cb0000;font-size:12px;font-weight:700;padding:5px 14px;border-radius:999px;margin-bottom:16px}

test-web.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ async function main() {
4040
const home = await (await fetch(base + '/')).text()
4141
test('首页含路径栏(无上传弹框)', home.includes('pathbar') && home.includes('id="dirs"'))
4242
test('首页含 URL 入口', home.includes('公开仓库地址'))
43+
test('首页含 中/EN 语言切换', home.includes('/?lang=zh') && home.includes('/?lang=en'))
44+
45+
// i18n:英文页面 + 英文报告
46+
const enHome = await (await fetch(base + '/?lang=en')).text()
47+
test('?lang=en → 英文首页', enHome.includes('Scan a local project') && !enHome.includes('体检本地项目'))
48+
const enReport = await (await fetch(base + '/scan?path=' + encodeURIComponent(tmpdir()) + '&lang=en')).text()
49+
test('?lang=en → 英文报告', enReport.includes('Project Scan Findings') && enReport.includes('Compliance Report'))
4350

4451
// 目录浏览器:列子目录(零上传)
4552
const browse = await (await fetch(base + '/browse?dir=' + encodeURIComponent(tmpdir()))).json() as any

0 commit comments

Comments
 (0)