fix: bound document downloads and cancel stale sessions

This commit is contained in:
2026-09-06 13:56:05 +03:00
parent 34da54a080
commit ae32382ffa
4 changed files with 106 additions and 21 deletions
+7
View File
@@ -957,3 +957,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Eski modele Uygun durumu ve teknik kaynak açıklaması girilmesi portföy değişikliği engelini kaldırmadı. Güncel ürün bilgilerini al ve kontrolleri sıfırla düğmesi M2'yi aldı; önceki teknik açıklamayı temizledi ve uygunluk durumunu Kontrol gerekli yaptı.
- Taslak UI üzerinden kaydedilip sayfa yenilendi; M2 seçimi ve beş kontrolün bekleyen durumu sunucudan geri geldi. Yedi kontrolün tamamı geçti. Kanıt: /Users/hikmettanriverdi/Documents/ChatGPT/ihale/rapor/qa-portfolio-revision.json.
- Bu kabul aynı oturumda profil değişikliği, düğme ve kalıcılığı kapsar. İki gerçek oturumun yarışması, tam hazır dosyanın çıktı denemesi veya gerçek başvuru kabulü değildir. Üretim kodu değişmediğinden önceki 217 test ve derleme tekrarlanmadı. Test sekmesi kapatıldı; mevcut QA sunucusu korunuyor.
### Belge indirmede süre ve oturum kontrolü — 6 Eylül 2026
- Kimliği doğrulanmış belge indirmede fetch, hata yanıtı ve dosya gövdesi için ortak 120 saniye sınırı eklendi. İptal sinyali desteklenir; oturum yoksa ağ isteği başlamaz, hesap değişimi veya iptal sonrası dosya teslim edilmez. Zaman aşımı anlaşılır hata verir.
- İndirme bağlantısı ve geçici Blob URL, tarayıcıya teslim düğmesi hata verse de temizlenir. Türkçe dosya adı ve özgün dosya baytları korunur.
- Belge deposu her dosyanın indirme işlemini kilitler, düğmede İndiriliyor durumu gösterir; erişilemeyen dosyanın düğmesi devre dışıdır. Depodan çıkılınca bekleyen indirmeler iptal edilir. Aynı e-posta için token yenilenmesi de panel oturumunu yeniden kurar.
- 220 otomatik test ve üretim derlemesi geçti. Yeni testler takılmış başarılı/hatalı yanıt gövdesi, iptal, oturumsuz istek, özgün Türkçe içerik ve dosya adı, tıklama hatasında kaynak temizliğini kapsar. Son küçük hata-mesajı sağlamlaştırmasından sonra belge oturum testleri ayrıca geçti.
- Gerçek tarayıcı Downloads klasöründe dosya kabulü ve yavaş ağda İndiriliyor görünümü henüz doğrulanmadı. Tarayıcıya teslim, dosyanın diske yazıldığının garantisi olarak sunulmaz. Canlıya yayın yapılmadı.
+14 -4
View File
@@ -16,7 +16,7 @@ const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Res
*/
export const DocumentVault = () => {
const { authUser } = useApp();
return <DocumentVaultSession key={authUser?.email || 'signed-out'} signedIn={Boolean(authUser)} />;
return <DocumentVaultSession key={`${authUser?.email || 'signed-out'}:${getToken()}`} signedIn={Boolean(authUser)} />;
};
const DocumentVaultSession = ({signedIn}) => {
@@ -36,6 +36,15 @@ const DocumentVaultSession = ({signedIn}) => {
const [islemeBilgi, setIslemeBilgi] = useOwnedState(null);
const [kategori, setKategori] = useOwnedState(KATEGORILER[0]);
const dosyaRef = useRef(null);
const downloads = useRef(new Set());
const [downloading,setDownloading] = useOwnedState([]);
const indir = async file => {
if (!current() || downloads.current.has(file.id)) return;
downloads.current.add(file.id);setDownloading([...downloads.current]);setHata(null);
try { await downloadDocument(file.id,file.ad,{signal:readLifetime.current.signal}); }
catch(error) { if(!readLifetime.current.signal.aborted)setHata(error.message); }
finally { downloads.current.delete(file.id);setDownloading([...downloads.current]); }
};
const yukle = () => {
if (!signedIn || !current()) return;
@@ -320,9 +329,10 @@ const DocumentVaultSession = ({signedIn}) => {
Kalemleri yeniden çıkar
</button>
)}
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
className="text-slate-400 hover:text-teal-600 p-1" title="İndir">
<Download className="w-4 h-4" />
<button onClick={() => indir(d)} disabled={downloading.includes(d.id) || d.erisilebilir===false}
aria-label={`${d.ad} indir`} aria-busy={downloading.includes(d.id)}
className="text-slate-400 hover:text-teal-600 p-1 disabled:opacity-40" title="İndir">
{downloading.includes(d.id) ? <span role="status" className="text-xs">İndiriliyor…</span> : <Download className="w-4 h-4" />}
</button>
<button onClick={() => sil(d.id)} disabled={busy} className="text-slate-400 hover:text-red-500 p-1" title="Sil">
<Trash2 className="w-4 h-4" />
+40 -17
View File
@@ -1,3 +1,4 @@
import {awaitWithAbort} from './abortable.js';
import { apiKeyRequest } from './apiKeys.js';
import { sessionGuard } from './sessionGuard.js';
/**
@@ -46,25 +47,47 @@ export async function uploadDocument(file, kategori, ikn, ek = {}) {
}
/** Auth başlığı gerektiği için <a href> yerine blob ile indirilir. */
export async function downloadDocument(id, ad) {
export async function downloadDocument(id, ad, {signal,timeoutMs=120000}={}) {
const current = sessionGuard(getToken);
const res = await fetch(`/api/me/documents/download?id=${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error || `İndirilemedi (HTTP ${res.status})`);
if (!current()) throw new Error('Belgeyi indirmek için hesabınıza giriş yapın.');
const controller = new AbortController();
const cancel = () => controller.abort(signal.reason);
if (signal?.aborted) cancel(); else signal?.addEventListener('abort',cancel,{once:true});
let timedOut=false;
const timer=setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
const assertCurrent=()=>{if(!current())throw new Error('Oturum değişti; belgeyi yeniden açın.');};
try {
controller.signal.throwIfAborted();
const res = await awaitWithAbort(fetch(`/api/me/documents/download?id=${encodeURIComponent(id)}`, {
signal:controller.signal,headers:{Authorization:`Bearer ${getToken()}`},
}),controller.signal);
assertCurrent();
if (!res.ok) {
const data=await awaitWithAbort(res.json().catch(()=>({})),controller.signal);
assertCurrent();
throw new Error(data?.error || `İndirilemedi (HTTP ${res.status})`);
}
const blob=await awaitWithAbort(res.blob(),controller.signal);
assertCurrent();
controller.signal.throwIfAborted();
const url=URL.createObjectURL(blob);
let anchor;
try {
anchor=document.createElement('a');
anchor.href=url;anchor.download=ad || 'belge';
document.body.appendChild(anchor);
anchor.click();
} finally {
anchor?.remove();
setTimeout(()=>URL.revokeObjectURL(url),5000);
}
} catch(error) {
assertCurrent();
if(timedOut)throw new Error('Belge zamanında indirilemedi. Bağlantınızı kontrol edip yeniden deneyin.');
throw error;
} finally {
clearTimeout(timer);signal?.removeEventListener('abort',cancel);
}
const blob = await res.blob();
if (!current()) throw new Error('Oturum değişti; belgeyi yeniden açın.');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ad || 'belge';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
export function formatBayt(b) {
+45
View File
@@ -22,3 +22,48 @@ test('account switch during file reading prevents upload and download delivery',
if(storage) Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;
}
});
test('download timeouts bound both successful and failed response bodies',async t=>{
const storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage'),originalFetch=globalThis.fetch;
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=> 'qa-token'}});
t.mock.timers.enable({apis:['setTimeout']});
try {
for(const ok of [true,false]){
let started;const reading=new Promise(resolve=>{started=resolve;});let fetchSignal;
const body=()=>{started();return new Promise(()=>{});};
globalThis.fetch=async(_url,options)=>{fetchSignal=options.signal;return {ok,status:503,blob:body,json:body};};
const downloading=downloadDocument('qa','qa.txt',{timeoutMs:50});await reading;t.mock.timers.tick(50);
await assert.rejects(downloading,/zamanında indirilemedi/);assert.equal(fetchSignal.aborted,true);
}
}finally{globalThis.fetch=originalFetch;t.mock.timers.reset();if(storage)Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;}
});
test('cancelled or unauthenticated downloads cannot start or deliver a file',async()=>{
const storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage'),originalFetch=globalThis.fetch;
let token=null,calls=0;
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=>token}});
try{
globalThis.fetch=async()=>{calls++;return {ok:true,blob:async()=>new Blob(['QA'])};};
await assert.rejects(downloadDocument('qa','qa.txt'),/giriş yapın/);assert.equal(calls,0);
token='qa-token';const controller=new AbortController();controller.abort();
await assert.rejects(downloadDocument('qa','qa.txt',{signal:controller.signal}),{name:'AbortError'});assert.equal(calls,0);
const later=new AbortController();
globalThis.fetch=async()=>({ok:true,blob:async()=>{later.abort();return new Blob(['QA']);}});
await assert.rejects(downloadDocument('qa','qa.txt',{signal:later.signal}),{name:'AbortError'});
}finally{globalThis.fetch=originalFetch;if(storage)Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;}
});
test('download delivers original bytes and releases URL and link if clicking fails',async t=>{
const storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage'),originalFetch=globalThis.fetch,originalDocument=globalThis.document;
const create=URL.createObjectURL,revoke=URL.revokeObjectURL;let removed=0,revoked=0,clicks=0,receivedBlob,link,fail=false;
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=> 'qa-token'}});
t.mock.timers.enable({apis:['setTimeout']});
try{
globalThis.fetch=async()=>({ok:true,blob:async()=>new Blob(['Özgün test belgesi'])});
URL.createObjectURL=blob=>{receivedBlob=blob;return 'blob:qa';};URL.revokeObjectURL=url=>{assert.equal(url,'blob:qa');revoked++;};
globalThis.document={body:{appendChild:()=>{}},createElement:()=>link={click:()=>{clicks++;if(fail)throw new Error('click failed');},remove:()=>{removed++;}}};
await downloadDocument('qa','Türkçe belge.txt');
assert.equal(link.download,'Türkçe belge.txt');assert.equal(await receivedBlob.text(),'Özgün test belgesi');
t.mock.timers.tick(5000);assert.equal(clicks,1);assert.equal(removed,1);assert.equal(revoked,1);
fail=true;await assert.rejects(downloadDocument('qa','qa.txt'),/click failed/);
t.mock.timers.tick(5000);assert.equal(removed,2);assert.equal(revoked,2);
}finally{globalThis.fetch=originalFetch;globalThis.document=originalDocument;URL.createObjectURL=create;URL.revokeObjectURL=revoke;t.mock.timers.reset();if(storage)Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;}
});