-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecure-relay.html
More file actions
114 lines (109 loc) · 21.1 KB
/
Copy pathsecure-relay.html
File metadata and controls
114 lines (109 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>安全中继记录入口</title>
<style>
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;background:#f6f8fc;color:#1f2937}
.wrap{max-width:940px;margin:0 auto;padding:24px 16px 48px}.card{background:#fff;border:1px solid #d8deea;border-radius:14px;padding:18px;margin-top:18px;box-shadow:0 10px 30px rgba(0,0,0,.05)}
h1{margin:0 0 8px;font-size:30px}h2{margin:0 0 14px;font-size:20px}p,.muted{color:#6b7280;line-height:1.6}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.full{grid-column:1/-1}
label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;box-sizing:border-box;border:1px solid #d8deea;border-radius:10px;padding:11px 12px;background:#fff}textarea{min-height:110px;resize:vertical}
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}button{border:0;border-radius:10px;padding:11px 16px;cursor:pointer;font-weight:600}.primary{background:#2563eb;color:#fff}.secondary{background:#eef2ff;color:#1e3a8a}.ghost{background:#f3f4f6;color:#374151}
.preview{background:#0b1020;color:#d6e2ff;border-radius:12px;padding:14px;white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.6;min-height:120px;overflow:auto}.status{display:none;margin-top:14px;padding:12px 14px;border-radius:10px;font-size:14px;line-height:1.6}.ok{display:block;background:#ecfdf5;color:#065f46}.warn{display:block;background:#fff7ed;color:#9a3412}.err{display:block;background:#fef2f2;color:#991b1b}
.share{margin-top:12px;padding:12px;border:1px dashed #bfdbfe;border-radius:12px;background:#f8fbff}.small{font-size:12px;color:#6b7280;line-height:1.6;margin-top:8px}code{background:#f3f4f6;padding:2px 6px;border-radius:6px}
@media (max-width:760px){.grid{grid-template-columns:1fr}h1{font-size:26px}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div style="display:inline-block;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;border-radius:999px;padding:4px 10px;font-size:12px;margin-bottom:10px">安全中继版</div>
<h1>结构化记录入口</h1>
<p>这个页面先在浏览器里加密,再通过邮件客户端、Gmail,或者第三方表单中继平台发送密文。拿到含密钥分享链接的人可以直接打开表单并发送。</p>
</div>
<div class="card">
<h2>安全与中继配置</h2>
<p class="muted">建议把敏感配置放在分享链接的 <code>#fragment</code> 里,而不是写进公开仓库。拿到完整含密钥链接的人就拥有提交权限。</p>
<div class="grid">
<div class="full"><label for="recipientEmail">收件邮箱</label><input id="recipientEmail" type="email" placeholder="例如 records@example.com" /></div>
<div><label for="subjectPrefix">主题前缀</label><input id="subjectPrefix" type="text" value="[OpenClaw Secure Record]" /></div>
<div><label for="sendMode">发送方式</label><select id="sendMode"><option value="relay_post">第三方表单中继 POST</option><option value="mailto">默认邮件客户端</option><option value="gmail">Gmail 写信页</option><option value="copy">只复制密文</option></select></div>
<div><label for="keyId">密钥 ID (kid)</label><input id="keyId" type="text" placeholder="例如 record-link-001" /></div>
<div><label for="authToken">授权密钥</label><input id="authToken" type="password" placeholder="共享密钥 / 令牌" /></div>
<div class="full"><label for="publicKeyPem">OpenClaw 公钥 PEM(RSA-OAEP, SPKI)</label><textarea id="publicKeyPem" placeholder="-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----"></textarea></div>
</div>
<div id="relayConfig" style="margin-top:18px">
<h2 style="font-size:18px">第三方表单中继配置</h2>
<div class="grid">
<div class="full"><label for="relayUrl">Relay POST URL</label><input id="relayUrl" type="url" placeholder="例如 https://example-form-service.com/submit" /></div>
<div><label for="relayBodyField">密文字段名</label><input id="relayBodyField" type="text" value="message" /></div>
<div><label for="relaySubjectField">主题字段名</label><input id="relaySubjectField" type="text" value="subject" /></div>
<div><label for="relayToField">收件人字段名</label><input id="relayToField" type="text" value="to" /></div>
<div class="full"><label for="relayExtraFields">附加隐藏字段(JSON)</label><textarea id="relayExtraFields" placeholder='例如 {"_captcha":"false","_template":"table"}'></textarea></div>
</div>
<div class="small">这个模式使用原生 HTML form POST,不用 fetch,因此不受浏览器 CORS 预检限制。是否能成功取决于第三方平台是否支持公开表单入口。</div>
</div>
<div class="toolbar">
<button class="secondary" type="button" id="saveConfigBtn">保存配置</button>
<button class="ghost" type="button" id="clearConfigBtn">清空配置</button>
</div>
<div class="share">
<label for="shareUrl">分享链接</label>
<input id="shareUrl" type="text" readonly />
<div class="toolbar">
<button class="secondary" type="button" id="copyShareWithoutSecretBtn">复制链接(不含密钥)</button>
<button class="ghost" type="button" id="copyShareWithSecretBtn">复制链接(含密钥)</button>
</div>
<div class="small">只有要直接授权别人使用时,才分享含密钥版本。默认更推荐不含密钥版本。</div>
</div>
<div id="configStatus" class="status"></div>
</div>
<div class="card">
<h2>记录表单</h2>
<div class="grid"><div class="full"><label for="feature">你要调用什么功能?</label><select id="feature"><option value="blood_glucose">记血糖</option><option value="accounting">记账</option></select></div></div>
<form id="recordForm"><div id="dynamicFields" class="grid" style="margin-top:14px"></div><div class="toolbar"><button class="primary" type="submit">加密并发送</button><button class="ghost" type="button" id="fillNowBtn">时间设为现在</button></div></form>
<div id="submitStatus" class="status"></div>
</div>
<div class="card"><h2>密文信封预览</h2><div id="envelopePreview" class="preview"></div></div>
</div>
<script>
const STORAGE_KEY='recording_app_secure_relay_config_v1';
const bloodGlucoseFields=[{key:'timestamp',label:'记录时间',type:'datetime-local',required:true},{key:'glucose_value',label:'血糖值',type:'number',step:'0.1',required:true,placeholder:'例如 5.4'},{key:'unit',label:'单位',type:'select',required:true,options:['mmol/L','mg/dL']},{key:'context',label:'场景',type:'select',required:true,options:['空腹','餐前','餐后1h','餐后2h','睡前','随机']},{key:'note',label:'备注',type:'textarea',required:false,full:true,placeholder:'可选'}];
const accountingFields=[{key:'timestamp',label:'记录时间',type:'datetime-local',required:true},{key:'direction',label:'收支方向',type:'select',required:true,options:['支出','收入']},{key:'category',label:'分类',type:'select',required:true,options:['餐饮','交通','购物','学习','房租','医疗','其他']},{key:'item',label:'项目说明',type:'text',required:true,placeholder:'例如 咖啡 / 工资'},{key:'amount',label:'金额',type:'number',step:'0.01',required:true,placeholder:'例如 3.50'},{key:'currency',label:'币种',type:'select',required:true,options:['EUR','CNY','USD']},{key:'payment_method',label:'支付方式',type:'select',required:true,options:['现金','银行卡','微信','支付宝','其他']},{key:'note',label:'备注',type:'textarea',required:false,full:true,placeholder:'可选'}];
const recipientEmailInput=document.getElementById('recipientEmail'); const subjectPrefixInput=document.getElementById('subjectPrefix'); const sendModeInput=document.getElementById('sendMode'); const keyIdInput=document.getElementById('keyId'); const authTokenInput=document.getElementById('authToken'); const publicKeyPemInput=document.getElementById('publicKeyPem'); const relayUrlInput=document.getElementById('relayUrl'); const relayBodyFieldInput=document.getElementById('relayBodyField'); const relaySubjectFieldInput=document.getElementById('relaySubjectField'); const relayToFieldInput=document.getElementById('relayToField'); const relayExtraFieldsInput=document.getElementById('relayExtraFields'); const shareUrlInput=document.getElementById('shareUrl'); const configStatus=document.getElementById('configStatus'); const featureSelect=document.getElementById('feature'); const dynamicFields=document.getElementById('dynamicFields'); const recordForm=document.getElementById('recordForm'); const submitStatus=document.getElementById('submitStatus'); const envelopePreview=document.getElementById('envelopePreview'); const relayConfigBox=document.getElementById('relayConfig');
function nowLocalValue(){const d=new Date();const p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}
function setStatus(el,kind,text){el.className=`status ${kind}`;el.textContent=text;} function clearStatus(el){el.className='status';el.textContent='';}
function getFields(){return featureSelect.value==='accounting'?accountingFields:bloodGlucoseFields;}
function createField(field){const wrap=document.createElement('div');if(field.full)wrap.classList.add('full');const label=document.createElement('label');label.htmlFor=field.key;label.textContent=field.label+(field.required?' *':'');wrap.appendChild(label);let input;if(field.type==='select'){input=document.createElement('select');field.options.forEach(opt=>{const o=document.createElement('option');o.value=opt;o.textContent=opt;input.appendChild(o);});}else if(field.type==='textarea'){input=document.createElement('textarea');input.placeholder=field.placeholder||'';}else{input=document.createElement('input');input.type=field.type;input.placeholder=field.placeholder||'';if(field.step)input.step=field.step;if(field.type==='datetime-local')input.value=nowLocalValue();}input.id=field.key;input.required=!!field.required;input.addEventListener('input',()=>updateEnvelopePreview(false));input.addEventListener('change',()=>updateEnvelopePreview(false));wrap.appendChild(input);return wrap;}
function renderFields(){dynamicFields.innerHTML='';getFields().forEach(f=>dynamicFields.appendChild(createField(f)));updateEnvelopePreview(false);clearStatus(submitStatus);}
function collectPayload(){const payload={source:'github-pages-secure-relay-form',feature:featureSelect.value};getFields().forEach(field=>{const el=document.getElementById(field.key);let value=el?el.value:'';if(field.type==='number'&&value!=='')value=Number(value);payload[field.key]=value;});return payload;}
function normalizePem(pem){return pem.replace(/-----BEGIN PUBLIC KEY-----/g,'').replace(/-----END PUBLIC KEY-----/g,'').replace(/\s+/g,'');}
function base64ToBytes(base64){const binary=atob(base64);const bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;}
function bytesToBase64(bytes){let bin='';bytes.forEach(b=>bin+=String.fromCharCode(b));return btoa(bin);} function base64UrlEncode(bytes){return bytesToBase64(bytes).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
function textToBytes(text){return new TextEncoder().encode(text);} function bytesToText(bytes){return new TextDecoder().decode(bytes);} function randomBytes(n){const b=new Uint8Array(n);crypto.getRandomValues(b);return b;}
async function importRsaPublicKey(pem){const der=base64ToBytes(normalizePem(pem));return crypto.subtle.importKey('spki',der.buffer,{name:'RSA-OAEP',hash:'SHA-256'},false,['encrypt']);}
async function hmacSha256(secret,message){const key=await crypto.subtle.importKey('raw',textToBytes(secret),{name:'HMAC',hash:'SHA-256'},false,['sign']);const sig=await crypto.subtle.sign('HMAC',key,textToBytes(message));return new Uint8Array(sig);}
async function buildEnvelope(){const pem=publicKeyPemInput.value.trim(); const kid=keyIdInput.value.trim(); const secret=authTokenInput.value.trim(); if(!pem||!kid||!secret) return null; const payload=collectPayload(); const plaintext=textToBytes(JSON.stringify(payload)); const aesKey=await crypto.subtle.generateKey({name:'AES-GCM',length:256},true,['encrypt']); const iv=randomBytes(12); const ctBuf=await crypto.subtle.encrypt({name:'AES-GCM',iv},aesKey,plaintext); const rawAes=await crypto.subtle.exportKey('raw',aesKey); const pub=await importRsaPublicKey(pem); const ekBuf=await crypto.subtle.encrypt({name:'RSA-OAEP'},pub,rawAes); const ek=base64UrlEncode(new Uint8Array(ekBuf)); const ivs=base64UrlEncode(iv); const ct=base64UrlEncode(new Uint8Array(ctBuf)); const nonce=base64UrlEncode(randomBytes(16)); const ts=Date.now(); const canonical=[1,kid,ts,nonce,ek,ivs,ct].join('.'); const mac=base64UrlEncode(await hmacSha256(secret,canonical)); return {v:1,kid,ts,nonce,alg:'RSA-OAEP-256/AES-256-GCM/HMAC-SHA-256',ek,iv:ivs,ct,mac}; }
function buildSubject(){return subjectPrefixInput.value.trim()||'[OpenClaw Secure Record]';}
function buildBody(envelope){return 'OPENCLAW_SECURE_RECORD_V1\n'+JSON.stringify(envelope,null,2);}
function encodeTextToB64u(text){return base64UrlEncode(textToBytes(text));} function decodeB64uToText(str){if(!str) return ''; let b64=str.replace(/-/g,'+').replace(/_/g,'/'); while(b64.length%4) b64+='='; return bytesToText(base64ToBytes(b64)); }
function updateRelayVisibility(){relayConfigBox.style.display = sendModeInput.value==='relay_post' ? 'block' : 'none';}
function updateShareUrl(includeSecret=false){const p=new URLSearchParams(); [['recipientEmail',recipientEmailInput.value.trim()],['subjectPrefix',subjectPrefixInput.value.trim()],['sendMode',sendModeInput.value],['keyId',keyIdInput.value.trim()],['relayUrl',relayUrlInput.value.trim()],['relayBodyField',relayBodyFieldInput.value.trim()],['relaySubjectField',relaySubjectFieldInput.value.trim()],['relayToField',relayToFieldInput.value.trim()],['relayExtraFields',relayExtraFieldsInput.value.trim()]].forEach(([k,v])=>{if(v)p.set(k,v);}); if(publicKeyPemInput.value.trim()) p.set('pub',encodeTextToB64u(publicKeyPemInput.value.trim())); if(includeSecret&&authTokenInput.value.trim()) p.set('authToken',authTokenInput.value.trim()); shareUrlInput.value=`${location.origin}${location.pathname}${p.toString()?'#'+p.toString():''}`; }
async function copyShare(includeSecret){updateShareUrl(includeSecret); try{await navigator.clipboard.writeText(shareUrlInput.value); setStatus(configStatus,'ok',includeSecret?'已复制含密钥分享链接。':'已复制不含密钥分享链接。');}catch{setStatus(configStatus,'warn','复制失败,请手动复制。');}}
function saveConfig(){localStorage.setItem(STORAGE_KEY,JSON.stringify({recipientEmail:recipientEmailInput.value.trim(),subjectPrefix:subjectPrefixInput.value.trim(),sendMode:sendModeInput.value,keyId:keyIdInput.value.trim(),authToken:authTokenInput.value.trim(),publicKeyPem:publicKeyPemInput.value.trim(),relayUrl:relayUrlInput.value.trim(),relayBodyField:relayBodyFieldInput.value.trim(),relaySubjectField:relaySubjectFieldInput.value.trim(),relayToField:relayToFieldInput.value.trim(),relayExtraFields:relayExtraFieldsInput.value.trim()})); updateShareUrl(false); setStatus(configStatus,'ok','配置已保存到当前浏览器。');}
function clearConfig(){localStorage.removeItem(STORAGE_KEY); recipientEmailInput.value=''; subjectPrefixInput.value='[OpenClaw Secure Record]'; sendModeInput.value='relay_post'; keyIdInput.value=''; authTokenInput.value=''; publicKeyPemInput.value=''; relayUrlInput.value=''; relayBodyFieldInput.value='message'; relaySubjectFieldInput.value='subject'; relayToFieldInput.value='to'; relayExtraFieldsInput.value=''; updateRelayVisibility(); updateShareUrl(false); setStatus(configStatus,'warn','已清空当前浏览器中的配置。'); updateEnvelopePreview(false);}
function loadConfig(){try{const raw=localStorage.getItem(STORAGE_KEY); if(!raw)return; const c=JSON.parse(raw); recipientEmailInput.value=c.recipientEmail||''; subjectPrefixInput.value=c.subjectPrefix||'[OpenClaw Secure Record]'; sendModeInput.value=c.sendMode||'relay_post'; keyIdInput.value=c.keyId||''; authTokenInput.value=c.authToken||''; publicKeyPemInput.value=c.publicKeyPem||''; relayUrlInput.value=c.relayUrl||''; relayBodyFieldInput.value=c.relayBodyField||'message'; relaySubjectFieldInput.value=c.relaySubjectField||'subject'; relayToFieldInput.value=c.relayToField||'to'; relayExtraFieldsInput.value=c.relayExtraFields||'';}catch(e){console.warn(e);}}
function loadHashConfig(){const hash=location.hash?location.hash.slice(1):''; if(!hash)return; const p=new URLSearchParams(hash); [['recipientEmail',recipientEmailInput],['subjectPrefix',subjectPrefixInput],['sendMode',sendModeInput],['keyId',keyIdInput],['authToken',authTokenInput],['relayUrl',relayUrlInput],['relayBodyField',relayBodyFieldInput],['relaySubjectField',relaySubjectFieldInput],['relayToField',relayToFieldInput],['relayExtraFields',relayExtraFieldsInput]].forEach(([k,el])=>{if(p.get(k))el.value=p.get(k);}); if(p.get('pub')) publicKeyPemInput.value=decodeB64uToText(p.get('pub')); }
async function updateEnvelopePreview(showErrors){try{const env=await buildEnvelope(); envelopePreview.textContent=env?JSON.stringify(env,null,2):'请先填写 kid、授权密钥和公钥。';}catch(e){envelopePreview.textContent='信封生成失败:'+e.message; if(showErrors) setStatus(submitStatus,'err','信封生成失败:'+e.message); }}
function openMailto(to,subject,body){location.href=`mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;} function openGmail(to,subject,body){const url=new URL('https://mail.google.com/mail/u/0/'); url.searchParams.set('view','cm'); url.searchParams.set('fs','1'); url.searchParams.set('to',to); url.searchParams.set('su',subject); url.searchParams.set('body',body); window.open(url.toString(),'_blank','noopener');}
async function copyText(text){try{await navigator.clipboard.writeText(text); setStatus(submitStatus,'ok','密文已复制。');}catch{setStatus(submitStatus,'warn','复制失败,请手动复制。');}}
function postRelayForm(fields){const action=relayUrlInput.value.trim(); if(!action){setStatus(submitStatus,'err','请先填写 Relay POST URL。'); return false;} let extras={}; const extraRaw=relayExtraFieldsInput.value.trim(); if(extraRaw){try{extras=JSON.parse(extraRaw);}catch{setStatus(submitStatus,'err','附加隐藏字段 JSON 格式错误。'); return false;}} const form=document.createElement('form'); form.method='POST'; form.action=action; form.target='_blank'; form.style.display='none'; Object.entries({...extras,...fields}).forEach(([k,v])=>{if(k&&v!==undefined&&v!==null&&String(v)!==''){const input=document.createElement('input'); input.type='hidden'; input.name=k; input.value=String(v); form.appendChild(input);}}); document.body.appendChild(form); form.submit(); form.remove(); return true; }
function fillNow(){const ts=document.getElementById('timestamp'); if(ts){ts.value=nowLocalValue(); updateEnvelopePreview(false);}}
async function submitRecord(e){e.preventDefault(); clearStatus(submitStatus); if(!recordForm.reportValidity()){setStatus(submitStatus,'err','请先补全必填项。'); return;} const to=recipientEmailInput.value.trim(); const env=await buildEnvelope().catch(err=>{setStatus(submitStatus,'err','加密失败:'+err.message); return null;}); if(!env) return; const subject=buildSubject(); const body=buildBody(env); if(sendModeInput.value==='relay_post'){const ok=postRelayForm({[relayBodyFieldInput.value.trim()||'message']:body,[relaySubjectFieldInput.value.trim()||'subject']:subject,[relayToFieldInput.value.trim()||'to']:to}); if(ok) setStatus(submitStatus,'ok','已向第三方表单中继提交密文。'); return;} if(!to){setStatus(submitStatus,'err','请先填写收件邮箱。'); return;} if(sendModeInput.value==='mailto'){openMailto(to,subject,body); setStatus(submitStatus,'ok','已尝试打开默认邮件客户端。');} else if(sendModeInput.value==='gmail'){openGmail(to,subject,body); setStatus(submitStatus,'ok','已打开 Gmail 写信页。');} else {await copyText(body);} }
['input','change'].forEach(ev=>{[recipientEmailInput,subjectPrefixInput,sendModeInput,keyIdInput,authTokenInput,publicKeyPemInput,relayUrlInput,relayBodyFieldInput,relaySubjectFieldInput,relayToFieldInput,relayExtraFieldsInput].forEach(el=>el.addEventListener(ev,()=>{updateRelayVisibility(); updateShareUrl(false);}));});
document.getElementById('saveConfigBtn').addEventListener('click',saveConfig); document.getElementById('clearConfigBtn').addEventListener('click',clearConfig); document.getElementById('copyShareWithoutSecretBtn').addEventListener('click',()=>copyShare(false)); document.getElementById('copyShareWithSecretBtn').addEventListener('click',()=>copyShare(true)); document.getElementById('fillNowBtn').addEventListener('click',fillNow); featureSelect.addEventListener('change',renderFields); recordForm.addEventListener('submit',submitRecord);
loadConfig(); loadHashConfig(); updateRelayVisibility(); renderFields(); updateShareUrl(false);
</script>
</body>
</html>