From 5763e94a7445426527e217acc07f1458aea95356 Mon Sep 17 00:00:00 2001 From: Pigway Date: Fri, 2 Oct 2026 01:11:17 +0800 Subject: [PATCH] Support secure idempotency UUIDs on plain LAN HTTP --- pigway_frame_flow/web/app.js | 4 +++- scripts/ui-smoke.cjs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 08b8aac..2ca9d32 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -31,7 +31,9 @@ async function navigate(page=state.page){state.page=icons[page]?page:'dashboard' try{await views[state.page](epoch);}catch(e){if(epoch!==state.epoch)return;$('#main').innerHTML=`
${t('apiError')}

${esc(e.message)}

${buttons('retry','refresh')}
`;bind('retry',()=>navigate());} } function login(){ $('#main').innerHTML=`
FRAME FLOW

${t('connect')}

${t('tokenHint')}

`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();state.token=$('#api-token').value.trim();try{state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;await navigate();}catch(error){state.token='';toast(error.message);}});} -async function accepted(path,payload){const key='web-'+crypto.randomUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;} +// LAN HTTP is not a secure context; getRandomValues remains available there. +function clientUUID(){if(typeof crypto.randomUUID==='function')return crypto.randomUUID();const bytes=crypto.getRandomValues(new Uint8Array(16));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const hex=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;} +async function accepted(path,payload){const key='web-'+clientUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;} async function waitOperation(id){for(let n=0;n<120;n++){const operation=await api('/api/v1/operations/'+id);if(operation.status==='COMPLETED')return operation;if(['FAILED','UNKNOWN'].includes(operation.status))throw new Error(operation.status==='UNKNOWN'?t('unknown'):(operation.error?.message||t('error')));await new Promise(resolve=>setTimeout(resolve,2000));}throw new Error(t('syncing'));} function showModal(html){$('#modal-content').innerHTML=html;$('#modal').showModal();bind('modal-cancel',()=>$('#modal').close());} async function confirmDialog(message,fn){showModal(`

${t('confirm')}

${esc(message)}

`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});} diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index 3945d32..542d2c8 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -5,7 +5,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode'); const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0; - await page.goto(base);await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++; + await page.addInitScript(()=>Object.defineProperty(window.crypto,'randomUUID',{value:undefined,configurable:true}));await page.goto(base);await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++; const shots=process.env.FRAME_FLOW_SCREENSHOT_DIR||path.join(root,'docs/screenshots');fs.mkdirSync(shots,{recursive:true});await page.screenshot({path:path.join(shots,'dashboard-light-zh.png'),fullPage:true}); await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++; await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++;