1515
1616import { createServer } from 'http'
1717import { spawn } from 'child_process'
18- import { mkdtempSync , rmSync , existsSync , statSync , mkdirSync , writeFileSync } from 'fs'
19- import { tmpdir } from 'os'
18+ import { mkdtempSync , rmSync , existsSync , statSync , mkdirSync , writeFileSync , readdirSync } from 'fs'
19+ import { tmpdir , homedir } from 'os'
2020import { join , resolve , dirname , normalize , isAbsolute } from 'path'
2121import { runProjectComplianceAudit } from '../compliance/audit.js'
2222import { renderHtmlReport } from '../compliance/html-report.js'
@@ -58,6 +58,11 @@ export function startWebServer(opts: WebServerOptions): void {
5858 if ( active >= MAX_CONCURRENT ) return send ( res , 503 , 'text/html' , errorPage ( '服务繁忙,请稍后再试' ) )
5959 return await handleUpload ( req , res , locale , ( ) => { active ++ } , ( ) => { active -- } )
6060 }
61+ // 本地目录浏览(仅本地模式):服务端直接列子目录,让用户点选要扫的文件夹(零上传)
62+ if ( u . pathname === '/browse' ) {
63+ if ( ! opts . local ) return send ( res , 403 , 'application/json' , JSON . stringify ( { error : '仅本地模式可用' } ) )
64+ return handleBrowse ( res , u . searchParams . get ( 'dir' ) )
65+ }
6166 // 演示:扫一个内置的「含风险样例项目」——证明"秒出≠没检查"(满屏发现 + 行号)
6267 if ( u . pathname === '/demo' ) {
6368 if ( active >= MAX_CONCURRENT ) return send ( res , 503 , 'text/html' , errorPage ( '服务繁忙,请稍后再试' ) )
@@ -180,6 +185,27 @@ async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () =>
180185 }
181186}
182187
188+ /** 本地目录浏览:返回某目录下的子目录列表(供网页点选;不读文件内容、不上传) */
189+ function handleBrowse ( res : any , dirParam : string | null ) {
190+ try {
191+ const abs = resolve ( dirParam && dirParam . trim ( ) ? dirParam : homedir ( ) )
192+ const entries = readdirSync ( abs , { withFileTypes : true } )
193+ . filter ( e => { try { return e . isDirectory ( ) } catch { return false } } )
194+ . map ( e => e . name )
195+ . filter ( n => ! n . startsWith ( '.' ) && n !== 'node_modules' )
196+ . sort ( ( a , b ) => a . localeCompare ( b ) )
197+ . slice ( 0 , 500 )
198+ const parent = dirname ( abs )
199+ send ( res , 200 , 'application/json' , JSON . stringify ( {
200+ current : abs ,
201+ parent : parent === abs ? null : parent ,
202+ dirs : entries ,
203+ } ) )
204+ } catch ( e : any ) {
205+ send ( res , 200 , 'application/json' , JSON . stringify ( { error : e ?. message || String ( e ) } ) )
206+ }
207+ }
208+
183209/** 演示:内置「含风险样例项目」扫描,证明检测真在工作 */
184210function handleDemo ( res : any , locale : 'zh' | 'en' , inc : ( ) => void , dec : ( ) => void ) {
185211 const dir = mkdtempSync ( join ( tmpdir ( ) , 'sw-demo-' ) )
@@ -242,13 +268,13 @@ function formPage(local: boolean): string {
242268 </form>`
243269
244270 const uploadForm = local ? `
245- <form id="dirform" >
246- <label>① 选择本地项目文件夹(推荐)</label >
247- <input type="file " id="dir" webkitdirectory directory multiple >
248- <button id="dbtn" type="submit">开始体检 →</button >
249- <div id="status" class="status"> </div>
250- <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p >
251- </form >
271+ <label>① 在本机点选项目文件夹(推荐 · 零上传)</label >
272+ <div class="browser" >
273+ <div class="bpath " id="curpath">加载中…</div >
274+ <ul class="dirs" id="dirs"></ul >
275+ </div>
276+ <button id="scanbtn" type="button">✅ 扫描当前文件夹 →</button >
277+ <p class="hint">📂 在你电脑上点进项目目录,再点"扫描当前文件夹"。<b>服务端直接读取本机文件、零上传、不出本机</b>,自动跳过 node_modules,无需选 3 万个文件。</p >
252278 <div class="or">— 或 —</div>` : ''
253279
254280 return page ( 'ShellWard 合规体检' , `
@@ -262,11 +288,30 @@ function formPage(local: boolean): string {
262288 <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
263289 <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
264290 </div>
265- ${ local ? UPLOAD_SCRIPT : '' } ` )
291+ ${ local ? BROWSE_SCRIPT : '' } ` )
266292}
267293
268- // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
269- // 注意:过滤后缀须与服务端 SCAN_EXT 对齐(含 .md),否则 markdown 项目会被全滤光显得"扫不了"。
294+ // 本地目录浏览器:点选文件夹 → 服务端直接扫(零上传,不读 node_modules)
295+ const BROWSE_SCRIPT = `<script>
296+ (function(){
297+ var cur='';
298+ function load(dir){
299+ var cp=document.getElementById('curpath'); if(cp)cp.textContent='加载中…';
300+ fetch('/browse?dir='+encodeURIComponent(dir||'')).then(function(r){return r.json()}).then(function(d){
301+ if(d.error){ if(cp)cp.textContent='无法读取:'+d.error; return; }
302+ cur=d.current; if(cp)cp.textContent=cur;
303+ var ul=document.getElementById('dirs'); if(!ul)return; ul.innerHTML='';
304+ if(d.parent){ var up=document.createElement('li'); up.className='up'; up.textContent='⬆ 上级目录'; up.onclick=function(){load(d.parent)}; ul.appendChild(up); }
305+ if(!d.dirs.length){ var e=document.createElement('li'); e.className='empty'; e.textContent='(此目录无子文件夹,可直接点上方扫描)'; ul.appendChild(e); }
306+ d.dirs.forEach(function(name){ var li=document.createElement('li'); li.textContent='📁 '+name; li.onclick=function(){ load(cur.replace(/\\/+$/,'')+'/'+name) }; ul.appendChild(li); });
307+ }).catch(function(e){ if(cp)cp.textContent='错误:'+e; });
308+ }
309+ var sb=document.getElementById('scanbtn');
310+ if(sb){ sb.onclick=function(){ if(cur){ sb.disabled=true; sb.textContent='扫描中…'; window.location.href='/scan?path='+encodeURIComponent(cur); } }; load(''); }
311+ })();
312+ </script>`
313+
314+ // (旧上传脚本保留备用,当前本地模式改用目录浏览器)
270315const UPLOAD_SCRIPT = `<script>
271316(function(){
272317 var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
@@ -337,6 +382,14 @@ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6
337382.status{display:none;margin:10px 0 0;padding:10px 14px;border-radius:8px;background:#f1f5f9;
338383color:#334155;font-size:13.5px;border-left:3px solid #cb0000;text-align:left}
339384.demo{margin:18px 0 0;font-size:13px;color:#475569}.demo a{font-weight:600}
385+ .browser{border:1px solid #cbd5e1;border-radius:10px;overflow:hidden;margin:4px 0 10px;text-align:left}
386+ .bpath{background:#0f172a;color:#93c5fd;font-family:ui-monospace,Menlo,monospace;font-size:12px;
387+ padding:9px 12px;word-break:break-all}
388+ .dirs{list-style:none;margin:0;padding:0;max-height:240px;overflow-y:auto}
389+ .dirs li{padding:9px 14px;border-top:1px solid #eef2f7;cursor:pointer;font-size:14px}
390+ .dirs li:hover{background:#f1f5f9}
391+ .dirs li.up{color:#cb0000;font-weight:600}
392+ .dirs li.empty{color:#94a3b8;cursor:default;font-size:13px}
340393.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
341394.back{font-weight:600}
342395</style></head><body>${ body } </body></html>`
0 commit comments