feat: secure web UI and expose runtime settings
This commit is contained in:
@@ -125,7 +125,7 @@ http://<NAS-IP>:18080/
|
||||
- 历史:查看 Hard Link 操作,并对仍保持同 inode 的操作执行安全回滚。
|
||||
- 扫描会把数据库中已经不存在的文件标记为 `missing`,避免脏索引继续计入统计。
|
||||
|
||||
> Web UI 当前面向 NAS 内网使用,未启用账号密码;不要直接暴露到公网。
|
||||
> Web UI 支持可选管理 Token。生产环境建议在 NAS 的 Compose 同目录 `.env` 中设置 `AM_DEDUPER_TOKEN=<随机长字符串>`;不要把 Token 提交到 Git。设置后 Web UI 会在首次访问 API 时要求输入 Token。若未设置 Token,则仅适合可信内网环境,不要直接暴露到公网。
|
||||
|
||||
更新版本:
|
||||
|
||||
|
||||
+25
-4
@@ -5,10 +5,12 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from .db import connect
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
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="jobsBox"></div></section>
|
||||
<section><h2>最近操作</h2><div id="historyBox"></div></section>
|
||||
<section><h2>设置</h2><div id="settingsBox" class="muted">加载中...</div></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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{
|
||||
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>';
|
||||
@@ -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 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)}}
|
||||
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);
|
||||
</script></html>'''
|
||||
|
||||
@@ -66,7 +71,11 @@ def duplicates():
|
||||
for r in rows: groups.setdefault(r["sha256"],[]).append(dict(r))
|
||||
out=[]
|
||||
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]}
|
||||
|
||||
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()
|
||||
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):
|
||||
with connect(DB) as c:
|
||||
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"):
|
||||
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)
|
||||
def authorized(self):
|
||||
if not TOKEN: return True
|
||||
return self.headers.get("Authorization","") == "Bearer "+TOKEN
|
||||
|
||||
def do_GET(self):
|
||||
p=urlparse(self.path).path
|
||||
try:
|
||||
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/duplicates": return self.send(200,duplicates())
|
||||
if p=="/api/review": return self.send(200,review_items())
|
||||
if p=="/api/history": return self.send(200,history())
|
||||
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"})
|
||||
except Exception as e: return self.send(500,{"error":str(e)})
|
||||
|
||||
def do_POST(self):
|
||||
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"{}")
|
||||
except Exception: return self.send(400,{"error":"invalid json"})
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONUNBUFFERED: "1"
|
||||
AM_DEDUPER_TOKEN: ${AM_DEDUPER_TOKEN:-}
|
||||
volumes:
|
||||
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
|
||||
- /vol2/1000/Docker/am-deduper/data:/data
|
||||
@@ -29,6 +30,7 @@ services:
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONUNBUFFERED: "1"
|
||||
AM_DEDUPER_TOKEN: ${AM_DEDUPER_TOKEN:-}
|
||||
volumes:
|
||||
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
|
||||
- /vol2/1000/Docker/am-deduper/data:/data
|
||||
|
||||
Reference in New Issue
Block a user