import { buildKeyText, CURRENT_HASH_VERSION, HASH_VERSIONS, MIN_UPLOAD_AGE, type ListUploadBody } from '@akrababul/shared'; import { qs, el } from '../lib/dom.js'; import { unwrapToHtml, UnwrapError, detectFormat } from '../lib/unwrap/index.js'; import { parseSoyDocument, SoyParseError, type ParsedDocument, type ParsedRow } from '../lib/parse/soy-parser.js'; import { hashAll, pickWorkerCount, HashAbortedError } from '../lib/hash/worker-pool.js'; import { ARGON2_SALT } from '../lib/hash/params.js'; import { mountPasteBox } from '../lib/paste-box.js'; import { detectDevice, pasteIntroFor, pasteHintFor, pastePlaceholderFor } from '../lib/device-guide.js'; /** * Hesapsız deneme sayfası. Gerçek yükleme akışının (`upload-flow.ts`) kullandığı * modüllerin aynısını kullanır — çözme, ayrıştırma, Argon2id — ama gönderme adımı * yoktur ve bilerek hiçbir ağ çağrısı içermez. * * DİKKAT: Bu dosyaya `api.ts` veya `report-error.ts` ithal etmeyin. Sayfanın tek * iddiası "hiçbir şey göndermiyor"; tek bir istek bile o iddiayı çürütür. * Tek istisna, kullanıcının açıkça istediği sahte örnek belgenin indirilmesidir. * `test/deneyin-sayfasi.test.ts` bunu denetler. */ const flow = qs('#flow'); const result = qs('#stepSonuc'); // Yapıştırma yönergesi cihaza göre değişir: telefonda Ctrl+A yoktur, seçim menüden yapılır. const device = detectDevice(navigator.userAgent); flow.replaceChildren( el('section', { id: 'stepInput' }, [ el('div', { class: 'dropzone', id: 'drop' }, [ el('p', {}, ['Dosyayı buraya bırakın veya']), el('input', { type: 'file', id: 'file', accept: '.html,.htm,.mhtml,.mht,.webarchive' }), el('p', { class: 'muted' }, ['Kabul edilen biçimler: .html, .mhtml, .webarchive. Ekran görüntüsü okunamaz.']), ]), el('p', { class: 'or' }, ['ya da']), el('div', { class: 'paste-wrap' }, [ el('p', {}, [el('strong', {}, ['Kopyalayıp yapıştırın.']), ' ' + pasteIntroFor(device)]), el('div', { id: 'paste' }), ]), el('p', { class: 'or' }, ['ya da']), el('p', {}, [ el('button', { type: 'button', class: 'secondary', id: 'sample' }, ['Sahte örnek belgeyle dene']), ' ', el('span', { class: 'muted' }, ['Tamamen uydurma verilerle hazırlanmıştır. Bunu seçerseniz belge sunucudan indirilir; bu, aşağıdaki ağ listesinde görünür.']), ]), ]), el('section', { id: 'stepRows', class: 'hidden' }, [ el('p', {}, ['Belgede ', el('strong', { id: 'selfName' }), ' adına ', el('span', { id: 'rowCount' }), ' kayıt okundu. Hepsi yalnızca bu sayfada, tarayıcınızın belleğinde.']), el('div', { class: 'scroll-x' }, [ el('table', { class: 'rows' }, [ el('thead', {}, [el('tr', {}, [el('th', {}, ['Yakınlık']), el('th', {}, ['Ad Soyad']), el('th', {}, ['Kayıtlı yer']), el('th', {}, ['Cilt-Hane-Birey'])])]), el('tbody', { id: 'rows' }), ]), ]), el('p', { id: 'ageNote', class: 'muted hidden' }), el('p', {}, [ el('button', { type: 'button', id: 'hash' }, ['Tarayıcımda şifrele']), ' ', el('button', { type: 'button', class: 'secondary', id: 'reset' }, ['Başka belge dene']), ]), ]), el('section', { id: 'stepHash', class: 'hidden' }, [ el('p', { id: 'progressText' }), el('progress', { id: 'progress', value: '0', max: '1' }), ]), el('div', { id: 'err', class: 'error hidden' }), ); const S = { input: qs('#stepInput'), rows: qs('#stepRows'), hash: qs('#stepHash') }; const err = qs('#err'); let doc: ParsedDocument | null = null; function showStep(k: keyof typeof S): void { for (const [n, e] of Object.entries(S)) e.classList.toggle('hidden', n !== k); } function showError(msg: string | (string | Node)[]): void { if (typeof msg === 'string') err.textContent = msg; else err.replaceChildren(...msg); err.classList.remove('hidden'); // Telefonda kutu giriş alanlarının altında, ekranın dışında kalıyor. err.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); } function clearError(): void { err.classList.add('hidden'); } if (typeof WebAssembly !== 'object') showError('Tarayıcınız bu işlemi desteklemiyor. Lütfen güncel bir tarayıcı kullanın.'); // --- Ağ izleyici: sayfanın veri isteği yapıp yapmadığını tarayıcının kendi ölçümünden gösterir. const netList = qs('#agListe'), netCount = qs('#agSayi'), netSince = qs('#agBelgeSonrasi'); const requests: { path: string; startTime: number }[] = []; /** Belgenin okunduğu an. Asıl iddia toplam istek sayısı değil, bu andan sonra * hiçbir isteğin başlamaması: açılıştaki analitik beacon'ı ve örnek belgenin * indirilmesi bundan önce başlar, ikisi de belgenin içeriğiyle ilgili değildir. * * Ölçüm sayaç değil zaman damgası üzerinden yapılır: PerformanceObserver * girdileri geç teslim edebiliyor, bu yüzden "o anki sayı" güvenilir bir * başlangıç noktası vermiyor. İsteğin kendi startTime'ı ise güvenilir. */ let docTime: number | null = null; function renderNet(): void { netCount.textContent = `${requests.length} istek`; netList.replaceChildren(...requests.map((r) => el('li', {}, [r.path]))); netSince.textContent = docTime === null ? 'Henüz belge vermediniz.' : `Belgeniz okunduktan sonra yapılan istek: ${requests.filter((r) => r.startTime > docTime!).length}`; } try { new PerformanceObserver((list) => { for (const e of list.getEntries() as PerformanceResourceTiming[]) { // Sayfanın kendi betik/stil yüklemeleri değil, yalnızca veri istekleri. if (e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest') { requests.push({ path: new URL(e.name, location.href).pathname, startTime: e.startTime }); } } renderNet(); }).observe({ type: 'resource', buffered: true }); } catch { netCount.textContent = 'Tarayıcınız bu ölçümü desteklemiyor; F12 → Ağ sekmesini kullanın.'; } // --- Belgeyi alma yolları: dosya, sürükle-bırak, yapıştırma, örnek belge async function onFile(file: File): Promise { clearError(); let bytes: Uint8Array | null = null; try { bytes = new Uint8Array(await file.arrayBuffer()); showParsed(unwrapToHtml(bytes, file.name)); } catch (e) { const msg = e instanceof UnwrapError ? e.message : 'Dosya okunamadı.'; if (bytes !== null && detectFormat(bytes, file.name) === 'image') showError([msg + ' ', el('a', { href: '/nasil-kaydederim.html#' + device }, ['Sayfayı nasıl kaydederim?'])]); else showError(msg); } } function showParsed(html: string): void { try { doc = parseSoyDocument(html); } catch (e) { doc = null; return showError(e instanceof SoyParseError ? e.message : 'Belge okunamadı.'); } qs('#selfName').textContent = `${doc.self.firstName} ${doc.self.lastName}`; qs('#rowCount').textContent = String(doc.rows.length); qs('#rows').replaceChildren(...doc.rows.map((r) => el('tr', {}, [ el('td', {}, [r.relation]), el('td', {}, [`${r.firstName} ${r.lastName ?? ''}`.trim()]), el('td', {}, [r.placeParts.join(' ')]), el('td', {}, [r.registryNo]), ]))); // Gerçek yüklemede yaş sınırı uygulanır; burada engellemek yerine bilgi verilir. const note = qs('#ageNote'), tooYoung = doc.selfAge < MIN_UPLOAD_AGE; note.classList.toggle('hidden', !tooYoung); if (tooYoung) note.textContent = `Not: Belgedeki doğum tarihine göre ${MIN_UPLOAD_AGE} yaşından küçüksünüz; gerçek yüklemede bu belge reddedilirdi.`; result.classList.add('hidden'); // Bundan sonrası belgeyle ilgili her şeydir; orada hiçbir istek olmamalıdır. docTime = performance.now(); renderNet(); showStep('rows'); } // --- Şifreleme: gerçek akışın kullandığı worker havuzunun aynısı async function runHash(): Promise { if (!doc) return; clearError(); const rows = doc.rows; const keyTexts = rows.map((r) => buildKeyText(r.placeParts, r.registryNo)); const params = HASH_VERSIONS[CURRENT_HASH_VERSION]!; const workerCount = pickWorkerCount({ hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory }); const bar = qs('#progress'), text = qs('#progressText'); bar.max = keyTexts.length; bar.value = 0; text.textContent = `Şifreleniyor, 0/${keyTexts.length}. Bu işlem tamamen sizin cihazınızda yapılıyor.`; showStep('hash'); let hashes: string[]; try { hashes = await hashAll(keyTexts, { params, salt: ARGON2_SALT, workerCount, onProgress: (d, t) => { bar.value = d; text.textContent = `Şifreleniyor, ${d}/${t}. Bu işlem tamamen sizin cihazınızda yapılıyor.`; }, }); } catch (e) { showStep('rows'); return showError(e instanceof HashAbortedError ? 'Şifreleme iptal edildi.' : 'Cihazınızda şifreleme yapılamadı. Başka bir tarayıcı veya cihazla deneyin.'); } // Gövde, gerçek yüklemedekiyle birebir aynı biçimde kurulur; tipi de aynı, yani // istek gövdesi değişirse bu sayfa derlenmez ve sessizce eskiyemez. const body: ListUploadBody = { hashVersion: CURRENT_HASH_VERSION, self: doc.self, records: rows.map((r, i) => ({ hash: hashes[i]!, firstName: r.firstName, lastName: r.lastName })), }; qs('#gidecekJson').textContent = JSON.stringify(body, null, 2); renderFieldTable(rows[0]); result.classList.remove('hidden'); showStep('rows'); result.scrollIntoView({ behavior: 'smooth', block: 'start' }); } /** İlk kaydın belgeden okunan tüm alanları ve her birinin akıbeti. */ function renderFieldTable(r: ParsedRow | undefined): void { const tbody = qs('#alanRows'); if (!r) return tbody.replaceChildren(); const SENT = 'Evet, açık metin olarak'; const HASHED = 'Yalnızca şifreli hâli (geri açılamaz)'; const NOT_SENT = 'Hayır'; const fields: [string, string, string][] = [ ['Adı', r.firstName, SENT], ['Soyadı', r.lastName ?? '—', SENT], ['İl-İlçe-Mahalle/Köy', r.placeParts.join(' '), HASHED], ['Cilt-Hane-Birey Sıra No', r.registryNo, HASHED], ['Baba Adı', r.fatherName || '—', NOT_SENT], ['Ana Adı', r.motherName || '—', NOT_SENT], ['Doğum Yeri', r.birthPlace || '—', NOT_SENT], ['Doğum Tarihi', r.birthDate || '—', NOT_SENT], ['Yakınlık Derecesi', r.relation || '—', NOT_SENT], ['Cinsiyet', r.gender || '—', NOT_SENT], ]; tbody.replaceChildren(...fields.map(([name, value, fate]) => el('tr', {}, [ el('td', {}, [name]), el('td', { class: 'wrap' }, [value]), el('td', {}, [fate === NOT_SENT ? el('strong', {}, [fate]) : el('span', {}, [fate])]), ]))); } // --- Bağlantılar qs('#file').addEventListener('change', (e) => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) void onFile(f); }); mountPasteBox(qs('#paste'), { onHtml: (html) => { clearError(); showParsed(html); }, onReject: () => showError('Yapıştırılan içerik bir web sayfasından kopyalanmış görünmüyor. ' + pasteHintFor(device)), placeholder: pastePlaceholderFor(device), }); const drop = qs('#drop'); drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', () => drop.classList.remove('over')); drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); const f = (e as DragEvent).dataTransfer?.files[0]; if (f) void onFile(f); }); qs('#sample').addEventListener('click', () => void (async () => { clearError(); try { // Tek istisna: sahte örnek belgenin indirilmesi. Bir indirmedir, gönderim değil; // ağ listesinde görünür ve sayfa bunu peşinen söyler. const res = await fetch('/ornek/ornek-soy-belgesi.html', { cache: 'no-store' }); if (!res.ok) throw new Error('indirilemedi'); showParsed(unwrapToHtml(new Uint8Array(await res.arrayBuffer()), 'ornek-soy-belgesi.html')); } catch { showError('Örnek belge indirilemedi. Çevrimdışıysanız bu düğme çalışmaz — kendi belgenizle deneyebilirsiniz.'); } })()); qs('#reset').addEventListener('click', () => { doc = null; clearError(); result.classList.add('hidden'); showStep('input'); }); qs('#hash').addEventListener('click', () => void runHash()); qs('#jsonKopyala').addEventListener('click', async () => { const status = qs('#jsonDurum'); try { await navigator.clipboard.writeText(qs('#gidecekJson').textContent ?? ''); status.textContent = 'Kopyalandı.'; } catch { getSelection()?.selectAllChildren(qs('#gidecekJson')); status.textContent = 'Kopyalanamadı; metin seçildi, Ctrl+C ile kopyalayabilirsiniz.'; } });