From 672a8e22372cb28ba89102a818b87f11b5296ee0 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Mon, 7 Sep 2026 18:11:05 +0300 Subject: [PATCH] =?UTF-8?q?Caterium=20v17.6.1=20=E2=80=94=20chat=20photo?= =?UTF-8?q?=20compression=20and=20server=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v17.6.1 after green GitHub QA and Cloudflare preview checks. --- .github/workflows/qa.yml | 3 + docs/releases/V17.6.1-CHANGES.txt | 26 +++++++ .../SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql | 45 ++++++++++++ package.json | 2 +- public/core/performance.js | 68 ++++++++++++++++++- public/service-worker.js | 4 +- tests/app.spec.mjs | 22 +++++- tests/release-check.mjs | 8 ++- tests/static-security.mjs | 6 +- 9 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 docs/releases/V17.6.1-CHANGES.txt create mode 100644 ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 6a40f71..b5afccb 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -2,6 +2,9 @@ name: Caterium QA on: push: pull_request: +concurrency: + group: caterium-qa-${{ github.ref }} + cancel-in-progress: true jobs: qa: runs-on: ubuntu-latest diff --git a/docs/releases/V17.6.1-CHANGES.txt b/docs/releases/V17.6.1-CHANGES.txt new file mode 100644 index 0000000..e8ee90e --- /dev/null +++ b/docs/releases/V17.6.1-CHANGES.txt @@ -0,0 +1,26 @@ +Caterium v17.6.1 — Chat photo compression + server memory counters +Date: 2026-09-07 + +1. Photos selected in chat are optimized in the browser before upload. + - Maximum side: 2048 px. + - Target size: about 2 MB. + - Absolute attachment limit remains 15 MB. + - Non-image files are unchanged; GIF/SVG are not recompressed. + - Browsers without DataTransfer fall back to the original chat upload path. + +2. Developer Console now receives server-side memory metrics: + - PostgreSQL database size. + - Supabase Storage size. + - Storage object count. + - Total server footprint (database + Storage). + - Per-bucket Storage breakdown is also returned by the RPC. + +3. Supabase migration: SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql. + +4. GitHub QA initialization race fixed: + - Playwright waits for SunSafe and SunPdfEngine before invoking them. + - Added an automated large-photo compression test. + +5. PWA cache bumped to v66 / v17.6.1-chat-storage-guard. + +Release flow: feature/chat-storage-guard-v1761 -> GitHub QA + Cloudflare preview -> main -> Cloudflare production. diff --git a/ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql b/ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql new file mode 100644 index 0000000..052272f --- /dev/null +++ b/ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql @@ -0,0 +1,45 @@ +-- Caterium v17.6.1: server memory counters for Developer Console +create or replace function public.sun_platform_dashboard() +returns jsonb +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare + v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; + v_locked bigint; v_errors bigint; v_backups bigint; v_members bigint; + v_last_state timestamptz; v_last_backup timestamptz; + v_database_bytes bigint := 0; v_storage_bytes bigint := 0; v_storage_objects bigint := 0; + v_storage_buckets jsonb := '{}'::jsonb; +begin + perform public.sun_require_platform_admin_aal2(); + select count(*) into v_companies from public.sun_workspaces; + select count(*) into v_accounts from auth.users; + select count(*) into v_admins from public.sun_platform_admins; + select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); + select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); + select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; + select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; + select max(updated_at) into v_last_state from public.sun_app_state; + select max(created_at) into v_last_backup from public.sun_v17_backups; + v_database_bytes := pg_database_size(current_database()); + select count(*), coalesce(sum(case when coalesce(metadata->>'size','') ~ '^[0-9]+$' then (metadata->>'size')::bigint else 0 end),0) + into v_storage_objects, v_storage_bytes from storage.objects; + select coalesce(jsonb_object_agg(bucket_id,jsonb_build_object('objects',object_count,'bytes',bucket_bytes,'size',pg_size_pretty(bucket_bytes))),'{}'::jsonb) + into v_storage_buckets + from (select bucket_id,count(*)::bigint as object_count,coalesce(sum(case when coalesce(metadata->>'size','') ~ '^[0-9]+$' then (metadata->>'size')::bigint else 0 end),0)::bigint as bucket_bytes from storage.objects group by bucket_id) s; + return jsonb_build_object( + 'companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members, + 'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked, + 'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup, + 'database_bytes',v_database_bytes,'database_size',pg_size_pretty(v_database_bytes), + 'storage_bytes',v_storage_bytes,'storage_size',pg_size_pretty(v_storage_bytes),'storage_objects',v_storage_objects,'storage_buckets',v_storage_buckets, + 'server_bytes',v_database_bytes+v_storage_bytes,'server_size',pg_size_pretty(v_database_bytes+v_storage_bytes), + 'postgres_version',current_setting('server_version')); +end; +$$; +revoke all on function public.sun_platform_dashboard() from public, anon; +grant execute on function public.sun_platform_dashboard() to authenticated; diff --git a/package.json b/package.json index d061d2d..43a0205 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "caterium-app", "private": true, - "version": "17.6.0", + "version": "17.6.1", "type": "module", "scripts": { "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/pdf-engine.js", diff --git a/public/core/performance.js b/public/core/performance.js index ef022f3..c299a3c 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -11,11 +11,73 @@ if(root instanceof HTMLImageElement)tune(root); root?.querySelectorAll?.('img').forEach(tune); }; + + const MAX_FILE=15*1024*1024,TARGET=2*1024*1024,MAX_SIDE=2048; + const guardedInputs=new WeakSet(); + const toast=(text,type='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,ms)}catch(_){}}; + const jpegName=name=>String(name||'photo').replace(/\.[^.]+$/,'')+'.jpg'; + const canvasBlob=(canvas,type,quality)=>new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сжать изображение.')),type,quality)); + async function decodeImage(file){ + if('createImageBitmap' in window){const bitmap=await createImageBitmap(file);return {source:bitmap,width:bitmap.width,height:bitmap.height,close:()=>bitmap.close?.()};} + const url=URL.createObjectURL(file);try{const img=new Image();await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=reject;img.src=url});return {source:img,width:img.naturalWidth,height:img.naturalHeight,close:()=>URL.revokeObjectURL(url)};}catch(e){URL.revokeObjectURL(url);throw e;} + } + async function compressImage(file){ + if(!(file instanceof File)||!String(file.type||'').startsWith('image/')||/gif|svg/i.test(file.type||''))return file; + let decoded;try{decoded=await decodeImage(file);}catch(_){return file;} + try{ + const {width,height,source}=decoded;if(!width||!height)return file; + if(file.size<=TARGET&&Math.max(width,height)<=MAX_SIDE)return file; + let scale=Math.min(1,MAX_SIDE/Math.max(width,height)),quality=.84,best=null; + for(let pass=0;pass<5;pass++){ + const w=Math.max(1,Math.round(width*scale)),h=Math.max(1,Math.round(height*scale)); + const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h; + const ctx=canvas.getContext('2d',{alpha:false});if(!ctx)return file;ctx.fillStyle='#fff';ctx.fillRect(0,0,w,h);ctx.drawImage(source,0,0,w,h); + const blob=await canvasBlob(canvas,'image/jpeg',quality);if(!best||blob.size=file.size)return file; + return new File([best],jpegName(file.name),{type:'image/jpeg',lastModified:file.lastModified||Date.now()}); + }finally{decoded.close?.();} + } + async function prepareAttachment(file){const prepared=await compressImage(file);if(prepared.size>MAX_FILE)throw new Error(`Файл «${file.name}» больше 15 МБ даже после оптимизации.`);return {file:prepared,originalSize:file.size,savedBytes:Math.max(0,file.size-prepared.size)};} + async function guardChatFiles(input){ + const raw=[...(input.files||[])];if(!raw.length)return; + const prepared=[];let saved=0; + for(const f of raw){try{const p=await prepareAttachment(f);prepared.push(p.file);saved+=p.savedBytes;}catch(e){toast(e.message||String(e),'warn',6000);}} + if(!prepared.length){input.value='';return;} + const dt=new DataTransfer();prepared.forEach(f=>dt.items.add(f));input.files=dt.files; + guardedInputs.add(input);input.dispatchEvent(new Event('change',{bubbles:true}));guardedInputs.delete(input); + if(saved>256*1024)toast(`Фото оптимизированы: сэкономлено ${(saved/1024/1024).toFixed(1)} МБ.`,'success',4200); + } + document.addEventListener('change',e=>{ + const input=e.target;if(!(input instanceof HTMLInputElement)||input.id!=='sunChatFilesV29'||guardedInputs.has(input)||typeof DataTransfer==='undefined')return; + e.preventDefault();e.stopImmediatePropagation();guardChatFiles(input).catch(err=>{console.error('[Caterium photo compression]',err);guardedInputs.add(input);input.dispatchEvent(new Event('change',{bubbles:true}));guardedInputs.delete(input);}); + },true); + window.SunAttachmentGuard={VERSION:'17.6.1',MAX_FILE,TARGET,MAX_SIDE,compressImage,prepareAttachment}; + + let memoryData=null,memoryAt=0,memoryLoading=false; + const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??''); + async function loadMemory(){ + const now=Date.now();if(memoryLoading)return memoryData;if(memoryData&&now-memoryAt<5000)return memoryData; + const c=window.SunCloudV2?.getClient?.();if(!c)return null;memoryLoading=true; + try{const r=await c.rpc('sun_dev_dashboard');if(r.error)throw r.error;memoryData=r.data||null;memoryAt=Date.now();return memoryData;}catch(_){return null}finally{memoryLoading=false;} + } + async function enhanceDeveloperMemory(){ + const view=document.getElementById('sun-developer-console-v22');if(!view?.classList.contains('on'))return; + const body=document.getElementById('sunDevBody');if(!body)return; + const version=document.getElementById('sunDevReleaseVersion');if(version)version.textContent='17.6.1'; + const d=await loadMemory();if(!d||!view.classList.contains('on'))return; + let box=document.getElementById('sunDevMemoryV1761');if(!box){box=document.createElement('div');box.id='sunDevMemoryV1761';box.className='sun-dev-grid';box.style.marginBottom='12px';body.prepend(box);} + box.innerHTML=`
Память сервера${esc(d.server_size||d.database_size||'—')}
База PostgreSQL${esc(d.database_size||'—')}
Файлы Storage${esc(d.storage_size||'—')}
Объектов Storage${Number(d.storage_objects||0)}
`; + } const start=()=>{ scan(document); - const mo=new MutationObserver(records=>records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}))); + const mo=new MutationObserver(records=>{records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory();}); mo.observe(document.documentElement,{childList:true,subtree:true}); - window.SunPerformance={scanImages:()=>scan(document),disconnect:()=>mo.disconnect()}; + document.addEventListener('click',e=>{if(e.target.closest('#sunDeveloperNavV22,[data-dev-tab],#sunDevRefresh'))setTimeout(enhanceDeveloperMemory,100)},true); + window.addEventListener('sun:cloud-state-applied',()=>setTimeout(enhanceDeveloperMemory,150)); + setInterval(()=>{if(document.getElementById('sun-developer-console-v22')?.classList.contains('on'))enhanceDeveloperMemory();},5000); + window.SunPerformance={scanImages:()=>scan(document),refreshDeveloperMemory:enhanceDeveloperMemory,disconnect:()=>mo.disconnect()}; }; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start(); -})(); +})(); \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index bb6587d..63260f3 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ -const CACHE='sun-catering-pwa-v65-20260907-v17-6-0-stability-security'; -const VERSION='20260907-v17-6-0-stability-security'; +const CACHE='sun-catering-pwa-v66-20260907-v17-6-1-chat-storage-guard'; +const VERSION='20260907-v17-6-1-chat-storage-guard'; const CORE=[ './','./index.html', `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, diff --git a/tests/app.spec.mjs b/tests/app.spec.mjs index debe7aa..3e9f788 100644 --- a/tests/app.spec.mjs +++ b/tests/app.spec.mjs @@ -1,4 +1,12 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { test, expect } from '@playwright/test'; +async function loadModule(page,file,globalName){ + await page.goto('/index.html', { waitUntil:'domcontentloaded' }); + const content=fs.readFileSync(path.join(process.cwd(),'public','core',file),'utf8'); + await page.addScriptTag({content}); + expect(await page.evaluate(name=>Boolean(window[name]),globalName)).toBeTruthy(); +} test('legacy localStorage payload cannot execute XSS', async ({ page }) => { const pageErrors=[]; page.on('pageerror',e=>pageErrors.push(String(e))); await page.addInitScript(() => { @@ -10,15 +18,25 @@ test('legacy localStorage payload cannot execute XSS', async ({ page }) => { expect(pageErrors.filter(x=>!x.includes('supabase'))).toEqual([]); }); test('safe insert helper handles foreign reference node', async ({ page }) => { - await page.goto('/index.html', { waitUntil:'domcontentloaded' }); + await loadModule(page,'sun-safe.js','SunSafe'); const result=await page.evaluate(()=>{const a=document.createElement('div'),b=document.createElement('div'),n=document.createElement('span'),foreign=document.createElement('i');a.appendChild(foreign);document.body.append(a,b);try{window.SunSafe.insertBefore(b,n,foreign);return {ok:true,parent:n.parentNode===b}}catch(e){return {ok:false,error:String(e)}}}); expect(result).toEqual({ok:true,parent:true}); }); test('shared PDF engine produces A4 PDF blob', async ({ page }) => { - await page.goto('/index.html', { waitUntil:'domcontentloaded' }); + await loadModule(page,'pdf-engine.js','SunPdfEngine'); const result=await page.evaluate(async()=>{const fake=new Uint8Array([255,216,255,217]);const blob=window.SunPdfEngine.fromJpegs([{width:1000,height:1414,bytes:fake}]);const head=new TextDecoder().decode(new Uint8Array(await blob.arrayBuffer()).slice(0,8));return {type:blob.type,size:blob.size,head,w:window.SunPdfEngine.PAGE_W,h:window.SunPdfEngine.PAGE_H}}); expect(result.type).toBe('application/pdf'); expect(result.size).toBeGreaterThan(150); expect(result.head.startsWith('%PDF-1.4')).toBeTruthy(); expect(result.w/result.h).toBeCloseTo(1/Math.sqrt(2),3); }); +test('chat photo guard compresses a large camera image', async ({ page }) => { + await loadModule(page,'performance.js','SunAttachmentGuard'); + const result=await page.evaluate(async()=>{ + const c=document.createElement('canvas');c.width=3000;c.height=2200;const x=c.getContext('2d'); + const g=x.createLinearGradient(0,0,c.width,c.height);g.addColorStop(0,'#172a3a');g.addColorStop(.5,'#d79a4a');g.addColorStop(1,'#f1e3ca');x.fillStyle=g;x.fillRect(0,0,c.width,c.height); + for(let i=0;i<1200;i++){x.fillStyle=`rgba(${i%255},${(i*7)%255},${(i*13)%255},.55)`;x.fillRect((i*31)%3000,(i*47)%2200,90,55)} + const blob=await new Promise(r=>c.toBlob(r,'image/jpeg',1));const input=new File([blob],'camera-photo.jpg',{type:'image/jpeg'});const out=await window.SunAttachmentGuard.prepareAttachment(input);const bm=await createImageBitmap(out.file);const data={original:input.size,size:out.file.size,width:bm.width,height:bm.height,type:out.file.type,name:out.file.name};bm.close();return data; + }); + expect(Math.max(result.width,result.height)).toBeLessThanOrEqual(2048);expect(result.size).toBeLessThan(result.original);expect(result.size).toBeLessThanOrEqual(15*1024*1024);expect(result.type).toBe('image/jpeg');expect(result.name.endsWith('.jpg')).toBeTruthy(); +}); test('mobile body does not overflow viewport', async ({ page }, testInfo) => { test.skip(testInfo.project.name!=='mobile-390'); await page.goto('/index.html', { waitUntil:'domcontentloaded' }); await page.waitForTimeout(500); const dims=await page.evaluate(()=>({innerWidth,scrollWidth:document.documentElement.scrollWidth,bodyWidth:document.body.scrollWidth})); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 30de7dc..2dd3150 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -3,14 +3,16 @@ import path from 'node:path'; const root=process.cwd(), pub=path.join(root,'public'); const read=p=>fs.readFileSync(path.join(pub,p),'utf8'); let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++}; -const index=read('index.html'),runtime=read('app-runtime.js'),sw=read('service-worker.js'),css=read('core/stability-v1760.css'); +const index=read('index.html'),runtime=read('app-runtime.js'),sw=read('service-worker.js'),css=read('core/stability-v1760.css'),performance=read('core/performance.js'); check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded'); check(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present'); check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent'); check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v17-6-0-stability-security'),'service worker cache is v17.6.0'); +check(sw.includes('v17-6-1-chat-storage-guard'),'service worker cache is v17.6.1'); +check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); +check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(fs.existsSync(path.join(root,'supabase/functions/caterium-create-employee/index.ts')),'employee Edge Function source is versioned'); -check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30,}/i.test(index+runtime),'no client secret-like token'); +check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30,}/i.test(index+runtime+performance),'no client secret-like token'); if(bad)process.exit(1); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index e187b3e..2dbafee 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -6,7 +6,7 @@ const readPub=p=>fs.readFileSync(path.join(pub,p),'utf8'); const readRoot=p=>fs.readFileSync(path.join(root,p),'utf8'); const fail=m=>{console.error('FAIL:',m);process.exitCode=1}; const ok=m=>console.log('OK:',m); -const html=readPub('index.html'),legacy=readPub('legacy/bootstrap.js'),runtime=readPub('app-runtime.js'),safe=readPub('core/sun-safe.js'),sw=readPub('service-worker.js'); +const html=readPub('index.html'),legacy=readPub('legacy/bootstrap.js'),runtime=readPub('app-runtime.js'),safe=readPub('core/sun-safe.js'),sw=readPub('service-worker.js'),performance=readPub('core/performance.js'); if(!html.includes('core/sun-safe.js'))fail('SunSafe must load before legacy modules');else ok('shared SunSafe loaded'); if(html.includes('offer-gallery-data.js')||fs.existsSync(path.join(pub,'offer-gallery-data.js')))fail('blocking offer-gallery-data.js still present');else ok('base64 gallery removed'); for(const raw of ['${b.name}','${o.event}','${o.address||','value="${x[0]}"','value="${x[2]}"']) if(legacy.includes(raw)) fail(`legacy bootstrap contains raw HTML interpolation ${raw}`); @@ -27,5 +27,7 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('v17-6-0-stability-security')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated'); +if(!sw.includes('v17-6-1-chat-storage-guard')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated'); +if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); +if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(process.exitCode)process.exit(process.exitCode);