@@ -44,54 +44,65 @@ export function validateRepoUrl(input: string): { ok: true; url: string } | { ok
4444// 报告「返回」链接:本地模式用绝对地址(上传报告经 blob: URL 打开,相对 '/' 会失效)
4545let SERVER_BASE = '/'
4646
47+ /** 决定本次请求语言:?lang=en|zh 优先,其次 Accept-Language,最后服务端默认 */
48+ function langOf ( req : any , fallback : 'zh' | 'en' ) : 'zh' | 'en' {
49+ const m = / [ ? & ] l a n g = ( e n | z h ) \b / . exec ( req . url || '' )
50+ if ( m ) return m [ 1 ] as 'zh' | 'en'
51+ const al = String ( req . headers ?. [ 'accept-language' ] || '' )
52+ if ( / \b e n \b / i. test ( al ) && ! / z h / i. test ( al ) ) return 'en'
53+ return fallback
54+ }
55+
4756export 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不止语」· 技术问答 · 实战文章 |
335+ ${ t ( ' 公众号「AI不止语」' , 'WeChat: AI不止语' ) } |
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
393410function 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}
0 commit comments