diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 912dc9b..516fc11 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -313,3 +313,9 @@ Doğrulama: üretim derlemesi geçti; HTTP testi, başarısız çıkarım ve yen Doküman Deposu'nda İKN bağlantılı, okunabilir ihale belgelerine yeniden kalem çıkarımı eklendi. İhale detayı ve depo artık aynı storedDocument servisiyle mevcut dosyayı okuyor, okuma hatasını kaydediyor ve sahiplik kontrollü kalem aktarımı yapıyor. Özel firma belgeleri bu işleme dahil değil. İşlem sonucu ve liste yenileme hatası ayrı gösteriliyor. Belge okuma sırasında oturum değişirse eski belge metninin yeni oturumla aktarımı veya hata bildirimi durduruluyor. 71 test geçti; üretim derlemesi geçti. Yeni servis testi mevcut dosya ID/İKN/metin aktarımını, okuma hatasının önceki durumla raporlanmasını ve hesap değişikliğinde ikinci isteğin gönderilmemesini doğruluyor. Gerçek tarayıcı PDF kabulü ve tüm depo işlemlerinin hesap değişiminde ekran durumlarını temizlemesi henüz açık. + +### Depo oturum ayrımı ve dosya aktarımı + +Doküman Deposu hesap kimliğine göre yeniden oluşturuluyor; eski bileşenden veya değişmiş token'dan gelen sonuçlar durum güncellemesine uygulanmıyor. Toplu yüklemenin sonraki dosyası ve kalem aktarımı öncesinde oturum kontrolü var. Ortak yükleme servisi FileReader tamamlandığında token değişmişse istek göndermiyor; ortak indirme servisi blob tamamlandığında token değişmişse tarayıcı indirmesi başlatmıyor. Bu kontroller sunucunun sahiplik kontrollerine ek olarak istemcide eski oturum işinin devamını sınırlıyor. + +72 test ve üretim derlemesi geçti. Yeni test, FileReader sırasında hesap değiştiğinde sıfır yükleme isteği gönderildiğini ve blob okuma sırasında hesap değiştiğinde indirmenin reddedildiğini doğruluyor. Gerçek tarayıcıda çok dosyalı yükleme/hesap değişimi kabulü, aynı hesapta eşzamanlı metadata yanıt sıralaması ve diğer modüllerin oturum denetimi halen açık. diff --git a/src/components/tools/DocumentVault.jsx b/src/components/tools/DocumentVault.jsx index c9616ab..bde7e61 100644 --- a/src/components/tools/DocumentVault.jsx +++ b/src/components/tools/DocumentVault.jsx @@ -1,6 +1,8 @@ +import { getToken } from '../../services/auth'; +import { sessionGuard } from '../../services/sessionGuard'; import { readableDocument, retryDocumentExtraction } from '../../services/storedDocument'; import { documentProcessingLabel } from '../../services/documentProcessing'; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../../context/AppContext'; import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt, updateDocumentMeta } from '../../services/account'; import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle, CalendarClock } from 'lucide-react'; @@ -14,28 +16,42 @@ const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Res */ export const DocumentVault = () => { const { authUser } = useApp(); - const [durum, setDurum] = useState(null); - const [busy, setBusy] = useState(false); - const [hata, setHata] = useState(null); - const [islemeBilgi, setIslemeBilgi] = useState(null); - const [kategori, setKategori] = useState(KATEGORILER[0]); + return ; +}; + +const DocumentVaultSession = ({signedIn}) => { + const live = useRef(true); + const session = useRef(sessionGuard(getToken)); + const current = () => live.current && session.current(); + useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); + const useOwnedState = (initial) => { + const [value, setValue] = useState(initial); + const setOwned = useCallback((next) => { if (current()) setValue(next); }, []); + return [value, setOwned]; + }; + const [durum, setDurum] = useOwnedState(null); + const [busy, setBusy] = useOwnedState(false); + const [hata, setHata] = useOwnedState(null); + const [islemeBilgi, setIslemeBilgi] = useOwnedState(null); + const [kategori, setKategori] = useOwnedState(KATEGORILER[0]); const dosyaRef = useRef(null); const yukle = () => { - if (!authUser) return; + if (!signedIn || !current()) return; setHata(null); listDocuments().then(setDurum).catch((e) => setHata(e.message)); }; - useEffect(yukle, [authUser]); + useEffect(yukle, [signedIn]); const yenidenIsle = async (document) => { + if (!current()) return; setBusy(true); setHata(null); setIslemeBilgi(null); try { const result = await retryDocumentExtraction(document); setIslemeBilgi(`${document.ad}: ${result.sayi || 0} kalem çıkarıldı.`); } catch (error) { setHata(error.message); } finally { - try { setDurum(await listDocuments()); } + try { if (current()) setDurum(await listDocuments()); } catch { setHata(previous => [previous, 'Belge listesi yenilenemedi.'].filter(Boolean).join(' ')); } setBusy(false); } @@ -59,7 +75,7 @@ export const DocumentVault = () => { // sorulmaz; kalemler otomatik havuza yazılır. docx/xlsx/zip sunucuda, // PDF tarayıcıda (pdfjs) okunur. Onlarca şartnameyi tek seferde atmak için. const topluRef = useRef(null); - const [toplu, setToplu] = useState([]); // [{ad, durum, ikn, kalem, hata}] + const [toplu, setToplu] = useOwnedState([]); // [{ad, durum, ikn, kalem, hata}] const topluSecildi = async (e) => { const dosyalar = [...(e.target.files || [])]; if (!dosyalar.length) return; @@ -67,6 +83,7 @@ export const DocumentVault = () => { setToplu(dosyalar.map((f) => ({ ad: f.name, durum: 'bekliyor' }))); const guncelle = (i, veri) => setToplu((o) => o.map((x, j) => (j === i ? { ...x, ...veri } : x))); for (let i = 0; i < dosyalar.length; i++) { + if (!current()) break; const f = dosyalar[i]; guncelle(i, { durum: 'yukleniyor' }); try { @@ -80,19 +97,22 @@ export const DocumentVault = () => { metin = (await extractText(f)).text; ikn = iknCikar(metin); } + if (!current()) break; const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, { otoIkn: true, documentType:'ihale' }); + if (!current()) break; ikn = cevap?.ikn || ikn; let kalem = cevap?.kalem?.sayi || null; let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null; if (ikn && !kalem && metin) { try { const { kalemCikar } = await import('../../services/kalem'); + if (!current()) break; kalem = (await kalemCikar({ ikn, metin, dosya: f.name, documentId:cevap.id })).sayi || null; } catch (error) { kalemHata = error.message; } } guncelle(i, { durum: 'tamam', ikn, kalem, kalemHata }); setDurum(cevap); - try { setDurum(await listDocuments()); } + try { if (current()) setDurum(await listDocuments()); } catch { setHata('Dosya kayıtlı; güncel belge listesi alınamadı. Listeyi yeniden yükleyin.'); } } catch (err) { guncelle(i, { durum: 'hata', hata: err.message }); @@ -105,6 +125,7 @@ export const DocumentVault = () => { // Şirket belgesi süre takibi: imza sirküleri, oda kayıt, borcu yoktur — // hepsinin süresi dolar, kimse teklif hazırlanana kadar fark etmez. const sureYaz = async (id, tarih) => { + if (!current()) return; setHata(null); try { setDurum(await updateDocumentMeta(id, { sonKullanma: tarih || null })); } catch (e) { setHata(e.message); } @@ -114,6 +135,7 @@ export const DocumentVault = () => { const yakinda = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) >= 0 && kalanGun(b) <= 30); const sil = async (id) => { + if (!current()) return; setBusy(true); setHata(null); try { setDurum(await deleteDocument(id)); } catch (e) { setHata(e.message); } diff --git a/src/services/account.js b/src/services/account.js index 7006e9b..77f8012 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -1,3 +1,4 @@ +import { sessionGuard } from './sessionGuard.js'; /** * Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler. * (Önceden bunların hepsi localStorage'daydı veya sahteydi.) @@ -45,12 +46,14 @@ export const listDocuments = () => iste('/api/me/documents'); export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); export async function uploadDocument(file, kategori, ikn, ek = {}) { + const current = sessionGuard(getToken); const icerikBase64 = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result).split(',')[1]); r.onerror = reject; r.readAsDataURL(file); }); + if (!current()) throw new Error('Oturum değişti; dosyayı yeniden seçin.'); return iste('/api/me/documents', { method: 'POST', body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64, ...ek }), @@ -59,6 +62,7 @@ export async function uploadDocument(file, kategori, ikn, ek = {}) { /** Auth başlığı gerektiği için yerine blob ile indirilir. */ export async function downloadDocument(id, ad) { + const current = sessionGuard(getToken); const res = await fetch(`/api/me/documents/download?id=${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${getToken()}` }, }); @@ -67,6 +71,7 @@ export async function downloadDocument(id, ad) { throw new Error(d.error || `İndirilemedi (HTTP ${res.status})`); } 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; diff --git a/src/services/sessionGuard.js b/src/services/sessionGuard.js new file mode 100644 index 0000000..cd450c6 --- /dev/null +++ b/src/services/sessionGuard.js @@ -0,0 +1,5 @@ +/** Snapshot of an authenticated operation; never continue it under another token. */ +export function sessionGuard(readToken) { + const token = readToken(); + return () => Boolean(token) && readToken() === token; +} diff --git a/tests/document-session.test.mjs b/tests/document-session.test.mjs new file mode 100644 index 0000000..54b1dda --- /dev/null +++ b/tests/document-session.test.mjs @@ -0,0 +1,24 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {uploadDocument,downloadDocument} from '../src/services/account.js'; + +test('account switch during file reading prevents upload and download delivery',async()=>{ + const storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage'); + const originalFetch=globalThis.fetch, originalReader=globalThis.FileReader; + let token='first', calls=0; + Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=>token}}); + try { + globalThis.FileReader=class { + readAsDataURL(){token='second';this.result='data:text/plain;base64,VGVzdA==';this.onload();} + }; + globalThis.fetch=async()=>{calls++;return Response.json({success:true});}; + await assert.rejects(uploadDocument({name:'test.txt',type:'text/plain'},'Test'),/Oturum değişti/); + assert.equal(calls,0); + token='first'; + globalThis.fetch=async()=>({ok:true,blob:async()=>{token='second';return new Blob(['private document']);}}); + await assert.rejects(downloadDocument('id','test.txt'),/Oturum değişti/); + } finally { + globalThis.fetch=originalFetch;globalThis.FileReader=originalReader; + if(storage) Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage; + } +});