diff --git a/README.md b/README.md index d9712f3..899e224 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,15 @@ Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann. - [x] Offline-fähig via Service Worker - [x] CoinGecko-Fallback mit Auto-Retry -### v2 — View-Key Zahlungsbestätigung (Browser-basiert) +### v2 — View-Key Zahlungsbestätigung (Browser-basiert) ✅ -- [ ] View-Only-Key eingeben (privater Spend-Key bleibt lokal) -- [ ] Browser pollt Remote Node via Monero RPC (kein eigener Node nötig) -- [ ] Live-Anzeige: "Warte auf Zahlung..." → "✅ Zahlung eingegangen (X Bestätigungen)" -- [ ] Warnhinweis bei Unterzahlung -- [ ] Subaddress-Unterstützung (für mehrere parallele Rechnungen) +- [x] Private View-Key eingeben (validiert gegen Adresse, verlässt nie den Browser) +- [x] Browser pollt Remote Node via PHP-Proxy mit Failover (4 Nodes) +- [x] Live-Anzeige: "Warte auf Zahlung..." → "Zahlung eingegangen (X/10 Bestätigungen)" +- [x] Fortschrittsbalken für Bestätigungen +- [x] Unterzahlungs-Erkennung +- [x] Standard- und Subaddress-Unterstützung +- [x] Leichtgewichtige Krypto (30KB noble-curves Bundle, kein 5MB WASM) ### v3 — Professionelle Features diff --git a/api/node.php b/api/node.php new file mode 100644 index 0000000..8ffa6c5 --- /dev/null +++ b/api/node.php @@ -0,0 +1,133 @@ + 'Method not allowed']); + exit; +} + +// Nodes in priority order +$NODES = [ + 'http://node.xmr.rocks:18089', + 'http://node.community.rino.io:18081', + 'http://node.sethforprivacy.com:18089', + 'http://xmr-node.cakewallet.com:18081', +]; + +// Allowed RPC methods (whitelist) +$ALLOWED_JSON_RPC = ['get_info', 'get_block', 'get_block_header_by_height']; +$ALLOWED_HTTP = ['get_transaction_pool', 'gettransactions', 'get_transaction_pool_hashes.bin']; + +// Rate limiting (simple file-based, 60 requests/minute per IP) +$RATE_DIR = __DIR__ . '/../data/rate/'; +if (!is_dir($RATE_DIR)) @mkdir($RATE_DIR, 0755, true); + +$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +$rateFile = $RATE_DIR . md5($ip) . '.json'; +$now = time(); +$rateData = []; + +if (file_exists($rateFile)) { + $rateData = json_decode(file_get_contents($rateFile), true) ?: []; + // Clean old entries + $rateData = array_filter($rateData, fn($t) => $t > $now - 60); +} + +if (count($rateData) >= 1000) { + http_response_code(429); + echo json_encode(['error' => 'Rate limit exceeded']); + exit; +} + +$rateData[] = $now; +file_put_contents($rateFile, json_encode($rateData)); + +// Parse request +$input = json_decode(file_get_contents('php://input'), true); +if (!$input || !isset($input['method'])) { + http_response_code(400); + echo json_encode(['error' => 'Missing method']); + exit; +} + +$method = $input['method']; +$params = $input['params'] ?? []; + +// Determine endpoint type +$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC); +$isHttp = in_array($method, $ALLOWED_HTTP); + +if (!$isJsonRpc && !$isHttp) { + http_response_code(403); + echo json_encode(['error' => 'Method not allowed: ' . $method]); + exit; +} + +// Cache last working node +$cacheFile = __DIR__ . '/../data/node_cache.json'; +$cachedNode = null; +if (file_exists($cacheFile)) { + $cache = json_decode(file_get_contents($cacheFile), true); + if ($cache && ($cache['time'] ?? 0) > $now - 300) { + $cachedNode = $cache['node']; + } +} + +// Order nodes: cached first +$orderedNodes = $NODES; +if ($cachedNode && in_array($cachedNode, $NODES)) { + $orderedNodes = array_merge([$cachedNode], array_filter($NODES, fn($n) => $n !== $cachedNode)); +} + +// Try nodes +$lastError = ''; +foreach ($orderedNodes as $node) { + if ($isJsonRpc) { + $url = $node . '/json_rpc'; + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => '0', + 'method' => $method, + 'params' => (object)$params + ]); + } else { + $url = $node . '/' . $method; + $body = json_encode((object)$params); + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response !== false && $httpCode >= 200 && $httpCode < 300) { + // Cache this working node + file_put_contents($cacheFile, json_encode(['node' => $node, 'time' => $now])); + echo $response; + exit; + } + + $lastError = $curlError ?: "HTTP $httpCode"; +} + +// All nodes failed +http_response_code(502); +echo json_encode(['error' => 'All nodes unreachable', 'detail' => $lastError]); diff --git a/app.js b/app.js index 095918f..1bff9b8 100644 --- a/app.js +++ b/app.js @@ -21,7 +21,9 @@ const amountInput = $('#amount'); const currencySelect = $('#currency'); const descInput = $('#desc'); - const timerInput = $('#timer'); + const timerCustom = $('#timerCustom'); + const deadlineBadges = $('#deadlineBadges'); + let selectedDays = 0; const generateBtn = $('#generate'); const resultSection = $('#result'); const qrContainer = $('#qr'); @@ -36,6 +38,21 @@ const newRequestBtn = $('#newRequest'); const homeLink = $('#homeLink'); + // Monitor DOM + const monitorSection = $('#monitorSection'); + const monitorToggle = $('#monitorToggle'); + const monitorPanel = $('#monitorPanel'); + const viewKeyInput = $('#viewKey'); + const startMonitorBtn = $('#startMonitor'); + const stopMonitorBtn = $('#stopMonitor'); + const monitorStatus = $('#monitorStatus'); + const statusIndicator = $('#statusIndicator'); + const statusText = $('#statusText'); + const confirmationsBar = $('#confirmationsBar'); + const confirmationsFill = $('#confirmationsFill'); + const confirmationsText = $('#confirmationsText'); + let cryptoLoaded = false; + // --- Init --- fetchRates(); loadFromHash() || loadSaved(); @@ -52,6 +69,33 @@ newRequestBtn.addEventListener('click', resetForm); homeLink.addEventListener('click', function (e) { e.preventDefault(); resetForm(); }); + // Deadline badge events + deadlineBadges.querySelectorAll('.badge').forEach(function (btn) { + btn.addEventListener('click', function () { + const days = parseInt(btn.getAttribute('data-days')); + if (btn.classList.contains('active')) { + btn.classList.remove('active'); + selectedDays = 0; + timerCustom.value = ''; + } else { + deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + selectedDays = days; + timerCustom.value = ''; + } + }); + }); + timerCustom.addEventListener('input', function () { + deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); + selectedDays = parseInt(timerCustom.value) || 0; + }); + + // Monitor events + monitorToggle.addEventListener('click', toggleMonitor); + viewKeyInput.addEventListener('input', validateViewKey); + startMonitorBtn.addEventListener('click', startMonitoring); + stopMonitorBtn.addEventListener('click', stopMonitoring); + // --- Functions --- function resetForm() { @@ -59,7 +103,9 @@ amountInput.value = ''; currencySelect.value = 'EUR'; descInput.value = ''; - timerInput.value = ''; + selectedDays = 0; + timerCustom.value = ''; + deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); fiatHint.textContent = ''; fiatHint.classList.remove('error'); addrInput.classList.remove('valid', 'invalid'); @@ -69,6 +115,12 @@ qrContainer.innerHTML = ''; uriBox.textContent = ''; shareLinkInput.value = ''; + // Reset monitor + stopMonitoring(); + monitorPanel.classList.remove('open'); + viewKeyInput.value = ''; + viewKeyInput.classList.remove('valid', 'invalid'); + startMonitorBtn.disabled = true; history.replaceState(null, '', location.pathname); window.scrollTo({ top: 0, behavior: 'smooth' }); addrInput.focus(); @@ -184,7 +236,7 @@ const xmrAmount = getXmrAmount(); const desc = descInput.value.trim(); - const timer = parseInt(timerInput.value) || 0; + const timer = selectedDays; const uri = buildUri(addr, xmrAmount, desc); // Show result @@ -205,8 +257,8 @@ text: uri, width: 256, height: 256, - colorDark: '#ffffff', - colorLight: '#1a1a1a', + colorDark: '#000000', + colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M }); const hint = document.createElement('div'); @@ -245,7 +297,16 @@ if (desc) descInput.value = desc; const timer = params.get('t'); - if (timer && parseInt(timer) > 0) timerInput.value = timer; + if (timer && parseInt(timer) > 0) { + selectedDays = parseInt(timer); + // Activate matching badge or set custom + const badge = deadlineBadges.querySelector('.badge[data-days="' + selectedDays + '"]'); + if (badge) { + badge.classList.add('active'); + } else { + timerCustom.value = selectedDays; + } + } // Auto-generate setTimeout(generate, 100); @@ -257,10 +318,9 @@ countdownEl.textContent = ''; countdownEl.className = 'countdown'; - const minutes = parseInt(timerInput.value); - if (!minutes || minutes <= 0) return; + if (!selectedDays || selectedDays <= 0) return; - const end = Date.now() + minutes * 60000; + const end = Date.now() + selectedDays * 86400000; countdownEl.classList.add('active'); function tick() { @@ -271,9 +331,17 @@ countdownEl.className = 'countdown expired'; return; } - const m = Math.floor(remaining / 60000); + const d = Math.floor(remaining / 86400000); + const h = Math.floor((remaining % 86400000) / 3600000); + const m = Math.floor((remaining % 3600000) / 60000); const s = Math.floor((remaining % 60000) / 1000); - countdownEl.textContent = I18n.t('countdown_remaining') + pad(m) + ':' + pad(s); + if (d > 0) { + countdownEl.textContent = I18n.t('countdown_remaining_days') + .replace('{d}', d).replace('{h}', pad(h)).replace('{m}', pad(m)).replace('{s}', pad(s)); + } else { + countdownEl.textContent = I18n.t('countdown_remaining_hours') + .replace('{h}', pad(h)).replace('{m}', pad(m)).replace('{s}', pad(s)); + } } tick(); @@ -344,4 +412,135 @@ navigator.serviceWorker.register('sw.js').catch(function () {}); } } + + // --- Monitor Functions (v2) --- + + function toggleMonitor() { + const panel = monitorPanel; + const isOpen = panel.classList.contains('open'); + if (isOpen) { + panel.classList.remove('open'); + return; + } + // Lazy-load crypto bundle + if (!cryptoLoaded && !window.XmrCrypto) { + loadCryptoBundle().then(function () { + cryptoLoaded = true; + panel.classList.add('open'); + viewKeyInput.focus(); + }); + return; + } + panel.classList.add('open'); + viewKeyInput.focus(); + } + + function loadCryptoBundle() { + return new Promise(function (resolve, reject) { + if (window.XmrCrypto) { resolve(); return; } + statusText.textContent = I18n.t('monitor_loading'); + monitorStatus.classList.add('active'); + const script = document.createElement('script'); + script.src = 'lib/xmr-crypto.bundle.js'; + script.onload = function () { + monitorStatus.classList.remove('active'); + resolve(); + }; + script.onerror = function () { + monitorStatus.classList.remove('active'); + reject(new Error('Failed to load crypto module')); + }; + document.head.appendChild(script); + }); + } + + function validateViewKey() { + const key = viewKeyInput.value.trim(); + viewKeyInput.classList.remove('valid', 'invalid'); + if (key.length === 0) { + startMonitorBtn.disabled = true; + return; + } + if (PaymentMonitor.isValidViewKey(key)) { + viewKeyInput.classList.add('valid'); + startMonitorBtn.disabled = false; + } else if (key.length >= 10) { + viewKeyInput.classList.add('invalid'); + startMonitorBtn.disabled = true; + } + } + + function startMonitoring() { + const viewKey = viewKeyInput.value.trim(); + if (!PaymentMonitor.isValidViewKey(viewKey)) return; + + const addr = addrInput.value.trim(); + const xmrAmount = getXmrAmount() || 0; + + // Hide input, show status + startMonitorBtn.style.display = 'none'; + viewKeyInput.closest('.field').style.display = 'none'; + monitorStatus.classList.add('active'); + stopMonitorBtn.classList.add('active'); + + PaymentMonitor.start(addr, viewKey, xmrAmount, function (newState, data) { + updateMonitorUI(newState, data); + }); + } + + function stopMonitoring() { + PaymentMonitor.stop(); + monitorStatus.classList.remove('active'); + confirmationsBar.classList.remove('active'); + stopMonitorBtn.classList.remove('active'); + startMonitorBtn.style.display = ''; + viewKeyInput.closest('.field').style.display = ''; + statusIndicator.className = 'status-indicator'; + statusText.textContent = ''; + } + + function updateMonitorUI(monitorState, data) { + const S = PaymentMonitor.STATE; + statusIndicator.className = 'status-indicator ' + monitorState; + + switch (monitorState) { + case S.CONNECTING: + statusText.textContent = I18n.t('monitor_connecting'); + confirmationsBar.classList.remove('active'); + break; + case S.SCANNING: + statusText.textContent = I18n.t('monitor_scanning'); + break; + case S.WAITING: + statusText.textContent = I18n.t('monitor_waiting'); + break; + case S.MEMPOOL: + statusText.textContent = I18n.t('monitor_mempool'); + showConfirmations(data.confirmations); + break; + case S.CONFIRMED: + statusText.textContent = I18n.t('monitor_confirmed'); + showConfirmations(data.confirmations); + stopMonitorBtn.classList.remove('active'); + break; + case S.UNDERPAID: + statusText.textContent = I18n.t('monitor_underpaid'); + var detail = I18n.t('monitor_underpaid_detail') + .replace('{expected}', data.expected.toFixed(6)) + .replace('{received}', data.received.toFixed(6)); + statusText.textContent += '\n' + detail; + showConfirmations(data.confirmations); + break; + case S.ERROR: + statusText.textContent = data.message || I18n.t('monitor_node_error'); + break; + } + } + + function showConfirmations(n) { + confirmationsBar.classList.add('active'); + var pct = Math.min(100, (n / 10) * 100); + confirmationsFill.style.width = pct + '%'; + confirmationsText.textContent = I18n.t('monitor_confirmations').replace('{n}', n); + } })(); diff --git a/i18n.js b/i18n.js index 5884db5..c154d55 100644 --- a/i18n.js +++ b/i18n.js @@ -14,8 +14,9 @@ var I18n = (function () { label_amount: 'Betrag', label_desc: 'Beschreibung (optional)', placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...', - label_timer: 'Zahlungsfrist in Minuten (optional)', - placeholder_timer: 'z.B. 30', + label_timer: 'Zahlungsfrist (optional)', + days: 'Tage', + placeholder_timer_custom: 'Tage', btn_generate: 'Zahlungsanforderung erstellen', btn_open_wallet: 'In Wallet öffnen', btn_copy_addr: 'Adresse kopieren', @@ -25,8 +26,25 @@ var I18n = (function () { btn_new_request: 'Neue Zahlungsanforderung', toast_copied: 'Kopiert!', countdown_expired: 'Zahlungsfrist abgelaufen', - countdown_remaining: 'Zahlungsfrist: ', - rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich' + countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h}:{m}:{s}', + countdown_remaining_hours: 'Zahlungsfrist: {h}:{m}:{s}', + rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich', + btn_monitor: 'Zahlung überwachen', + label_view_key: 'Privater View-Key', + placeholder_view_key: '64 Hex-Zeichen...', + hint_view_key: 'Der View-Key verlässt nie deinen Browser', + btn_start_monitor: 'Überwachung starten', + btn_stop_monitor: 'Überwachung beenden', + monitor_connecting: 'Verbinde mit Node...', + monitor_scanning: 'Scanne Mempool...', + monitor_waiting: 'Warte auf Zahlung...', + monitor_mempool: 'Zahlung erkannt (unbestätigt)', + monitor_confirmed: 'Zahlung bestätigt', + monitor_confirmations: '{n}/10 Bestätigungen', + monitor_underpaid: 'Unterzahlung erkannt', + monitor_underpaid_detail: 'Erwartet: {expected} XMR — Erhalten: {received} XMR', + monitor_node_error: 'Node nicht erreichbar — versuche nächsten...', + monitor_loading: 'Lade Kryptografie-Modul...' }, en: { subtitle: 'Monero payment request in seconds', @@ -35,8 +53,9 @@ var I18n = (function () { label_amount: 'Amount', label_desc: 'Description (optional)', placeholder_desc: 'e.g. Invoice #42, freelance work...', - label_timer: 'Payment deadline in minutes (optional)', - placeholder_timer: 'e.g. 30', + label_timer: 'Payment deadline (optional)', + days: 'days', + placeholder_timer_custom: 'Days', btn_generate: 'Create payment request', btn_open_wallet: 'Open in wallet', btn_copy_addr: 'Copy address', @@ -46,8 +65,25 @@ var I18n = (function () { btn_new_request: 'New payment request', toast_copied: 'Copied!', countdown_expired: 'Payment deadline expired', - countdown_remaining: 'Payment deadline: ', - rates_offline: 'Rates unavailable — XMR amount only' + countdown_remaining_days: 'Deadline: {d} days, {h}:{m}:{s}', + countdown_remaining_hours: 'Deadline: {h}:{m}:{s}', + rates_offline: 'Rates unavailable — XMR amount only', + btn_monitor: 'Monitor payment', + label_view_key: 'Private view key', + placeholder_view_key: '64 hex characters...', + hint_view_key: 'Your view key never leaves your browser', + btn_start_monitor: 'Start monitoring', + btn_stop_monitor: 'Stop monitoring', + monitor_connecting: 'Connecting to node...', + monitor_scanning: 'Scanning mempool...', + monitor_waiting: 'Waiting for payment...', + monitor_mempool: 'Payment detected (unconfirmed)', + monitor_confirmed: 'Payment confirmed', + monitor_confirmations: '{n}/10 confirmations', + monitor_underpaid: 'Underpayment detected', + monitor_underpaid_detail: 'Expected: {expected} XMR — Received: {received} XMR', + monitor_node_error: 'Node unreachable — trying next...', + monitor_loading: 'Loading crypto module...' } }; diff --git a/index.html b/index.html index 651d1d4..420a0f2 100644 --- a/index.html +++ b/index.html @@ -44,8 +44,13 @@