Add loopback-only HTTP server with startup token, Host/Origin/CSP guards, embedded live UI, CLI serve/export, and safe single-file HTML. Co-authored-by: Cursor <cursoragent@cursor.com>
157 lines
5.5 KiB
Go
157 lines
5.5 KiB
Go
package engineeringdashboard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ExportManifest describes what was embedded in an offline HTML.
|
|
type ExportManifest struct {
|
|
Included []string `json:"included"`
|
|
Truncated []string `json:"truncated"`
|
|
MetadataOnly []string `json:"metadata_only"`
|
|
SensitiveWarn []string `json:"sensitive_warnings,omitempty"`
|
|
}
|
|
|
|
// ExportHTML writes a self-contained offline snapshot (mode 0600).
|
|
func ExportHTML(snap Snapshot, outputPath string) error {
|
|
css, err := readAsset("assets/app.css")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
manifest := ExportManifest{}
|
|
docs := make([]DocumentNode, 0, len(snap.Documents))
|
|
for _, d := range snap.Documents {
|
|
cp := d
|
|
if IsAssetsPath(cp.Path) {
|
|
cp.Body = ""
|
|
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
|
} else if cp.BodyTruncated {
|
|
manifest.Truncated = append(manifest.Truncated, cp.Path)
|
|
manifest.Included = append(manifest.Included, cp.Path)
|
|
} else if cp.Body != "" {
|
|
manifest.Included = append(manifest.Included, cp.Path)
|
|
} else {
|
|
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
|
}
|
|
// strip any accidental absolute secret-looking paths from body display only — keep relative
|
|
if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") {
|
|
manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention")
|
|
}
|
|
docs = append(docs, cp)
|
|
}
|
|
exportSnap := snap
|
|
exportSnap.Documents = docs
|
|
|
|
payload, err := MarshalSnapshotJSON(exportSnap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// HTML-safe JSON: break </script> sequences
|
|
safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c")
|
|
safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e")
|
|
safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026")
|
|
safeJSON = strings.ReplaceAll(safeJSON, "</script", "<\\/script")
|
|
|
|
manJSON, _ := json.MarshalIndent(manifest, "", " ")
|
|
manSafe := strings.ReplaceAll(string(manJSON), "<", "\\u003c")
|
|
|
|
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>工程中控台快照 %s</title>
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; base-uri 'none'">
|
|
<style>%s</style>
|
|
</head>
|
|
<body>
|
|
<header class="top">
|
|
<div>
|
|
<h1>个人工程中控台(离线快照)</h1>
|
|
<p class="meta">repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s</p>
|
|
</div>
|
|
<p class="banner" role="status">敏感快照 · 默认权限 0600 · 不含 assets 二进制 · 交接方自行控制分发</p>
|
|
</header>
|
|
<main id="main" style="padding:1rem">
|
|
<section class="panel">
|
|
<h2>覆盖清单</h2>
|
|
<pre class="pre" id="manifest"></pre>
|
|
</section>
|
|
<div id="content"></div>
|
|
</main>
|
|
<script type="application/json" id="snapshot-data">%s</script>
|
|
<script type="application/json" id="manifest-data">%s</script>
|
|
<script>
|
|
(function(){
|
|
const snap = JSON.parse(document.getElementById('snapshot-data').textContent);
|
|
const manifest = JSON.parse(document.getElementById('manifest-data').textContent);
|
|
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
|
|
const root = document.getElementById('content');
|
|
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
|
|
let html = '<section class="panel"><h2>任务四轴</h2>';
|
|
(snap.tasks||[]).forEach(t=>{
|
|
html += '<article><h3>'+esc(t.title)+'</h3>';
|
|
html += '<div class="axes">';
|
|
html += '<div class="axis"><strong>计划</strong>'+esc(t.doc_status)+'</div>';
|
|
html += '<div class="axis"><strong>Git</strong>'+esc(JSON.stringify(t.git||{}))+'</div>';
|
|
html += '<div class="axis"><strong>验证</strong>'+esc(JSON.stringify(t.verification||{}))+'</div>';
|
|
html += '<div class="axis"><strong>实装</strong>'+esc(JSON.stringify(t.deployment||{}))+'</div>';
|
|
html += '</div></article>';
|
|
});
|
|
html += '</section>';
|
|
html += '<section class="panel"><h2>冲突</h2><ul>'+(snap.conflicts||[]).map(c=>'<li>'+esc(c.kind+': '+c.summary)+'</li>').join('')+'</ul></section>';
|
|
html += '<section class="panel"><h2>文档正文</h2>';
|
|
(snap.documents||[]).forEach(d=>{
|
|
if(!d.body) return;
|
|
html += '<h3>'+esc(d.title)+'</h3><p class="meta">'+esc(d.path)+(d.body_truncated?' · truncated':'')+'</p>';
|
|
const pre=document.createElement('div');
|
|
pre.className='pre';
|
|
pre.textContent=d.body;
|
|
html += pre.outerHTML;
|
|
});
|
|
html += '</section>';
|
|
root.innerHTML = html;
|
|
// Note: textContent assignment above via outerHTML of node with textContent is safe.
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`, snap.RevisionID, string(css), snap.Repository.Root, snap.Repository.Head, snap.GenerationID, snap.RevisionID, snap.GeneratedAt.Format(timeRFC3339), safeJSON, manSafe)
|
|
|
|
dir := filepath.Dir(outputPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".engdash-*.html")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write([]byte(html)); err != nil {
|
|
tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, outputPath); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const timeRFC3339 = "2006-01-02T15:04:05Z07:00"
|