From ee0d0d4124da70209894ff0b8739dddc60d5b1d9 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Thu, 26 Mar 2026 11:01:32 +0100 Subject: [PATCH] Implement lazy-cleanup for expired invoices with deadline-based deletion --- api/shorten.php | 4 ++++ api/verify.php | 30 ++++++++++++++++++++++++------ app.js | 7 ++++++- app.min.js | 2 +- s.php | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/api/shorten.php b/api/shorten.php index 10e8e45..315d3dc 100644 --- a/api/shorten.php +++ b/api/shorten.php @@ -24,6 +24,7 @@ $dbFile = __DIR__ . '/../data/urls.json'; $rawInput = file_get_contents('php://input'); $input = is_string($rawInput) ? json_decode($rawInput, true) : null; $hash = is_array($input) && isset($input['hash']) && is_string($input['hash']) ? $input['hash'] : ''; +$expiryTs = is_array($input) && isset($input['expiry_ts']) ? intval($input['expiry_ts']) : 0; if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,500}$/', $hash)) { http_response_code(400); @@ -69,6 +70,9 @@ while (isset($urls[$code])) { $signature = hash_hmac('sha256', $hash, $secret); $urls[$code] = ['h' => $hash, 's' => $signature]; +if ($expiryTs > 0) { + $urls[$code]['e'] = $expiryTs; +} write_json_locked($fp, $urls); diff --git a/api/verify.php b/api/verify.php index 3c8f62e..a677084 100644 --- a/api/verify.php +++ b/api/verify.php @@ -31,16 +31,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $decodedProofs = is_string($rawProofs) ? json_decode($rawProofs, true) : []; $proofs = is_array($decodedProofs) ? $decodedProofs : []; if (isset($proofs[$code])) { - $response = ['verified' => true]; $proofEntry = $proofs[$code]; - if (is_array($proofEntry)) { - foreach ($proofEntry as $k => $v) { - if (is_string($k)) { - $response[$k] = $v; + $proofExpiry = is_array($proofEntry) ? intval($proofEntry['e'] ?? 0) : 0; + + // Check if proof has expired (lazy cleanup) + if ($proofExpiry > 0 && time() > $proofExpiry) { + unset($proofs[$code]); + [$fp, $allProofs] = read_json_locked($dbFile); + if (isset($allProofs[$code])) { + unset($allProofs[$code]); + write_json_locked($fp, $allProofs); + } + echo json_encode(['verified' => false]); + } else { + $response = ['verified' => true]; + if (is_array($proofEntry)) { + foreach ($proofEntry as $k => $v) { + if (is_string($k)) { + $response[$k] = $v; + } } } + echo json_encode($response); } - echo json_encode($response); } else { echo json_encode(['verified' => false]); } @@ -132,6 +145,11 @@ $proofs[$code] = [ 'verified_at' => time() ]; +// Copy expiry timestamp from URL if it exists +if (isset($urls[$code]) && is_array($urls[$code]) && isset($urls[$code]['e']) && $urls[$code]['e'] > 0) { + $proofs[$code]['e'] = $urls[$code]['e']; +} + write_json_locked($fp, $proofs); echo json_encode(['ok' => true]); diff --git a/app.js b/app.js index a7f8c5a..835d796 100644 --- a/app.js +++ b/app.js @@ -282,10 +282,15 @@ async function shortenUrl(hash) { try { + // Calculate expiry timestamp if deadline is set + let expiryTs = null; + if (selectedDays && selectedDays > 0) { + expiryTs = Math.floor((Date.now() + selectedDays * 86400000) / 1000); + } const res = await fetch('/api/shorten.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hash: hash }) + body: JSON.stringify({ hash: hash, expiry_ts: expiryTs }) }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); diff --git a/app.min.js b/app.min.js index 3c52506..6457559 100644 --- a/app.min.js +++ b/app.min.js @@ -1 +1 @@ -!function(){"use strict";const t=/^[48][1-9A-HJ-NP-Za-km-z]{94}$/,e=/^4[1-9A-HJ-NP-Za-km-z]{105}$/;let n=null,a=0,o=null,r=null,i=!1,s=null,c=null,l=null;const d=t=>document.querySelector(t),u=d("#addr"),f=d("#amount"),p=d("#currency"),m=d("#desc"),v=d("#timerCustom"),h=d("#deadlineBadges");let g=0;const y=d("#generate"),x=d("#result"),C=d("#qr"),w=d("#uri"),L=d("#openWallet"),S=d("#copyAddr"),_=d("#countdown"),I=d("#fiatHint"),F=d("#toast"),T=d("#shareLink"),b=d("#copyShareLink"),E=d("#newRequest"),R=d("#homeLink"),H=d("#proofToggle"),k=d("#proofPanel"),M=d("#txHash"),N=d("#txKey"),U=d("#verifyProof"),j=d("#proofResult"),P=d("#paymentStatus"),X=d("#paymentSummary"),q=d("#downloadPdf");let D=!1,A=!1,z=null;function O(){u.value="",f.value="",p.value="EUR",m.value="",g=0,v.value="",h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),I.textContent="",I.classList.remove("error"),u.classList.remove("valid","invalid"),y.disabled=!0,x.classList.remove("visible"),o&&clearInterval(o),C.innerHTML="",C.classList.remove("paid","confirming"),w.textContent="",T.value="",s=null,ot(),k.classList.remove("open"),M.value="",N.value="",U.disabled=!0,j.innerHTML="",j.className="proof-result",P.innerHTML="",P.className="payment-status",X.innerHTML="",document.title="xmrpay.link — Monero Invoice Generator",history.replaceState(null,"",location.pathname),window.scrollTo({top:0,behavior:"smooth"}),u.focus()}function B(n){return t.test(n)||e.test(n)}function J(){const t=u.value.trim();u.classList.remove("valid","invalid"),0!==t.length&&(B(t)?u.classList.add("valid"):t.length>=10&&u.classList.add("invalid"),function(){const t=u.value.trim();y.disabled=!B(t)}())}function K(){const t=parseFloat(f.value),e=p.value;if(!t||t<=0)return I.textContent="",void I.classList.remove("error");if("XMR"!==e&&!n)return I.textContent=i?I18n.t("rates_offline"):"",void I.classList.toggle("error",i);if(I.classList.remove("error"),"XMR"===e)if(n){const e=(t*n.eur).toFixed(2);I.textContent="≈ "+e+" EUR"}else I.textContent="";else{const a=n[e.toLowerCase()];if(a&&a>0){const e=(t/a).toFixed(8);I.textContent="≈ "+e+" XMR"}}}function W(){const t=parseFloat(f.value),e=p.value;if(!t||t<=0)return null;if("XMR"===e)return t;if(n){const a=n[e.toLowerCase()];if(a&&a>0)return t/a}return null}function G(){const t=u.value.trim();if(!B(t))return;const e=W(),n=m.value.trim(),a=g,i=function(t,e,n){let a="monero:"+t;const o=[];return e&&o.push("tx_amount="+e.toFixed(12)),n&&o.push("tx_description="+encodeURIComponent(n)),o.length&&(a+="?"+o.join("&")),a}(t,e,n);x.classList.add("visible"),w.textContent=i,L.onclick=function(){window.location.href=i},V(e,n,a),Z(e,n);const c=function(t,e,n,a){const o=new URLSearchParams;return o.set("a",t),e&&o.set("x",e.toFixed(12)),n&&o.set("d",n),a&&o.set("t",a),o.toString()}(t,e,n,a);T.value=location.origin+"/#"+c,async function(t){try{const e=await fetch("/api/shorten.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({hash:t})});if(!e.ok)throw new Error("HTTP "+e.status);const n=await e.json();return s||(s=n.code),location.origin+"/s/"+n.code}catch(t){return console.warn("Short URL failed:",t),null}}(c).then(function(t){t&&(T.value=t)}),C.innerHTML="",new QRCode(C,{text:i,width:256,height:256,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRCode.CorrectLevel.M});const l=document.createElement("div");l.className="qr-hint",l.textContent=I18n.t("qr_hint"),C.appendChild(l),function(){o&&clearInterval(o);if(_.textContent="",_.className="countdown",!g||g<=0)return;const t=Date.now()+864e5*g;function e(){const e=t-Date.now();if(e<=0)return clearInterval(o),_.textContent=I18n.t("countdown_expired"),void(_.className="countdown expired");const n=Math.floor(e/864e5),a=Math.floor(e%864e5/36e5),r=Math.floor(e%36e5/6e4);_.textContent=n>0?I18n.t("countdown_remaining_days").replace("{d}",n).replace("{h}",a):I18n.t("countdown_remaining_hours").replace("{h}",$(a)).replace("{m}",$(r))}_.classList.add("active"),r=e,e(),o=setInterval(e,6e4)}(),function(t){try{localStorage.setItem("xmrpay_addr",t)}catch(t){}}(t),x.scrollIntoView({behavior:"smooth",block:"start"})}function V(t,e,n){var a="";if(t){a+='
'+t.toFixed(8)+" XMR
";var o=parseFloat(f.value),r=p.value;"XMR"!==r&&o&&(a+='
≈ '+o.toFixed(2)+" "+r+"
")}e&&(a+='
'+e.replace(/"),X.innerHTML=a,X.classList.remove("paid-confirmed")}function Z(t,e){var n=[];t&&n.push(t.toFixed(4)+" XMR"),e&&n.push(e),n.length&&(document.title=n.join(" — ")+" | xmrpay.link")}function $(t){return t<10?"0"+t:""+t}function Q(t){navigator.clipboard.writeText(t).then(()=>{Y(I18n.t("toast_copied"))})}function Y(t){F.textContent=t,F.classList.add("show"),setTimeout(()=>F.classList.remove("show"),2e3)}function tt(t){return/^[0-9a-fA-F]{64}$/.test(t)}function et(){const t=M.value.trim(),e=N.value.trim();U.disabled=!(tt(t)&&tt(e))}function nt(t){t.verified_at||(t=Object.assign({},t,{verified_at:Math.floor(Date.now()/1e3)})),P.className="payment-status paid",C.classList.add("paid");var e=C.querySelector(".paid-stamp");if(e)e.textContent=I18n.t("status_paid");else{var n=document.createElement("div");n.className="paid-stamp",n.textContent=I18n.t("status_paid"),C.appendChild(n)}var a=C.querySelector(".qr-hint");if(a){var o="";if(t.verified_at)o=" — "+new Date(1e3*t.verified_at).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});a.textContent="TX "+t.tx_hash.substring(0,8)+"..."+o,a.className="qr-hint paid-info"}P.innerHTML="",z=t,X.classList.add("paid-confirmed"),L.style.display="none",document.getElementById("copyAddr").style.display="none";var r=document.getElementById("proofSection");r&&(r.style.display="none"),function(){var t=document.createElement("canvas");t.width=32,t.height=32;var e=t.getContext("2d"),n=new Image;n.onload=function(){e.drawImage(n,0,0,32,32),e.beginPath(),e.arc(25,25,7,0,2*Math.PI),e.fillStyle="#fff",e.fill(),e.beginPath(),e.arc(25,25,5.5,0,2*Math.PI),e.fillStyle="#4caf50",e.fill(),document.getElementById("favicon").href=t.toDataURL("image/png")},n.src="favicon.svg"}()}function at(t){var e=t.confirmations||0;P.className="payment-status pending",X.classList.remove("paid-confirmed"),C.classList.add("confirming");var n=C.querySelector(".paid-stamp");if(!n){var a=document.createElement("div");a.className="paid-stamp pending-stamp",C.appendChild(a),n=a}n.textContent=0===e?I18n.t("status_pending"):e+"/10";var o=C.querySelector(".qr-hint");o&&(o.textContent="TX "+t.tx_hash.substring(0,8)+"... — "+(0===e?I18n.t("status_pending"):e+"/10"),o.className="qr-hint pending-info")}function ot(){c&&(clearInterval(c),c=null),l=null}async function rt(){if(l){var t=l.txHash,e=l.xmrAmount;try{var n=await fetch("/api/node.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"gettransactions",params:{txs_hashes:[t]}})}),a=(await n.json()).txs||[];if(0===a.length)return;var o=a[0].confirmations||0;o>=10?(ot(),j.className="proof-result active success",j.textContent=I18n.t("proof_verified").replace("{amount}",e.toFixed(6)),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:e,confirmations:o,status:"paid"})}),nt({amount:e,tx_hash:t,confirmations:o})):(at({amount:e,tx_hash:t,confirmations:o}),j.className="proof-result active warning",j.textContent=I18n.t("proof_confirmed_pending").replace("{amount}",e.toFixed(6)).replace("{n}",o))}catch(t){}}}!function(){for(var t={de:"EUR",fr:"EUR",it:"EUR",es:"EUR",pt:"EUR",nl:"EUR","de-CH":"CHF","fr-CH":"CHF","it-CH":"CHF","de-AT":"EUR","en-GB":"GBP","en-US":"USD",en:"USD",ja:"JPY",ru:"RUB","pt-BR":"BRL"},e=navigator.languages||[navigator.language||"en"],n=0;n0){g=parseInt(r);const t=h.querySelector('.badge[data-days="'+g+'"]');t?t.classList.add("active"):v.value=g}const i=e.get("c");i&&(s=i,setTimeout(function(){!function(t){fetch("/api/check-short.php?code="+encodeURIComponent(t)).then(function(t){if(!t.ok)throw new Error("Integrity check failed");return t.json()}).then(function(t){t.signature&&async function(t,e){try{const n=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(location.hostname+"xmrpay.link")),a=await crypto.subtle.importKey("raw",n,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),o=await crypto.subtle.sign("HMAC",a,(new TextEncoder).encode(t));return Array.from(new Uint8Array(o)).map(t=>t.toString(16).padStart(2,"0")).join("")===e}catch(t){return console.warn("xmrpay: HMAC verification failed:",t),!1}}(t.hash,t.signature).then(function(t){t||(console.warn("xmrpay: Hash signature mismatch - possible server tampering detected"),Y(I18n.t("toast_integrity_warning")))})}).catch(function(t){console.warn("xmrpay: Could not verify short URL integrity:",t)})}(i),function(t){fetch("/api/verify.php?code="+encodeURIComponent(t)).then(function(t){return t.json()}).then(function(e){e.verified&&("pending"===e.status?(at(e),c=setInterval(function(){fetch("/api/verify.php?code="+encodeURIComponent(t)).then(function(t){return t.json()}).then(function(t){t.verified&&("paid"===t.status?(ot(),nt(t)):at(t))}).catch(function(){})},6e4)):nt(e))}).catch(function(){})}(i)},200));return setTimeout(G,100),!0}()||function(){try{const t=localStorage.getItem("xmrpay_addr");t&&(u.value=t,J())}catch(t){}}(),"serviceWorker"in navigator&&navigator.serviceWorker.register("sw.js").catch(function(){}),I18n.onChange(function(){var t=C.querySelector(".qr-hint");t&&(t.textContent=I18n.t("qr_hint"));var e=C.querySelector(".paid-stamp");if(e&&(e.textContent=I18n.t("status_paid")),z&&nt(z),x.classList.contains("visible")){var n=W(),a=m.value.trim();V(n,a,g),Z(n,a)}r&&r()}),u.addEventListener("input",J),f.addEventListener("input",K),p.addEventListener("change",K),y.addEventListener("click",G),S.addEventListener("click",()=>Q(u.value.trim())),b.addEventListener("click",()=>Q(T.value)),C.addEventListener("click",function(){const t=C.querySelector("canvas");if(!t)return;const e=document.createElement("a");e.download="xmrpay-qr.png",e.href=t.toDataURL("image/png"),e.click()}),E.addEventListener("click",O),R.addEventListener("click",function(t){t.preventDefault(),O()}),h.querySelectorAll(".badge").forEach(function(t){t.addEventListener("click",function(){const e=parseInt(t.getAttribute("data-days"));t.classList.contains("active")?(t.classList.remove("active"),g=0,v.value=""):(h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),t.classList.add("active"),g=e,v.value="")})}),v.addEventListener("input",function(){h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),g=parseInt(v.value)||0}),q.addEventListener("click",async function(){await new Promise(function(t,e){if(window.jspdf)t();else{var n=document.createElement("script");n.src="lib/jspdf.min.js",n.onload=function(){A=!0,t()},n.onerror=function(){e(new Error("Failed to load jsPDF"))},document.head.appendChild(n)}});var t=new(0,window.jspdf.jsPDF)({orientation:"portrait",unit:"mm",format:"a4"}),e=u.value.trim(),n=W(),a=m.value.trim(),o=parseFloat(f.value),r=p.value,i=t.internal.pageSize.getWidth(),s=20,c=i-40,l=s;t.setFillColor(242,104,33),t.rect(0,0,i,8,"F"),l=22,t.setFont("helvetica","bold"),t.setFontSize(22),t.setTextColor(242,104,33),t.text(I18n.t("pdf_title"),s,l),t.setFont("helvetica","normal"),t.setFontSize(10),t.setTextColor(120,120,120);var d=(new Date).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});t.text(I18n.t("pdf_date")+": "+d,i-s,l,{align:"right"}),l+=6,t.setDrawColor(220,220,220),t.setLineWidth(.3),t.line(s,l,i-s,l);var v=C.querySelector("canvas"),h=50,y=i-s-h,x=l+6;if(v){var L=v.toDataURL("image/png");t.addImage(L,"PNG",y,x,h,h),t.setFontSize(7),t.setTextColor(150,150,150),t.text(I18n.t("pdf_scan_qr"),y+25,x+h+4,{align:"center"})}var S=y-s-10;function _(e,n){t.setFont("helvetica","normal"),t.setFontSize(9),t.setTextColor(150,150,150),t.text(e,20,l),l+=5,t.setFont("helvetica","bold"),t.setFontSize(11),t.setTextColor(40,40,40);var a=t.splitTextToSize(n,S);t.text(a,20,l),l+=5*a.length+4}if(l+=14,n){var I=n.toFixed(8)+" XMR";"XMR"!==r&&o&&(I+=" (~ "+o.toFixed(2)+" "+r+")"),_(I18n.t("pdf_amount"),I)}a&&_(I18n.t("pdf_desc"),a);if(g>0){var F=new Date(Date.now()+864e5*g).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});_(I18n.t("pdf_deadline"),F+" ("+I18n.t("pdf_deadline_days").replace("{d}",g)+")")}l=Math.max(l,x+h+12),t.setFont("helvetica","normal"),t.setFontSize(9),t.setTextColor(150,150,150),t.text(I18n.t("pdf_address"),s,l),l+=5,t.setFillColor(245,245,245),t.roundedRect(s,l-3.5,c,10,2,2,"F"),t.setFont("courier","normal"),t.setFontSize(8),t.setTextColor(60,60,60),t.text(e,23,l+2.5),l+=14;var b=w.textContent;if(b){t.setFillColor(245,245,245),t.roundedRect(s,l-3.5,c,10,2,2,"F"),t.setFont("courier","normal"),t.setFontSize(6.5),t.setTextColor(100,100,100);var E=t.splitTextToSize(b,c-6);t.text(E,23,l+2),l+=3*E.length+10}if(z){l+=4;var R="";if(z.verified_at)R=new Date(1e3*z.verified_at).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});var H=z.amount.toFixed(6)+" XMR — TX "+z.tx_hash.substring(0,8)+"..."+(R?" — "+R:"");t.setFillColor(76,175,80),t.roundedRect(s,l-4,c,16,2,2,"F"),t.setFont("helvetica","bold"),t.setFontSize(12),t.setTextColor(255,255,255),t.text(I18n.t("status_paid").toUpperCase(),s+c/2,l+1,{align:"center"}),t.setFont("helvetica","normal"),t.setFontSize(7.5),t.text(H,s+c/2,l+7,{align:"center"}),l+=22}t.setDrawColor(220,220,220),t.setLineWidth(.3);var k=t.internal.pageSize.getHeight()-15;t.line(s,k,i-s,k),t.setFont("helvetica","normal"),t.setFontSize(7),t.setTextColor(180,180,180),t.text(I18n.t("pdf_footer"),i/2,k+5,{align:"center"});var M=T.value;M&&t.text(M,i/2,k+9,{align:"center"});var N="xmrpay-"+(a?a.replace(/[^a-zA-Z0-9]/g,"-").substring(0,30):"invoice")+".pdf";t.save(N)}),H.addEventListener("click",function(){if(k.classList.contains("open"))return void k.classList.remove("open");if(!D&&!window.XmrCrypto)return void new Promise(function(t,e){if(window.XmrCrypto)return void t();const n=document.createElement("script");n.src="lib/xmr-crypto.bundle.js",n.onload=t,n.onerror=function(){e(new Error("Failed to load crypto module"))},document.head.appendChild(n)}).then(function(){D=!0,k.classList.add("open"),M.focus()});k.classList.add("open"),M.focus()}),M.addEventListener("input",et),N.addEventListener("input",et),U.addEventListener("click",async function(){const t=M.value.trim(),e=N.value.trim(),n=u.value.trim();if(!tt(t)||!tt(e)||!B(n))return;U.disabled=!0,j.className="proof-result active",j.textContent=I18n.t("proof_verifying");try{var a=await fetch("/api/node.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"gettransactions",params:{txs_hashes:[t],decode_as_json:!0}})}),o=(await a.json()).txs||[];if(0===o.length)return j.className="proof-result active error",j.textContent=I18n.t("proof_tx_not_found"),void(U.disabled=!1);for(var r=o[0],i=JSON.parse(r.as_json),d=XmrCrypto.getKeysFromAddress(n),f=d.publicViewKey,p=d.publicSpendKey,m=XmrCrypto.bytesToScalar(XmrCrypto.hexToBytes(e)),v=XmrCrypto.Point.fromHex(f).multiply(m).multiply(8n).toBytes(),h=XmrCrypto.Point.fromHex(p),g=i.vout||[],y=i.rct_signatures&&i.rct_signatures.ecdhInfo||[],x=0n,C=!1,w=0;w=10?(j.className="proof-result active success",j.textContent=I18n.t("proof_verified").replace("{amount}",b.toFixed(6)),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:b,confirmations:E,status:"paid"})}),nt({amount:b,tx_hash:t,confirmations:E})):(j.className="proof-result active warning",j.textContent=I18n.t("proof_confirmed_pending").replace("{amount}",b.toFixed(6)).replace("{n}",E),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:b,confirmations:E,status:"pending"})}),at({amount:b,tx_hash:t,confirmations:E}),function(t,e){ot(),l={txHash:t,xmrAmount:e},c=setInterval(rt,6e4)}(t,b))}else j.className="proof-result active error",j.textContent=I18n.t("proof_no_match")}catch(t){j.className="proof-result active error",j.textContent=I18n.t("proof_error")}U.disabled=!1})}(); \ No newline at end of file +!function(){"use strict";const t=/^[48][1-9A-HJ-NP-Za-km-z]{94}$/,e=/^4[1-9A-HJ-NP-Za-km-z]{105}$/;let n=null,a=0,o=null,r=null,i=!1,s=null,c=null,l=null;const d=t=>document.querySelector(t),u=d("#addr"),f=d("#amount"),p=d("#currency"),m=d("#desc"),v=d("#timerCustom"),h=d("#deadlineBadges");let g=0;const y=d("#generate"),x=d("#result"),C=d("#qr"),w=d("#uri"),L=d("#openWallet"),S=d("#copyAddr"),_=d("#countdown"),I=d("#fiatHint"),F=d("#toast"),T=d("#shareLink"),b=d("#copyShareLink"),E=d("#newRequest"),R=d("#homeLink"),H=d("#proofToggle"),k=d("#proofPanel"),M=d("#txHash"),N=d("#txKey"),U=d("#verifyProof"),j=d("#proofResult"),P=d("#paymentStatus"),X=d("#paymentSummary"),q=d("#downloadPdf");let D=!1,A=!1,z=null;function O(){u.value="",f.value="",p.value="EUR",m.value="",g=0,v.value="",h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),I.textContent="",I.classList.remove("error"),u.classList.remove("valid","invalid"),y.disabled=!0,x.classList.remove("visible"),o&&clearInterval(o),C.innerHTML="",C.classList.remove("paid","confirming"),w.textContent="",T.value="",s=null,ot(),k.classList.remove("open"),M.value="",N.value="",U.disabled=!0,j.innerHTML="",j.className="proof-result",P.innerHTML="",P.className="payment-status",X.innerHTML="",document.title="xmrpay.link — Monero Invoice Generator",history.replaceState(null,"",location.pathname),window.scrollTo({top:0,behavior:"smooth"}),u.focus()}function B(n){return t.test(n)||e.test(n)}function J(){const t=u.value.trim();u.classList.remove("valid","invalid"),0!==t.length&&(B(t)?u.classList.add("valid"):t.length>=10&&u.classList.add("invalid"),function(){const t=u.value.trim();y.disabled=!B(t)}())}function K(){const t=parseFloat(f.value),e=p.value;if(!t||t<=0)return I.textContent="",void I.classList.remove("error");if("XMR"!==e&&!n)return I.textContent=i?I18n.t("rates_offline"):"",void I.classList.toggle("error",i);if(I.classList.remove("error"),"XMR"===e)if(n){const e=(t*n.eur).toFixed(2);I.textContent="≈ "+e+" EUR"}else I.textContent="";else{const a=n[e.toLowerCase()];if(a&&a>0){const e=(t/a).toFixed(8);I.textContent="≈ "+e+" XMR"}}}function W(){const t=parseFloat(f.value),e=p.value;if(!t||t<=0)return null;if("XMR"===e)return t;if(n){const a=n[e.toLowerCase()];if(a&&a>0)return t/a}return null}function G(){const t=u.value.trim();if(!B(t))return;const e=W(),n=m.value.trim(),a=g,i=function(t,e,n){let a="monero:"+t;const o=[];return e&&o.push("tx_amount="+e.toFixed(12)),n&&o.push("tx_description="+encodeURIComponent(n)),o.length&&(a+="?"+o.join("&")),a}(t,e,n);x.classList.add("visible"),w.textContent=i,L.onclick=function(){window.location.href=i},V(e,n,a),Z(e,n);const c=function(t,e,n,a){const o=new URLSearchParams;return o.set("a",t),e&&o.set("x",e.toFixed(12)),n&&o.set("d",n),a&&o.set("t",a),o.toString()}(t,e,n,a);T.value=location.origin+"/#"+c,async function(t){try{let e=null;g&&g>0&&(e=Math.floor((Date.now()+864e5*g)/1e3));const n=await fetch("/api/shorten.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({hash:t,expiry_ts:e})});if(!n.ok)throw new Error("HTTP "+n.status);const a=await n.json();return s||(s=a.code),location.origin+"/s/"+a.code}catch(t){return console.warn("Short URL failed:",t),null}}(c).then(function(t){t&&(T.value=t)}),C.innerHTML="",new QRCode(C,{text:i,width:256,height:256,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRCode.CorrectLevel.M});const l=document.createElement("div");l.className="qr-hint",l.textContent=I18n.t("qr_hint"),C.appendChild(l),function(){o&&clearInterval(o);if(_.textContent="",_.className="countdown",!g||g<=0)return;const t=Date.now()+864e5*g;function e(){const e=t-Date.now();if(e<=0)return clearInterval(o),_.textContent=I18n.t("countdown_expired"),void(_.className="countdown expired");const n=Math.floor(e/864e5),a=Math.floor(e%864e5/36e5),r=Math.floor(e%36e5/6e4);_.textContent=n>0?I18n.t("countdown_remaining_days").replace("{d}",n).replace("{h}",a):I18n.t("countdown_remaining_hours").replace("{h}",$(a)).replace("{m}",$(r))}_.classList.add("active"),r=e,e(),o=setInterval(e,6e4)}(),function(t){try{localStorage.setItem("xmrpay_addr",t)}catch(t){}}(t),x.scrollIntoView({behavior:"smooth",block:"start"})}function V(t,e,n){var a="";if(t){a+='
'+t.toFixed(8)+" XMR
";var o=parseFloat(f.value),r=p.value;"XMR"!==r&&o&&(a+='
≈ '+o.toFixed(2)+" "+r+"
")}e&&(a+='
'+e.replace(/"),X.innerHTML=a,X.classList.remove("paid-confirmed")}function Z(t,e){var n=[];t&&n.push(t.toFixed(4)+" XMR"),e&&n.push(e),n.length&&(document.title=n.join(" — ")+" | xmrpay.link")}function $(t){return t<10?"0"+t:""+t}function Q(t){navigator.clipboard.writeText(t).then(()=>{Y(I18n.t("toast_copied"))})}function Y(t){F.textContent=t,F.classList.add("show"),setTimeout(()=>F.classList.remove("show"),2e3)}function tt(t){return/^[0-9a-fA-F]{64}$/.test(t)}function et(){const t=M.value.trim(),e=N.value.trim();U.disabled=!(tt(t)&&tt(e))}function nt(t){t.verified_at||(t=Object.assign({},t,{verified_at:Math.floor(Date.now()/1e3)})),P.className="payment-status paid",C.classList.add("paid");var e=C.querySelector(".paid-stamp");if(e)e.textContent=I18n.t("status_paid");else{var n=document.createElement("div");n.className="paid-stamp",n.textContent=I18n.t("status_paid"),C.appendChild(n)}var a=C.querySelector(".qr-hint");if(a){var o="";if(t.verified_at)o=" — "+new Date(1e3*t.verified_at).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});a.textContent="TX "+t.tx_hash.substring(0,8)+"..."+o,a.className="qr-hint paid-info"}P.innerHTML="",z=t,X.classList.add("paid-confirmed"),L.style.display="none",document.getElementById("copyAddr").style.display="none";var r=document.getElementById("proofSection");r&&(r.style.display="none"),function(){var t=document.createElement("canvas");t.width=32,t.height=32;var e=t.getContext("2d"),n=new Image;n.onload=function(){e.drawImage(n,0,0,32,32),e.beginPath(),e.arc(25,25,7,0,2*Math.PI),e.fillStyle="#fff",e.fill(),e.beginPath(),e.arc(25,25,5.5,0,2*Math.PI),e.fillStyle="#4caf50",e.fill(),document.getElementById("favicon").href=t.toDataURL("image/png")},n.src="favicon.svg"}()}function at(t){var e=t.confirmations||0;P.className="payment-status pending",X.classList.remove("paid-confirmed"),C.classList.add("confirming");var n=C.querySelector(".paid-stamp");if(!n){var a=document.createElement("div");a.className="paid-stamp pending-stamp",C.appendChild(a),n=a}n.textContent=0===e?I18n.t("status_pending"):e+"/10";var o=C.querySelector(".qr-hint");o&&(o.textContent="TX "+t.tx_hash.substring(0,8)+"... — "+(0===e?I18n.t("status_pending"):e+"/10"),o.className="qr-hint pending-info")}function ot(){c&&(clearInterval(c),c=null),l=null}async function rt(){if(l){var t=l.txHash,e=l.xmrAmount;try{var n=await fetch("/api/node.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"gettransactions",params:{txs_hashes:[t]}})}),a=(await n.json()).txs||[];if(0===a.length)return;var o=a[0].confirmations||0;o>=10?(ot(),j.className="proof-result active success",j.textContent=I18n.t("proof_verified").replace("{amount}",e.toFixed(6)),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:e,confirmations:o,status:"paid"})}),nt({amount:e,tx_hash:t,confirmations:o})):(at({amount:e,tx_hash:t,confirmations:o}),j.className="proof-result active warning",j.textContent=I18n.t("proof_confirmed_pending").replace("{amount}",e.toFixed(6)).replace("{n}",o))}catch(t){}}}!function(){for(var t={de:"EUR",fr:"EUR",it:"EUR",es:"EUR",pt:"EUR",nl:"EUR","de-CH":"CHF","fr-CH":"CHF","it-CH":"CHF","de-AT":"EUR","en-GB":"GBP","en-US":"USD",en:"USD",ja:"JPY",ru:"RUB","pt-BR":"BRL"},e=navigator.languages||[navigator.language||"en"],n=0;n0){g=parseInt(r);const t=h.querySelector('.badge[data-days="'+g+'"]');t?t.classList.add("active"):v.value=g}const i=e.get("c");i&&(s=i,setTimeout(function(){!function(t){fetch("/api/check-short.php?code="+encodeURIComponent(t)).then(function(t){if(!t.ok)throw new Error("Integrity check failed");return t.json()}).then(function(t){t.signature&&async function(t,e){try{const n=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(location.hostname+"xmrpay.link")),a=await crypto.subtle.importKey("raw",n,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),o=await crypto.subtle.sign("HMAC",a,(new TextEncoder).encode(t));return Array.from(new Uint8Array(o)).map(t=>t.toString(16).padStart(2,"0")).join("")===e}catch(t){return console.warn("xmrpay: HMAC verification failed:",t),!1}}(t.hash,t.signature).then(function(t){t||(console.warn("xmrpay: Hash signature mismatch - possible server tampering detected"),Y(I18n.t("toast_integrity_warning")))})}).catch(function(t){console.warn("xmrpay: Could not verify short URL integrity:",t)})}(i),function(t){fetch("/api/verify.php?code="+encodeURIComponent(t)).then(function(t){return t.json()}).then(function(e){e.verified&&("pending"===e.status?(at(e),c=setInterval(function(){fetch("/api/verify.php?code="+encodeURIComponent(t)).then(function(t){return t.json()}).then(function(t){t.verified&&("paid"===t.status?(ot(),nt(t)):at(t))}).catch(function(){})},6e4)):nt(e))}).catch(function(){})}(i)},200));return setTimeout(G,100),!0}()||function(){try{const t=localStorage.getItem("xmrpay_addr");t&&(u.value=t,J())}catch(t){}}(),"serviceWorker"in navigator&&navigator.serviceWorker.register("sw.js").catch(function(){}),I18n.onChange(function(){var t=C.querySelector(".qr-hint");t&&(t.textContent=I18n.t("qr_hint"));var e=C.querySelector(".paid-stamp");if(e&&(e.textContent=I18n.t("status_paid")),z&&nt(z),x.classList.contains("visible")){var n=W(),a=m.value.trim();V(n,a,g),Z(n,a)}r&&r()}),u.addEventListener("input",J),f.addEventListener("input",K),p.addEventListener("change",K),y.addEventListener("click",G),S.addEventListener("click",()=>Q(u.value.trim())),b.addEventListener("click",()=>Q(T.value)),C.addEventListener("click",function(){const t=C.querySelector("canvas");if(!t)return;const e=document.createElement("a");e.download="xmrpay-qr.png",e.href=t.toDataURL("image/png"),e.click()}),E.addEventListener("click",O),R.addEventListener("click",function(t){t.preventDefault(),O()}),h.querySelectorAll(".badge").forEach(function(t){t.addEventListener("click",function(){const e=parseInt(t.getAttribute("data-days"));t.classList.contains("active")?(t.classList.remove("active"),g=0,v.value=""):(h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),t.classList.add("active"),g=e,v.value="")})}),v.addEventListener("input",function(){h.querySelectorAll(".badge").forEach(function(t){t.classList.remove("active")}),g=parseInt(v.value)||0}),q.addEventListener("click",async function(){await new Promise(function(t,e){if(window.jspdf)t();else{var n=document.createElement("script");n.src="lib/jspdf.min.js",n.onload=function(){A=!0,t()},n.onerror=function(){e(new Error("Failed to load jsPDF"))},document.head.appendChild(n)}});var t=new(0,window.jspdf.jsPDF)({orientation:"portrait",unit:"mm",format:"a4"}),e=u.value.trim(),n=W(),a=m.value.trim(),o=parseFloat(f.value),r=p.value,i=t.internal.pageSize.getWidth(),s=20,c=i-40,l=s;t.setFillColor(242,104,33),t.rect(0,0,i,8,"F"),l=22,t.setFont("helvetica","bold"),t.setFontSize(22),t.setTextColor(242,104,33),t.text(I18n.t("pdf_title"),s,l),t.setFont("helvetica","normal"),t.setFontSize(10),t.setTextColor(120,120,120);var d=(new Date).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});t.text(I18n.t("pdf_date")+": "+d,i-s,l,{align:"right"}),l+=6,t.setDrawColor(220,220,220),t.setLineWidth(.3),t.line(s,l,i-s,l);var v=C.querySelector("canvas"),h=50,y=i-s-h,x=l+6;if(v){var L=v.toDataURL("image/png");t.addImage(L,"PNG",y,x,h,h),t.setFontSize(7),t.setTextColor(150,150,150),t.text(I18n.t("pdf_scan_qr"),y+25,x+h+4,{align:"center"})}var S=y-s-10;function _(e,n){t.setFont("helvetica","normal"),t.setFontSize(9),t.setTextColor(150,150,150),t.text(e,20,l),l+=5,t.setFont("helvetica","bold"),t.setFontSize(11),t.setTextColor(40,40,40);var a=t.splitTextToSize(n,S);t.text(a,20,l),l+=5*a.length+4}if(l+=14,n){var I=n.toFixed(8)+" XMR";"XMR"!==r&&o&&(I+=" (~ "+o.toFixed(2)+" "+r+")"),_(I18n.t("pdf_amount"),I)}a&&_(I18n.t("pdf_desc"),a);if(g>0){var F=new Date(Date.now()+864e5*g).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});_(I18n.t("pdf_deadline"),F+" ("+I18n.t("pdf_deadline_days").replace("{d}",g)+")")}l=Math.max(l,x+h+12),t.setFont("helvetica","normal"),t.setFontSize(9),t.setTextColor(150,150,150),t.text(I18n.t("pdf_address"),s,l),l+=5,t.setFillColor(245,245,245),t.roundedRect(s,l-3.5,c,10,2,2,"F"),t.setFont("courier","normal"),t.setFontSize(8),t.setTextColor(60,60,60),t.text(e,23,l+2.5),l+=14;var b=w.textContent;if(b){t.setFillColor(245,245,245),t.roundedRect(s,l-3.5,c,10,2,2,"F"),t.setFont("courier","normal"),t.setFontSize(6.5),t.setTextColor(100,100,100);var E=t.splitTextToSize(b,c-6);t.text(E,23,l+2),l+=3*E.length+10}if(z){l+=4;var R="";if(z.verified_at)R=new Date(1e3*z.verified_at).toLocaleDateString("de"===I18n.getLang()?"de-CH":"en-US",{year:"numeric",month:"long",day:"numeric"});var H=z.amount.toFixed(6)+" XMR — TX "+z.tx_hash.substring(0,8)+"..."+(R?" — "+R:"");t.setFillColor(76,175,80),t.roundedRect(s,l-4,c,16,2,2,"F"),t.setFont("helvetica","bold"),t.setFontSize(12),t.setTextColor(255,255,255),t.text(I18n.t("status_paid").toUpperCase(),s+c/2,l+1,{align:"center"}),t.setFont("helvetica","normal"),t.setFontSize(7.5),t.text(H,s+c/2,l+7,{align:"center"}),l+=22}t.setDrawColor(220,220,220),t.setLineWidth(.3);var k=t.internal.pageSize.getHeight()-15;t.line(s,k,i-s,k),t.setFont("helvetica","normal"),t.setFontSize(7),t.setTextColor(180,180,180),t.text(I18n.t("pdf_footer"),i/2,k+5,{align:"center"});var M=T.value;M&&t.text(M,i/2,k+9,{align:"center"});var N="xmrpay-"+(a?a.replace(/[^a-zA-Z0-9]/g,"-").substring(0,30):"invoice")+".pdf";t.save(N)}),H.addEventListener("click",function(){if(k.classList.contains("open"))return void k.classList.remove("open");if(!D&&!window.XmrCrypto)return void new Promise(function(t,e){if(window.XmrCrypto)return void t();const n=document.createElement("script");n.src="lib/xmr-crypto.bundle.js",n.onload=t,n.onerror=function(){e(new Error("Failed to load crypto module"))},document.head.appendChild(n)}).then(function(){D=!0,k.classList.add("open"),M.focus()});k.classList.add("open"),M.focus()}),M.addEventListener("input",et),N.addEventListener("input",et),U.addEventListener("click",async function(){const t=M.value.trim(),e=N.value.trim(),n=u.value.trim();if(!tt(t)||!tt(e)||!B(n))return;U.disabled=!0,j.className="proof-result active",j.textContent=I18n.t("proof_verifying");try{var a=await fetch("/api/node.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"gettransactions",params:{txs_hashes:[t],decode_as_json:!0}})}),o=(await a.json()).txs||[];if(0===o.length)return j.className="proof-result active error",j.textContent=I18n.t("proof_tx_not_found"),void(U.disabled=!1);for(var r=o[0],i=JSON.parse(r.as_json),d=XmrCrypto.getKeysFromAddress(n),f=d.publicViewKey,p=d.publicSpendKey,m=XmrCrypto.bytesToScalar(XmrCrypto.hexToBytes(e)),v=XmrCrypto.Point.fromHex(f).multiply(m).multiply(8n).toBytes(),h=XmrCrypto.Point.fromHex(p),g=i.vout||[],y=i.rct_signatures&&i.rct_signatures.ecdhInfo||[],x=0n,C=!1,w=0;w=10?(j.className="proof-result active success",j.textContent=I18n.t("proof_verified").replace("{amount}",b.toFixed(6)),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:b,confirmations:E,status:"paid"})}),nt({amount:b,tx_hash:t,confirmations:E})):(j.className="proof-result active warning",j.textContent=I18n.t("proof_confirmed_pending").replace("{amount}",b.toFixed(6)).replace("{n}",E),s&&await fetch("/api/verify.php",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s,tx_hash:t,amount:b,confirmations:E,status:"pending"})}),at({amount:b,tx_hash:t,confirmations:E}),function(t,e){ot(),l={txHash:t,xmrAmount:e},c=setInterval(rt,6e4)}(t,b))}else j.className="proof-result active error",j.textContent=I18n.t("proof_no_match")}catch(t){j.className="proof-result active error",j.textContent=I18n.t("proof_error")}U.disabled=!1})}(); \ No newline at end of file diff --git a/s.php b/s.php index 8d02343..025ccbf 100644 --- a/s.php +++ b/s.php @@ -31,6 +31,21 @@ $data = $urls[$code]; $hash = is_array($data) ? ($data['h'] ?? '') : $data; $hash = is_string($hash) ? $hash : ''; $signature = is_array($data) ? ($data['s'] ?? null) : null; +$expiryTs = is_array($data) ? intval($data['e'] ?? 0) : 0; + +// Check if URL has expired (lazy cleanup) +if ($expiryTs > 0 && time() > $expiryTs) { + require_once __DIR__ . '/api/_helpers.php'; + // Delete expired URL + [$fp, $urls] = read_json_locked(__DIR__ . '/data/urls.json'); + if (isset($urls[$code])) { + unset($urls[$code]); + write_json_locked($fp, $urls); + } + http_response_code(410); + echo 'Gone'; + exit; +} // Verify HMAC signature if present (detect server-side tampering) if (is_string($signature) && $signature !== '') {