import { buildKeyText, CURRENT_HASH_VERSION, HASH_VERSIONS, MIN_UPLOAD_AGE, type ListUploadBody, type ListUploadResult, type QuotaResult } from '@akrababul/shared'; import { qs, el } from './dom.js'; import { unwrapToHtml, UnwrapError, detectFormat } from './unwrap/index.js'; import { parseSoyDocument, SoyParseError, type ParsedDocument } from './parse/soy-parser.js'; import { hashAll, pickWorkerCount, HashAbortedError } from './hash/worker-pool.js'; import { ARGON2_SALT } from './hash/params.js'; import { apiGet, apiPost, ApiError } from './api.js'; import { reportError } from './report-error.js'; import { detectDevice, pasteIntroFor, pasteHintFor, pastePlaceholderFor } from './device-guide.js'; import { mountPasteBox } from './paste-box.js'; /** Hata raporuna eklenen bağlam: biçim ve sayılar; kayıt metni veya kişisel veri içermez. */ type ErrorContext = Record; const FILE_HINT = 'Sayfayı nasıl kaydedeceğinizi görmek için yukarıdaki bağlantıyı kullanın.'; const PASTE_REJECT = 'Yapıştırılan içerik bir web sayfasından kopyalanmış görünmüyor.'; /** Yalnızca uzantı; noktasız adlarda boş döner (dosya adının tamamı kişisel veri taşıyabilir). */ const extOf = (name: string): string => { const i = name.lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase().slice(0, 20) : ''; }; export interface UploadFlowOptions { /** Yükleme başarıyla tamamlandığında çağrılır. */ onSuccess: (r: ListUploadResult) => void; /** Şüpheli işlem nedeniyle hesap askıya alındığında çağrılır (yükleme geri alınmaz). */ onSuspended?: () => void; } /** * Soy belgesi yükleme akışı: dosya seç → kayıtları gözden geçir → tarayıcıda Argon2id → gönder. * Kendi işaretlemesini kurar; hem `yukle.html` sayfasında hem Ayarlar'daki açılır pencerede kullanılır. */ export function mountUploadFlow(root: HTMLElement, opts: UploadFlowOptions): void { // 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); const pasteHint = pasteHintFor(device); root.replaceChildren( el('p', { class: 'notice' }, [ 'Seçtiğiniz dosya cihazınızdan çıkmaz. Sunucuya yalnızca seçtiğiniz kayıtların ad-soyadı ile geri açılamayan şifreli kayıt anahtarı gönderilir. ', el('a', { href: '/nasil-kaydederim.html', id: 'guideLink' }, ['Sayfayı nasıl kaydederim?']), ]), el('section', { id: 'stepFile' }, [ 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('section', { id: 'stepRows', class: 'hidden' }, [ el('p', {}, ['Belgede ', el('strong', { id: 'selfName' }), ' adına ', el('span', { id: 'rowCount' }), ' kayıt bulundu. Göndermek istemediklerinizin işaretini kaldırın.']), el('div', { class: 'scroll-x' }, [ el('table', { class: 'rows' }, [ el('thead', {}, [el('tr', {}, [el('th', {}), 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', { class: 'muted' }, ['Sunucuya gönderilecek: ', el('span', { id: 'summary' })]), el('p', {}, [el('button', { type: 'button', id: 'send' }, ['Gönder']), ' ', el('button', { type: 'button', class: 'secondary', id: 'reset' }, ['Başka belge seç'])]), ]), el('section', { id: 'stepHash', class: 'hidden' }, [ el('p', { id: 'progressText' }, ['Verileriniz şifreleniyor, 0/0. Lütfen bu sayfadan ayrılmayın.']), el('progress', { id: 'progress', value: '0', max: '1' }), el('p', {}, [el('button', { type: 'button', class: 'secondary', id: 'cancel' }, ['İptal'])]), ]), el('div', { id: 'err', class: 'error hidden' }), el('p', { id: 'retryWrap', class: 'hidden' }, [el('button', { type: 'button', id: 'retry' }, ['Tekrar dene'])]), ); const q = (sel: string) => qs(sel, root); q('#guideLink').href = '/nasil-kaydederim.html#' + device; const S = { file: q('#stepFile'), rows: q('#stepRows'), hash: q('#stepHash') }; const err = q('#err'), retryWrap = q('#retryWrap'); let doc: ParsedDocument | null = null; let pending: ListUploadBody | null = null; // ağ hatasında yeniden gönderim için let abort: AbortController | null = null; let hashing = false; // yalnızca şifreleme sürerken sayfadan ayrılma uyarısı function showStep(k: keyof typeof S) { for (const [n, e] of Object.entries(S)) e.classList.toggle('hidden', n !== k); } function showError(msg: string | (string | Node)[], retry = false) { if (typeof msg === 'string') err.textContent = msg; else err.replaceChildren(...msg); err.classList.remove('hidden'); retryWrap.classList.toggle('hidden', !retry); // Telefonda kutu dosya ve yapıştırma alanlarının altında, ekranın dışında kalıyor. err.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); } function clearError() { err.classList.add('hidden'); retryWrap.classList.add('hidden'); } const workerCount = () => pickWorkerCount({ hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory }); if (typeof WebAssembly !== 'object') showError('Tarayıcınız bu işlemi desteklemiyor. Lütfen güncel bir tarayıcı kullanın.'); async function onFile(file: File) { clearError(); const ctx: ErrorContext = { src: 'file', ext: extOf(file.name), size: file.size }; let html: string; try { const bytes = new Uint8Array(await file.arrayBuffer()); ctx['fmt'] = detectFormat(bytes, file.name); html = unwrapToHtml(bytes, file.name); } catch (e) { reportError('unwrap', e, { ...ctx, ...(e instanceof UnwrapError ? { code: e.code } : {}) }); const msg = e instanceof UnwrapError ? e.message : 'Dosya okunamadı.'; if (ctx['fmt'] === 'image') return showError([msg + ' ', el('a', { href: '/nasil-kaydederim.html#' + device }, ['Sayfayı nasıl kaydederim?'])]); return showError(msg + ' ' + FILE_HINT); } showParsed(html, ctx, FILE_HINT); } /** Panodan gelen seçim: dosya yoluyla aynı ayrıştırıcı, aynı yaş ve satır kontrolü. */ function onPaste(html: string) { clearError(); showParsed(html, { src: 'paste', size: html.length }, pasteHint); } function showParsed(html: string, ctx: ErrorContext, hint: string) { try { doc = parseSoyDocument(html); } catch (e) { // Teşhis: biçim, sütun adları ve sayılar. Kayıt metni veya kişisel veri gönderilmez. reportError('parse', e, { ...ctx, ...(e instanceof SoyParseError ? e.details : {}) }); return showError((e instanceof SoyParseError ? e.message : 'Belge okunamadı.') + ' ' + hint); } // Yaş sınırı belgeden doğrulanır: tam doğum tarihi yalnızca burada, tarayıcıda bilinir. if (doc.selfAge < MIN_UPLOAD_AGE) { doc = null; return showError(`Belgenizdeki doğum tarihine göre ${MIN_UPLOAD_AGE} yaşından küçüksünüz. Kullanım şartları gereği belge yükleyemezsiniz.`); } renderRows(doc); showStep('rows'); } function renderRows(d: ParsedDocument) { q('#selfName').textContent = `${d.self.firstName} ${d.self.lastName}`; q('#rowCount').textContent = String(d.rows.length); const tbody = q('#rows'); tbody.replaceChildren(); d.rows.forEach((r, i) => { const cb = el('input', { type: 'checkbox', 'data-i': String(i) }); cb.checked = true; cb.addEventListener('change', updateSummary); tbody.append(el('tr', {}, [el('td', {}, [cb]), el('td', {}, [r.relation]), el('td', {}, [`${r.firstName} ${r.lastName ?? ''}`.trim()]), el('td', {}, [r.placeParts.join(' ')]), el('td', {}, [r.registryNo])])); }); updateSummary(); } const selectedIdx = () => Array.from(root.querySelectorAll('#rows input:checked')).map((c) => Number(c.dataset['i'])); function updateSummary() { const n = selectedIdx().length; q('#summary').textContent = `${n} kayıt için ad, soyad ve şifrelenmiş kayıt anahtarı`; q('#send').disabled = n === 0; } async function send() { if (!doc) return; clearError(); const idx = selectedIdx(); if (idx.length === 0) return showError('En az bir kayıt seçin.'); let quota: QuotaResult; try { quota = await apiGet('/api/list/quota'); } catch (e) { return handleApiError(e, false); } if (quota.remaining === 0) return showError(`Günlük yükleme hakkınız doldu. Yenilenme: ${quota.resetsAt ? new Date(quota.resetsAt).toLocaleString('tr-TR') : 'yarın'}.`); const rows = idx.map((i) => doc!.rows[i]!); const keyTexts = rows.map((r) => buildKeyText(r.placeParts, r.registryNo)); const params = HASH_VERSIONS[CURRENT_HASH_VERSION]!; showStep('hash'); const progress = q('#progress'), ptext = q('#progressText'); progress.max = keyTexts.length; progress.value = 0; ptext.textContent = `Verileriniz şifreleniyor, 0/${keyTexts.length}. Lütfen bu sayfadan ayrılmayın.`; const t0 = performance.now(); let hashes: string[]; hashing = true; const run = (workers: number) => { abort = new AbortController(); return hashAll(keyTexts, { params, salt: ARGON2_SALT, workerCount: workers, signal: abort.signal, onProgress: (d, t) => { progress.value = d; ptext.textContent = `Verileriniz şifreleniyor, ${d}/${t}. Lütfen bu sayfadan ayrılmayın.`; } }); }; try { try { hashes = await run(workerCount()); } catch (e) { if (e instanceof HashAbortedError) throw e; reportError('hash', e, { attempt: 1, count: keyTexts.length }); showError('Şifreleme başarısız oldu. Daha az bellekle tekrar deneniyor.'); hashes = await run(1); clearError(); } } catch (e) { hashing = false; showStep('rows'); if (e instanceof HashAbortedError) return showError("Şifreleme tamamlanmadı. Hiçbir veri gönderilmedi. Yeniden başlatmak için Gönder'e basın."); reportError('hash', e, { attempt: 2, count: keyTexts.length }); return showError('Cihazınızda şifreleme yapılamadı. Başka bir tarayıcı veya cihazla deneyin.'); } hashing = false; ptext.textContent = 'Şifreleme tamamlandı, gönderiliyor…'; // Kalibrasyon verisi: kayıt sayısı, süre, worker sayısı (kişisel veri yok) reportError('other', new Error('hash-timing'), { count: keyTexts.length, ms: Math.round(performance.now() - t0), workers: workerCount() }); pending = { hashVersion: CURRENT_HASH_VERSION, self: doc.self, records: rows.map((r, i) => ({ hash: hashes[i]!, firstName: r.firstName, lastName: r.lastName })) }; await upload(); } async function upload() { if (!pending) return; clearError(); try { const r = await apiPost('/api/list', pending); pending = null; if (r.suspended) { showStep('file'); S.file.classList.add('hidden'); showError('Şüpheli işlem tespit edildi. Hesabınız incelenerek size bilgi verilecektir. İnceleme tamamlanana kadar uygulamayı kullanamayacaksınız.'); return opts.onSuspended?.(); } opts.onSuccess(r); } catch (e) { showStep('rows'); handleApiError(e, true); } } function handleApiError(e: unknown, canRetry: boolean) { if (!(e instanceof ApiError)) { reportError('upload', e); return showError('Beklenmeyen bir hata oluştu.', canRetry); } reportError('upload', e, { code: e.code, status: e.status }); switch (e.code) { case 'UNAUTHORIZED': return showError('Oturumunuzun süresi dolmuş. Lütfen yeniden giriş yapın: /giris.html', canRetry); case 'QUOTA_EXCEEDED': return showError(`Günlük yükleme hakkınız doldu. Yenilenme: ${e.details?.['resetsAt'] ? new Date(String(e.details['resetsAt'])).toLocaleString('tr-TR') : 'yarın'}.`); case 'SUSPENDED': return showError(e.message); case 'HASH_VERSION_UNSUPPORTED': return showError('Sayfa sürümü eski. Lütfen sayfayı yenileyip tekrar deneyin.'); default: return showError(e.message, canRetry); } } q('#file').addEventListener('change', (e) => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) void onFile(f); }); mountPasteBox(q('#paste'), { onHtml: onPaste, onReject: () => { clearError(); showError(PASTE_REJECT + ' ' + pasteHint); }, placeholder: pastePlaceholderFor(device), }); const drop = q('#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.dataTransfer?.files[0]; if (f) void onFile(f); }); q('#send').addEventListener('click', () => void send()); q('#reset').addEventListener('click', () => { doc = null; pending = null; clearError(); showStep('file'); }); q('#cancel').addEventListener('click', () => abort?.abort()); q('#retry').addEventListener('click', () => void upload()); window.addEventListener('beforeunload', (e) => { if (hashing) e.preventDefault(); }); }