fix: restrict PWA cache and report actual offline capabilities
This commit is contained in:
@@ -206,3 +206,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont
|
||||
- Fiziksel silme sırasında yalnız ENOENT (zaten yok) tolere ediliyor. Erişim/tür/disk hataları artık bastırılmıyor; belge metadata kaydı silinmeden hata dönüyor.
|
||||
- Kanıt: 57/57 test geçti. SQLite trigger ile gerçek INSERT hatası üretildi; dosya listesi değişmedi ve önceden kayıtlı Türkçe belge baytları korundu. Dizin üzerinde unlink hatasıyla metadata koruması ve diğer hesabın silme reddi doğrulandı. Mevcut HTTP yükleme/indirme testi geçti.
|
||||
- Kalan sınır: süreç aniden kapanırken dosya/DB arasında tam atomiklik ve fiziksel silme sonrasındaki DB silme hatasının kurtarılması henüz çözülmüş değildir. Bunlar dosya yaşam döngüsü kapsamında açık tutuluyor; tam belge dayanıklılığı kabulü verilmedi.
|
||||
|
||||
### D09 — PWA önbelleği ve gerçek durum açıklaması
|
||||
|
||||
- PWA ekranındaki çalışmayan aç/kapat benzeri düğme kaldırıldı; düğme gerçekten Cache API üzerinden İhaleist önbellek durumunu yeniden okuyor. Destek/hata/bekleme durumları gösteriliyor. Koşulsuz “Senkronize” yerine bağlantı durumu var; bağlantının sunucuya erişim garantisi olmadığı belirtiliyor.
|
||||
- Ürün metni mevcut davranışla hizalandı: uygulama kabuğu açılabilir, özel belge/hazırlık kaydı için bağlantı gerekir; çevrimdışı yazma kuyruğu varmış gibi sunulmuyor.
|
||||
- Service worker v3 yalnız bilinen kabuk ve varlık yollarını önbellekliyor. API, authorization başlıklı istekler ve rastgele GET yolları ağda kalıyor. Başarısız/yanlış MIME yanıtları sağlam kabuğu veya JS/CSS’yi bozamıyor; HTML yedeği varlık isteğine dönmüyor. Aktivasyon sadece eski İhaleist cache adlarını temizliyor.
|
||||
- Kanıt: gerçek sw.js kodunu VM içinde fetch/activate olaylarıyla çalıştıran iki regresyon eklendi; 59/59 test ve üretim derlemesi geçti. API/özel yol, authorization, sunucu 500, offline kabuk, offline asset, yanlış MIME ve başka uygulama cache’ini koruma kontrol edildi.
|
||||
- Sınır: VM testleri gerçek service worker kurulumu/güncellemesi veya cihazda offline kullanım kabulü değildir. Bunlar ve hesap değişiminin gerçek tarayıcıdaki tüm PWA senaryoları hâlâ açık.
|
||||
|
||||
+15
-7
@@ -1,19 +1,19 @@
|
||||
/* İhaleist service worker — uygulama kabuğu çevrimdışı açılır, API her zaman ağdan.
|
||||
Sürüm değişince eski önbellek temizlenir (build her deploy'da yeni asset adı üretir). */
|
||||
const SURUM = 'ihaleist-v2'; // v2: yanlış MIME ile saklanmış pdf worker kopyaları temizlensin
|
||||
const SURUM = 'ihaleist-v3'; // v3: yalnız uygulama kabuğu ve başarılı varlık yanıtları
|
||||
const KABUK = ['/', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(SURUM).then((c) => c.addAll(KABUK)).then(() => self.skipWaiting()));
|
||||
});
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SURUM).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k.startsWith('ihaleist-') && k !== SURUM).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
|
||||
// API: yalnız ağ — önbellekten eski ihale gösterme
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
if (url.pathname.startsWith('/api/') || e.request.headers.has('authorization')) return;
|
||||
// Hash'li build varlıkları: cache-first (değişmez adlar)
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request).then((r) => {
|
||||
@@ -22,15 +22,23 @@ self.addEventListener('fetch', (e) => {
|
||||
// sunucu düzeltilse bile kullanıcı eski bozuk kopyayı görmeye devam eder.
|
||||
const tip = r.headers.get('content-type') || '';
|
||||
const jsMi = /\.m?js$/.test(url.pathname);
|
||||
if (r.ok && (!jsMi || /javascript|ecmascript/i.test(tip))) {
|
||||
const kopya = r.clone(); caches.open(SURUM).then((c) => c.put(e.request, kopya));
|
||||
if (r.ok && !r.redirected && !/text\/html/i.test(tip) && (!jsMi || /javascript|ecmascript/i.test(tip)) && (!/\.css$/.test(url.pathname) || /text\/css/i.test(tip))) {
|
||||
const kopya = r.clone(); e.waitUntil(caches.open(SURUM).then((c) => c.put(e.request, kopya)));
|
||||
}
|
||||
return r;
|
||||
})));
|
||||
return;
|
||||
}
|
||||
// Only known shell files and root navigation are cacheable; never cache arbitrary GETs.
|
||||
const navigation = e.request.mode === 'navigate' && url.pathname === '/';
|
||||
if (!navigation && !KABUK.includes(url.pathname)) return;
|
||||
if (e.request.headers.has('authorization')) return;
|
||||
// Kabuk / sayfa: network-first, ağ yoksa önbellekten index
|
||||
e.respondWith(fetch(e.request).then((r) => {
|
||||
const kopya = r.clone(); caches.open(SURUM).then((c) => c.put(e.request, kopya)); return r;
|
||||
}).catch(() => caches.match(e.request).then((hit) => hit || caches.match('/'))));
|
||||
const type = r.headers.get('content-type') || '';
|
||||
if (r.ok && !r.redirected && (!navigation || /text\/html/i.test(type))) {
|
||||
const kopya = r.clone(); e.waitUntil(caches.open(SURUM).then(c => c.put(navigation ? '/' : e.request, kopya)));
|
||||
}
|
||||
return r;
|
||||
}).catch(() => caches.match(e.request).then((hit) => hit || (navigation ? caches.match('/') : Response.error()))));
|
||||
});
|
||||
|
||||
@@ -10,18 +10,26 @@ export const PwaSettings = () => {
|
||||
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||
const [onbellekAdi, setOnbellekAdi] = useState('');
|
||||
const [cacheLoading, setCacheLoading] = useState(false);
|
||||
const [cacheError, setCacheError] = useState(null);
|
||||
const [online, setOnline] = useState(navigator.onLine);
|
||||
const refreshCacheStatus = async () => {
|
||||
setCacheLoading(true); setCacheError(null);
|
||||
try {
|
||||
if (!('caches' in window)) throw new Error('Bu tarayıcı uygulama önbelleğini desteklemiyor.');
|
||||
const adlar = (await caches.keys()).filter(name => name.startsWith('ihaleist-'));
|
||||
let toplam = 0;
|
||||
for (const ad of adlar) toplam += (await (await caches.open(ad)).keys()).length;
|
||||
setOnbellekSayisi(toplam); setOnbellekAdi(adlar.join(', '));
|
||||
} catch (error) { setCacheError(error.message); setOnbellekSayisi(null); }
|
||||
finally { setCacheLoading(false); }
|
||||
};
|
||||
useEffect(() => { refreshCacheStatus(); }, []);
|
||||
useEffect(() => {
|
||||
if (!('caches' in window)) return;
|
||||
(async () => {
|
||||
try {
|
||||
const adlar = await caches.keys();
|
||||
let toplam = 0;
|
||||
for (const ad of adlar) toplam += (await (await caches.open(ad)).keys()).length;
|
||||
setOnbellekSayisi(toplam); setOnbellekAdi(adlar[adlar.length - 1] || '');
|
||||
} catch { setOnbellekSayisi(null); }
|
||||
})();
|
||||
const changed = () => setOnline(navigator.onLine);
|
||||
window.addEventListener('online', changed); window.addEventListener('offline', changed);
|
||||
return () => { window.removeEventListener('online', changed); window.removeEventListener('offline', changed); };
|
||||
}, []);
|
||||
const [offlineMode, setOfflineMode] = useState(true);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported');
|
||||
|
||||
@@ -105,8 +113,8 @@ export const PwaSettings = () => {
|
||||
<Smartphone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">Mobil PWA & Saha Ekibi Offline Modu</h2>
|
||||
<p className="text-xs text-cyan-200 mt-0.5">Saha satış temsilcileriniz için internet bağlantısı olmadığında dahi çevrimdışı çalışma ve anlık push bildirimi.</p>
|
||||
<h2 className="font-extrabold text-xl">Mobil uygulama ve bağlantı durumu</h2>
|
||||
<p className="text-xs text-cyan-200 mt-0.5">Uygulama kabuğu önbellekten açılabilir. İhale güncelleme, belge erişimi ve hazırlık kaydı için internet bağlantısı gerekir.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,10 +126,10 @@ export const PwaSettings = () => {
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
{offlineMode ? <Wifi className="w-4 h-4 text-emerald-600" /> : <WifiOff className="w-4 h-4 text-slate-400" />} Çevrimdışı (Offline) Önbellek
|
||||
{online ? <Wifi className="w-4 h-4 text-emerald-600" /> : <WifiOff className="w-4 h-4 text-slate-400" />} Çevrimdışı (Offline) Önbellek
|
||||
</h3>
|
||||
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
||||
Senkronize
|
||||
{online ? 'Bağlantı var' : 'Çevrimdışı'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -141,11 +149,13 @@ export const PwaSettings = () => {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setOfflineMode(!offlineMode)}
|
||||
onClick={refreshCacheStatus} disabled={cacheLoading}
|
||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 font-bold text-xs py-2.5 rounded-xl border border-slate-200 transition-colors"
|
||||
>
|
||||
{offlineMode ? 'Çevrimdışı Önbelleği Yenile' : 'Çevrimdışı Modu Aktif Et'}
|
||||
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
||||
</button>
|
||||
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
|
||||
<p className="text-xs text-slate-600">Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.</p>
|
||||
</div>
|
||||
|
||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import fs from 'node:fs';
|
||||
const script=fs.readFileSync(new URL('../public/sw.js',import.meta.url),'utf8');
|
||||
function harness() {
|
||||
const handlers={},writes=[],deleted=[];
|
||||
let network=()=>Promise.resolve(new Response('app',{headers:{'content-type':'text/html'}}));
|
||||
const cached=new Map([['/',new Response('offline app')]]);
|
||||
const key=r=>typeof r==='string'?r:new URL(r.url).pathname;
|
||||
const caches={keys:async()=>['ihaleist-v2','ihaleist-v3','other-app'],
|
||||
delete:async name=>{deleted.push(name);return true;},
|
||||
match:async request=>cached.get(key(request))?.clone(),
|
||||
open:async()=>({put:async(request,response)=>{writes.push(key(request));cached.set(key(request),response);},addAll:async()=>{}})};
|
||||
vm.runInNewContext(script,{URL,Response,caches,fetch:request=>network(request),self:{location:{origin:'https://ihaleist.test'},
|
||||
addEventListener:(name,handler)=>{handlers[name]=handler;},clients:{claim:async()=>{}},skipWaiting:async()=>{}}});
|
||||
return {writes,deleted,handlers,setNetwork:fn=>{network=fn;},async fetch(path,{mode='cors',authorization=false,method='GET'}={}) {
|
||||
let response;const pending=[];
|
||||
handlers.fetch({request:{url:'https://ihaleist.test'+path,method,mode,headers:new Headers(authorization?{authorization:'Bearer test'}:{})},
|
||||
respondWith:p=>{response=p;},waitUntil:p=>pending.push(p)});
|
||||
const out=await response;await Promise.all(pending);return out;
|
||||
}};
|
||||
}
|
||||
test('worker ignores API, arbitrary GET and authorized resources; removes only own old caches',async()=>{
|
||||
const h=harness();
|
||||
for(const path of ['/api/me/documents','/mcp','/private-report'])assert.equal(await h.fetch(path),undefined);
|
||||
assert.equal(await h.fetch('/assets/app.js',{authorization:true}),undefined);
|
||||
assert.equal(await h.fetch('/',{method:'POST'}),undefined);
|
||||
assert.deepEqual(h.writes,[]);
|
||||
let activation;h.handlers.activate({waitUntil:p=>{activation=p;}});await activation;
|
||||
assert.deepEqual(h.deleted,['ihaleist-v2']);
|
||||
});
|
||||
test('failed navigation never overwrites offline shell, assets never receive HTML fallback',async()=>{
|
||||
const h=harness();
|
||||
h.setNetwork(async()=>new Response('server error',{status:500}));
|
||||
assert.equal((await h.fetch('/',{mode:'navigate'})).status,500);
|
||||
assert.deepEqual(h.writes,[]);
|
||||
h.setNetwork(async()=>{throw new Error('offline');});
|
||||
assert.equal(await (await h.fetch('/',{mode:'navigate'})).text(),'offline app');
|
||||
await assert.rejects(()=>h.fetch('/assets/app.js'),/offline/);
|
||||
h.setNetwork(async()=>new Response('<html>wrong asset</html>',{headers:{'content-type':'text/html'}}));
|
||||
await h.fetch('/assets/app.js');await h.fetch('/assets/app.css');assert.deepEqual(h.writes,[]);
|
||||
h.setNetwork(async()=>new Response('export default 1',{headers:{'content-type':'text/javascript'}}));
|
||||
await h.fetch('/assets/app.js');assert.deepEqual(h.writes,['/assets/app.js']);
|
||||
});
|
||||
Reference in New Issue
Block a user