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.
|
- 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.
|
- 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.
|
- 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.
|
/* İ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). */
|
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'];
|
const KABUK = ['/', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png'];
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(caches.open(SURUM).then((c) => c.addAll(KABUK)).then(() => self.skipWaiting()));
|
e.waitUntil(caches.open(SURUM).then((c) => c.addAll(KABUK)).then(() => self.skipWaiting()));
|
||||||
});
|
});
|
||||||
self.addEventListener('activate', (e) => {
|
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) => {
|
self.addEventListener('fetch', (e) => {
|
||||||
const url = new URL(e.request.url);
|
const url = new URL(e.request.url);
|
||||||
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
|
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
|
||||||
// API: yalnız ağ — önbellekten eski ihale gösterme
|
// 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)
|
// Hash'li build varlıkları: cache-first (değişmez adlar)
|
||||||
if (url.pathname.startsWith('/assets/')) {
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request).then((r) => {
|
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.
|
// sunucu düzeltilse bile kullanıcı eski bozuk kopyayı görmeye devam eder.
|
||||||
const tip = r.headers.get('content-type') || '';
|
const tip = r.headers.get('content-type') || '';
|
||||||
const jsMi = /\.m?js$/.test(url.pathname);
|
const jsMi = /\.m?js$/.test(url.pathname);
|
||||||
if (r.ok && (!jsMi || /javascript|ecmascript/i.test(tip))) {
|
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(); caches.open(SURUM).then((c) => c.put(e.request, kopya));
|
const kopya = r.clone(); e.waitUntil(caches.open(SURUM).then((c) => c.put(e.request, kopya)));
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
})));
|
})));
|
||||||
return;
|
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
|
// Kabuk / sayfa: network-first, ağ yoksa önbellekten index
|
||||||
e.respondWith(fetch(e.request).then((r) => {
|
e.respondWith(fetch(e.request).then((r) => {
|
||||||
const kopya = r.clone(); caches.open(SURUM).then((c) => c.put(e.request, kopya)); return r;
|
const type = r.headers.get('content-type') || '';
|
||||||
}).catch(() => caches.match(e.request).then((hit) => hit || caches.match('/'))));
|
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
|
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||||
const [onbellekAdi, setOnbellekAdi] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!('caches' in window)) return;
|
const changed = () => setOnline(navigator.onLine);
|
||||||
(async () => {
|
window.addEventListener('online', changed); window.addEventListener('offline', changed);
|
||||||
try {
|
return () => { window.removeEventListener('online', changed); window.removeEventListener('offline', changed); };
|
||||||
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 [offlineMode, setOfflineMode] = useState(true);
|
|
||||||
const [installed, setInstalled] = useState(false);
|
const [installed, setInstalled] = useState(false);
|
||||||
const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported');
|
const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported');
|
||||||
|
|
||||||
@@ -105,8 +113,8 @@ export const PwaSettings = () => {
|
|||||||
<Smartphone className="w-6 h-6" />
|
<Smartphone className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-extrabold text-xl">Mobil PWA & Saha Ekibi Offline Modu</h2>
|
<h2 className="font-extrabold text-xl">Mobil uygulama ve bağlantı durumu</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>
|
<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>
|
</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="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">
|
<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">
|
<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>
|
</h3>
|
||||||
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,11 +149,13 @@ export const PwaSettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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"
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
{/* 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