diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index e086753..3db06a5 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -199,3 +199,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Yükleme ve üst veri güncellemesinde gerçek takvim günü doğrulanıyor; 31 Şubat, artık yıl olmayan 29 Şubat, tarih sonuna eklenen metin ve yanlış türler reddediliyor. - Dosya aktarımının base64 kodlaması ve 10 MB dosya sınırı doğrulanıyor. Bozuk kodlamanın boş veya kısmen çözülmüş dosya olarak kaydedilmesi önlendi. Bu kontrol PDF/DOCX iç yapısının sağlam olduğunu kanıtlamaz; ayrıştırma/OCR hata senaryoları açık. - Kanıt: genişletilen HTTP testleriyle 55/55 test geçti; bozuk yükleme ve tarih isteği 400, diğer hesabın tarih silmesi 404, geçerli silme ve kategori koruma doğrulandı. 10 MB tam sınırı ve bir bayt fazlası ayrıca gerçek Buffer ile geçti. Gerçek dosya indirme ve bayt eşitliği regresyonu geçti. Tarayıcıda ardışık tarih güncelleme yarışı ayrıca incelenmeli. + +### D01 — Dosya kaydı başarısızlığında temizlik + +- Yeni yükleme tek servis üzerinden benzersiz ad ve exclusive write ile yazılıyor; metadata INSERT başarısızsa yalnız bu yüklemenin baytları siliniyor. Temizlik de başarısızsa ayrı hata veriliyor. Önceden mevcut dosyalara dokunulmuyor; yeni dosyanın izinleri 0600. +- 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. diff --git a/server/index.js b/server/index.js index 9ce315c..e0c504d 100644 --- a/server/index.js +++ b/server/index.js @@ -969,17 +969,13 @@ const server = http.createServer(async (req, res) => { try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { /* metin çıkmazsa devam */ } if (!ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni); } - const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`; - fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf); - const id = store.addDocument(user.email, { + const id = store.saveDocumentFile(user.email, { ad: String(body.ad).slice(0, 200), kategori: metadata.kategori || null, - boyut: buf.length, // GERÇEK bayt — rastgele değil mime: body.mime || null, - dosya: dosyaAdi, sonKullanma: metadata.sonKullanma || null, ikn, // ihaleye bağlı belge - }); + }, buf); // Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan // tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci // metni /api/kalem/cikar'a gönderir. diff --git a/server/user-store.mjs b/server/user-store.mjs index d2a0e1e..ea1bc07 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -249,6 +249,21 @@ export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullan return id; } +/** A rejected metadata insert must not leave private uploaded bytes on disk. */ +export function saveDocumentFile(email, metadata, bytes) { + fs.mkdirSync(BELGE_DIR, { recursive:true }); + const dosya = crypto.randomUUID(); + const destination = path.join(BELGE_DIR, dosya); + fs.writeFileSync(destination, bytes, { flag:'wx', mode:0o600 }); + try { + return addDocument(email, { ...metadata, dosya, boyut:bytes.length }); + } catch (error) { + try { fs.unlinkSync(destination); } + catch (cleanupError) { throw new AggregateError([error,cleanupError], 'Belge kaydedilemedi ve geçici dosya temizlenemedi.'); } + throw error; + } +} + export function listDocuments(email, { ikn } = {}) { const alanlar = 'id, ad, kategori, boyut, mime, son_kullanma, ikn, olusturma, dosya'; return withDb((db) => (ikn @@ -280,7 +295,8 @@ export function listUsers() { export function deleteDocument(email, id) { const rec = getDocument(email, id); if (!rec) return false; - try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } catch { /* dosya zaten yok */ } + try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } + catch (error) { if (error.code !== 'ENOENT') throw error; } return withDb((db) => db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id, email).changes > 0, false); } diff --git a/tests/document-file-failures.test.mjs b/tests/document-file-failures.test.mjs new file mode 100644 index 0000000..af0f18f --- /dev/null +++ b/tests/document-file-failures.test.mjs @@ -0,0 +1,35 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +const dir = fs.mkdtempSync(join(tmpdir(),'ihaleist-doc-failure-')); +process.env.IHALEIST_USER_DATA_DIR = dir; +const store = await import('../server/user-store.mjs'); +after(()=>fs.rmSync(dir,{recursive:true,force:true})); + +test('failed document insert removes only the new upload and preserves previous files',()=>{ + const bytes=Buffer.from('Özel test belgesi'); + const id=store.saveDocumentFile('a',{ad:'test.txt',mime:'text/plain'},bytes); + const record=store.getDocument('a',id); + const before=fs.readdirSync(store.BELGE_DIR); + const db=store.openDb(); + db.exec("CREATE TRIGGER reject_document BEFORE INSERT ON belgeler BEGIN SELECT RAISE(FAIL,'test insert rejected'); END;"); + db.close(); + assert.throws(()=>store.saveDocumentFile('a',{ad:'failed.txt'},bytes),/test insert rejected/); + assert.deepEqual(fs.readdirSync(store.BELGE_DIR),before); + assert.deepEqual(fs.readFileSync(join(store.BELGE_DIR,record.dosya)),bytes); + assert.equal(store.listDocuments('a').length,1); +}); + +test('filesystem deletion error preserves document metadata instead of reporting success',()=>{ + const destination=join(store.BELGE_DIR,'not-a-file'); + fs.mkdirSync(destination); + const db=store.openDb(); db.exec('DROP TRIGGER reject_document'); db.close(); + const id=store.addDocument('a',{ad:'test',boyut:1,dosya:'not-a-file'}); + assert.throws(()=>store.deleteDocument('a',id)); + assert.ok(store.getDocument('a',id)); + assert.equal(store.deleteDocument('b',id),false); + fs.rmdirSync(destination); + assert.equal(store.deleteDocument('a',id),true); +});