feat: secure web UI and expose runtime settings

This commit is contained in:
OpenClaw Agent
2026-09-18 06:58:06 +08:00
parent a0673ab79e
commit 0fbadf0c9c
3 changed files with 28 additions and 5 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ http://<NAS-IP>:18080/
- 历史:查看 Hard Link 操作,并对仍保持同 inode 的操作执行安全回滚。 - 历史:查看 Hard Link 操作,并对仍保持同 inode 的操作执行安全回滚。
- 扫描会把数据库中已经不存在的文件标记为 `missing`,避免脏索引继续计入统计。 - 扫描会把数据库中已经不存在的文件标记为 `missing`,避免脏索引继续计入统计。
> Web UI 当前面向 NAS 内网使用,未启用账号密码;不要直接暴露到公网。 > Web UI 支持可选管理 Token。生产环境建议在 NAS 的 Compose 同目录 `.env` 中设置 `AM_DEDUPER_TOKEN=<随机长字符串>`;不要把 Token 提交到 Git。设置后 Web UI 会在首次访问 API 时要求输入 Token。若未设置 Token,则仅适合可信内网环境,不要直接暴露到公网。
更新版本: 更新版本:
+25 -4
View File
@@ -5,10 +5,12 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from .db import connect from .db import connect
from .scanner import scan from .scanner import scan
from .exact import exact_dedupe, hardlink_duplicate from .exact import exact_dedupe, hardlink_duplicate, choose_winner
from .l2 import acoustic_dedupe from .l2 import acoustic_dedupe
ROOT=Path("/music/AllMusic"); DB=Path("/data/dedupe.db") ROOT=Path(os.environ.get("AM_DEDUPER_ROOT","/music/AllMusic")).resolve()
DB=Path(os.environ.get("AM_DEDUPER_DB","/data/dedupe.db")).resolve()
TOKEN=os.environ.get("AM_DEDUPER_TOKEN","").strip()
LOCK=threading.Lock() LOCK=threading.Lock()
HTML=r'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"> HTML=r'''<!doctype html><html lang="zh-CN"><meta charset="utf-8">
@@ -30,11 +32,12 @@ button.primary{background:#111827;color:#fff}button.danger{background:#fee2e2}ta
<section><h2>待人工审核</h2><div id="review"></div></section> <section><h2>待人工审核</h2><div id="review"></div></section>
<section><h2>任务</h2><div id="jobsBox"></div></section> <section><h2>任务</h2><div id="jobsBox"></div></section>
<section><h2>最近操作</h2><div id="historyBox"></div></section> <section><h2>最近操作</h2><div id="historyBox"></div></section>
<section><h2>设置</h2><div id="settingsBox" class="muted">加载中...</div></section>
</main> </main>
<script> <script>
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(u,o){let r=await fetch(u,o),j=await r.json();if(!r.ok)throw Error(j.error||r.status);return j} async function api(u,o={}){o.headers=Object.assign({},o.headers||{});let t=localStorage.getItem('am_token');if(t)o.headers.Authorization='Bearer '+t;let r=await fetch(u,o);let j=await r.json();if(r.status===401){t=prompt('请输入 AM Deduper 管理 Token');if(t){localStorage.setItem('am_token',t);return api(u,o)} }if(!r.ok)throw Error(j.error||r.status);return j}
async function load(){try{ async function load(){try{
let s=await api('/api/stats');stats.innerHTML=s.cards.map(x=>'<div class="card"><div class="muted">'+esc(x.name)+'</div><div class="num">'+esc(x.value)+'</div></div>').join(''); let s=await api('/api/stats');stats.innerHTML=s.cards.map(x=>'<div class="card"><div class="muted">'+esc(x.name)+'</div><div class="num">'+esc(x.value)+'</div></div>').join('');
let d=await api('/api/duplicates');dupes.innerHTML=d.groups.length?d.groups.map(g=>'<div class="card"><b>'+esc(g.type)+' · '+esc(g.savings)+' MB 可节省</b><table><tr><th>文件</th><th>状态</th></tr>'+g.items.map(x=>'<tr><td class="path">'+esc(x.file_path)+'</td><td>'+esc(x.status)+'</td></tr>').join('')+'</table></div>').join(''):'<p class="muted">暂无重复组。</p>'; let d=await api('/api/duplicates');dupes.innerHTML=d.groups.length?d.groups.map(g=>'<div class="card"><b>'+esc(g.type)+' · '+esc(g.savings)+' MB 可节省</b><table><tr><th>文件</th><th>状态</th></tr>'+g.items.map(x=>'<tr><td class="path">'+esc(x.file_path)+'</td><td>'+esc(x.status)+'</td></tr>').join('')+'</table></div>').join(''):'<p class="muted">暂无重复组。</p>';
@@ -47,6 +50,8 @@ let h=await api('/api/history');historyBox.innerHTML=h.items.length?'<table><tr>
async function run(kind){try{await api('/api/jobs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({kind})});load()}catch(e){alert(e.message)}} async function run(kind){try{await api('/api/jobs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({kind})});load()}catch(e){alert(e.message)}}
async function reviewAction(id,action,candidate_id){try{await api('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,action,candidate_id})});load()}catch(e){alert(e.message)}} async function reviewAction(id,action,candidate_id){try{await api('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,action,candidate_id})});load()}catch(e){alert(e.message)}}
async function rollback(id){if(!confirm('确认回滚此 Hard Link?'))return;try{await api('/api/rollback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})});load()}catch(e){alert(e.message)}} async function rollback(id){if(!confirm('确认回滚此 Hard Link?'))return;try{await api('/api/rollback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})});load()}catch(e){alert(e.message)}}
let cfg=await api('/api/settings');settingsBox.innerHTML='<div>音乐目录:<span class="path">'+esc(cfg.root)+'</span></div><div>数据库:<span class="path">'+esc(cfg.db)+'</span></div><div>认证:'+esc(cfg.auth)+'</div><div>L2:相似度 ≥ '+esc(cfg.l2_min)+' 自动判定,'+esc(cfg.l2_review)+' 起进入审核;时长窗口 ±'+esc(cfg.duration_delta)+' 秒</div>';
}catch(e){alert(e.message)}}
load();setInterval(load,5000); load();setInterval(load,5000);
</script></html>''' </script></html>'''
@@ -66,7 +71,11 @@ def duplicates():
for r in rows: groups.setdefault(r["sha256"],[]).append(dict(r)) for r in rows: groups.setdefault(r["sha256"],[]).append(dict(r))
out=[] out=[]
for _,items in groups.items(): for _,items in groups.items():
if len(items)>1: out.append({"type":"SHA256 完全重复","savings":round(sum(x["file_size"] for x in items[1:])/1024/1024,2),"items":items}) if len(items)>1:
winner=choose_winner(items)
ordered=[winner]+[x for x in items if x["id"]!=winner["id"]]
savings=round(sum(x["file_size"] for x in ordered[1:])/1024/1024,2)
out.append({"type":"SHA256 完全重复","savings":savings,"winner_id":winner["id"],"items":ordered})
return {"groups":out[:100]} return {"groups":out[:100]}
def review_items(): def review_items():
@@ -87,6 +96,11 @@ def jobs():
with connect(DB) as c: rows=c.execute("SELECT * FROM jobs ORDER BY id DESC LIMIT 30").fetchall() with connect(DB) as c: rows=c.execute("SELECT * FROM jobs ORDER BY id DESC LIMIT 30").fetchall()
return {"items":[dict(r) for r in rows]} return {"items":[dict(r) for r in rows]}
def settings():
from .l2 import MIN_SIMILARITY, REVIEW_SIMILARITY, DURATION_DELTA
return {"root":str(ROOT),"db":str(DB),"auth":"Token 已启用" if TOKEN else "未启用(仅建议内网)",
"l2_min":MIN_SIMILARITY,"l2_review":REVIEW_SIMILARITY,"duration_delta":DURATION_DELTA}
def run_job(kind): def run_job(kind):
with connect(DB) as c: with connect(DB) as c:
cur=c.execute("INSERT INTO jobs(kind,status) VALUES(?,?)",(kind,"running")); jid=cur.lastrowid cur=c.execute("INSERT INTO jobs(kind,status) VALUES(?,?)",(kind,"running")); jid=cur.lastrowid
@@ -133,20 +147,27 @@ class Handler(BaseHTTPRequestHandler):
def send(self,status,data,ctype="application/json"): def send(self,status,data,ctype="application/json"):
raw=data.encode() if isinstance(data,str) else json.dumps(data,ensure_ascii=False).encode() raw=data.encode() if isinstance(data,str) else json.dumps(data,ensure_ascii=False).encode()
self.send_response(status); self.send_header("Content-Type",ctype+"; charset=utf-8"); self.send_header("Content-Length",str(len(raw))); self.end_headers(); self.wfile.write(raw) self.send_response(status); self.send_header("Content-Type",ctype+"; charset=utf-8"); self.send_header("Content-Length",str(len(raw))); self.end_headers(); self.wfile.write(raw)
def authorized(self):
if not TOKEN: return True
return self.headers.get("Authorization","") == "Bearer "+TOKEN
def do_GET(self): def do_GET(self):
p=urlparse(self.path).path p=urlparse(self.path).path
try: try:
if p=="/": return self.send(200,HTML,"text/html") if p=="/": return self.send(200,HTML,"text/html")
if not self.authorized(): return self.send(401,{"error":"unauthorized"})
if p=="/api/stats": return self.send(200,stats()) if p=="/api/stats": return self.send(200,stats())
if p=="/api/duplicates": return self.send(200,duplicates()) if p=="/api/duplicates": return self.send(200,duplicates())
if p=="/api/review": return self.send(200,review_items()) if p=="/api/review": return self.send(200,review_items())
if p=="/api/history": return self.send(200,history()) if p=="/api/history": return self.send(200,history())
if p=="/api/jobs": return self.send(200,jobs()) if p=="/api/jobs": return self.send(200,jobs())
if p=="/api/settings": return self.send(200,settings())
return self.send(404,{"error":"not found"}) return self.send(404,{"error":"not found"})
except Exception as e: return self.send(500,{"error":str(e)}) except Exception as e: return self.send(500,{"error":str(e)})
def do_POST(self): def do_POST(self):
p=urlparse(self.path).path p=urlparse(self.path).path
if not self.authorized(): return self.send(401,{"error":"unauthorized"})
try: body=json.loads(self.rfile.read(int(self.headers.get("content-length","0"))) or b"{}") try: body=json.loads(self.rfile.read(int(self.headers.get("content-length","0"))) or b"{}")
except Exception: return self.send(400,{"error":"invalid json"}) except Exception: return self.send(400,{"error":"invalid json"})
try: try:
+2
View File
@@ -11,6 +11,7 @@ services:
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
AM_DEDUPER_TOKEN: ${AM_DEDUPER_TOKEN:-}
volumes: volumes:
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic - /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
- /vol2/1000/Docker/am-deduper/data:/data - /vol2/1000/Docker/am-deduper/data:/data
@@ -29,6 +30,7 @@ services:
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
AM_DEDUPER_TOKEN: ${AM_DEDUPER_TOKEN:-}
volumes: volumes:
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic - /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
- /vol2/1000/Docker/am-deduper/data:/data - /vol2/1000/Docker/am-deduper/data:/data