Compare commits
47 Commits
v1.0.2
...
ec99e097c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec99e097c2 | ||
|
|
7e389d6a1c | ||
|
|
3cd8d03d9b | ||
|
|
e52955f106 | ||
|
|
eae15de873 | ||
|
|
71383431f2 | ||
|
|
09616adc81 | ||
|
|
090256ae4f | ||
|
|
9c466d3814 | ||
|
|
523bdae81c | ||
|
|
c206a51f0b | ||
|
|
9faec16b31 | ||
|
|
fa2f7a4ab1 | ||
|
|
a5de8752dd | ||
|
|
4549a05b6d | ||
|
|
c8df4df881 | ||
|
|
9999c00d59 | ||
|
|
458ee78362 | ||
|
|
ded24ce575 | ||
|
|
600154493e | ||
|
|
fa9f2243ae | ||
|
|
cffdee2cb6 | ||
|
|
2154d5996d | ||
|
|
27cb9e0fec | ||
|
|
69c66aea38 | ||
|
|
1bbf309029 | ||
|
|
14f73875de | ||
|
|
38f23d6627 | ||
|
|
96dd4bfc72 | ||
|
|
1edf8bb324 | ||
|
|
6b45a6346c | ||
|
|
9ef0988627 | ||
|
|
2b84a16055 | ||
|
|
af7824b080 | ||
|
|
b30f3051e4 | ||
|
|
8af78dbb53 | ||
|
|
dbe6e45ef3 | ||
|
|
9b6cca5d4b | ||
|
|
6bad55113b | ||
|
|
c2abb49a42 | ||
|
|
68a871d00c | ||
|
|
2aff54a765 | ||
|
|
a0236a7c43 | ||
|
|
025e3a06c0 | ||
|
|
59125ecea0 | ||
|
|
2ffc3cec1f | ||
|
|
e9b66fda24 |
276
README.md
276
README.md
@@ -1,172 +1,184 @@
|
||||
# xmrpay.link — Serverless XMR Invoice Builder
|
||||
# xmrpay.link — Monero Invoice Generator
|
||||
|
||||
> Privat. Selbst gehostet. Keine Accounts. Kein Backend. Kein Bullshit.
|
||||
> Private. Self-hosted. No accounts. No tracking. No bullshit.
|
||||
|
||||
**[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)**
|
||||
|
||||
---
|
||||
|
||||
## Idee
|
||||
## What is this?
|
||||
|
||||
**xmrpay.link** ist eine rein clientseitige Web-App, die es jedem ermöglicht,
|
||||
in unter 30 Sekunden eine professionelle Monero-Zahlungsanforderung zu erstellen —
|
||||
ohne eigenen Node, ohne Registration, ohne KYC, ohne Drittanbieter.
|
||||
**xmrpay.link** is a client-side web app that lets anyone create a professional Monero payment request in under 30 seconds — no account registration, no KYC, no custodial services.
|
||||
|
||||
Du gibst deine Adresse ein, den Betrag, eine optionale Beschreibung —
|
||||
und bekommst einen QR-Code, einen kopierbaren `monero:`-Link und eine
|
||||
optionale PDF-Rechnung. Fertig.
|
||||
Enter your address, the amount, an optional description — and get a QR code, a shareable short link, and a PDF invoice. Done.
|
||||
|
||||
### Architecture & Transparency
|
||||
|
||||
xmrpay.link uses a **minimal backend** for the following specific purposes:
|
||||
|
||||
| Component | Where it runs | What the server sees |
|
||||
|-----------|--------------|---------------------|
|
||||
| QR code generation | Browser only | Nothing |
|
||||
| PDF invoice | Browser only | Nothing |
|
||||
| Payment (TX) verification | Browser only | Nothing |
|
||||
| Fiat exchange rates | Server (CoinGecko proxy) | Your IP address |
|
||||
| Short URL storage | Server | Invoice hash (address + amount + description), HMAC-signed |
|
||||
| Payment proof storage | Server | TX hash + amount — **not** your XMR address |
|
||||
|
||||
**Self-hosting** eliminates any trust in the public instance.
|
||||
**No short links** (use the long `/#...` URL or QR code) = zero server involvement.
|
||||
|
||||
### Security Model
|
||||
|
||||
- **HMAC-signed short URLs:** Hashes are signed with a server-side secret. Clients verify the signature on load to detect tampering.
|
||||
- **Address never stored:** Payment verification is cryptographic and runs client-side. The server never learns your XMR address.
|
||||
- **Rate-limited APIs:** All write endpoints are rate-limited per IP.
|
||||
- **Origin-restricted:** API endpoints reject cross-origin requests.
|
||||
|
||||
---
|
||||
|
||||
## Das Problem (Warum es das noch nicht gibt)
|
||||
## Why?
|
||||
|
||||
| Lösung | Problem |
|
||||
| Solution | Problem |
|
||||
|---|---|
|
||||
| **BTCPay Server** | Eigener Server nötig, komplexes Setup |
|
||||
| **NOWPayments, Globee** | Custodial, KYC, Fees, Drittanbieter-Abhängigkeit |
|
||||
| **Cake Wallet Invoice** | Mobil-only, kein Teilen ohne App |
|
||||
| **MoneroPay** | Backend-Daemon nötig, nur für Entwickler |
|
||||
| **Wallet-QR direkt** | Kein Betrag, keine Beschreibung, keine Bestätigung |
|
||||
| **BTCPay Server** | Requires own server, complex setup |
|
||||
| **NOWPayments, Globee** | Custodial, KYC, fees, third-party dependency |
|
||||
| **Cake Wallet Invoice** | Mobile-only, no sharing without app |
|
||||
| **MoneroPay** | Backend daemon required, developer-only |
|
||||
| **Wallet QR** | No amount, no description, no confirmation |
|
||||
|
||||
**Die Lücke:** Es gibt kein einfaches, datenschutzkonformes Tool für Freelancer,
|
||||
kleine Händler und Creator, das ohne Setup funktioniert und trotzdem
|
||||
Zahlungsbestätigung ermöglicht.
|
||||
**The gap:** There's no simple, privacy-respecting tool for freelancers, small merchants, and creators that works without setup and still allows payment confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Stack
|
||||
## Features
|
||||
|
||||
### Invoice Generation
|
||||
- XMR address input with validation (standard, subaddress, integrated)
|
||||
- Amount in XMR or fiat (EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected)
|
||||
- Description and payment deadline (7/14/30 days or custom)
|
||||
- QR code with `monero:` URI
|
||||
- Shareable short URLs (`/s/abc123`) with HMAC signatures for integrity
|
||||
- PDF invoice download (with QR, amount, fiat equivalent, deadline)
|
||||
- i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection
|
||||
|
||||
### Payment Verification (TX Proof)
|
||||
- Sender provides TX Hash + TX Key from their wallet
|
||||
- Cryptographic verification in the browser (no private keys needed)
|
||||
- Payment status stored with the invoice (server stores proof, but not your address)
|
||||
- Invoice link shows "Paid" badge after verification
|
||||
- Standard and subaddress support
|
||||
|
||||
### Performance & Privacy
|
||||
- 100% Lighthouse score (Performance, Accessibility, Best Practices, SEO)
|
||||
- Offline-capable via Service Worker
|
||||
- Self-hosted fonts (no Google Fonts dependency)
|
||||
- Zero external tracking, no cookies
|
||||
- Dark mode, responsive design
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
```
|
||||
Frontend: HTML + Vanilla JS (oder leichtes Vue 3)
|
||||
Crypto: monero-javascript (WASM, läuft im Browser)
|
||||
Node: Frei wählbarer öffentlicher Remote Node (z.B. xmr.sh, node.community)
|
||||
QR: QRCode.js (clientseitig)
|
||||
PDF: jsPDF (clientseitig)
|
||||
Hosting: Statische Site — GitHub Pages, Netlify, Vercel, Self-hosted
|
||||
Backend: KEINES
|
||||
Daten: LocalStorage (optional, nur lokal, nie übertragen)
|
||||
Frontend: HTML + Vanilla JS (no frameworks, no build step)
|
||||
Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle)
|
||||
QR: QRCode.js (client-side)
|
||||
PDF: jsPDF (client-side, lazy-loaded)
|
||||
Hosting: Static site + minimal PHP for short URLs and RPC proxy
|
||||
Backend: Minimal PHP (URL shortener, rates proxy, proof storage)
|
||||
Data: JSON files (no database), LocalStorage (client-side)
|
||||
```
|
||||
|
||||
**Kein PHP-Backend. Kein Node.js-Server. Kein Datenbank-Setup.**
|
||||
Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann.
|
||||
|
||||
---
|
||||
|
||||
## Feature-Roadmap
|
||||
|
||||
### v1 — Der Kern (Static QR Generator)
|
||||
|
||||
- [ ] XMR-Adresse eingeben (mit Validierung)
|
||||
- [ ] Betrag in XMR eingeben (optional: EUR/CHF/USD-Umrechnung via CoinGecko API)
|
||||
- [ ] Beschreibung / Verwendungszweck
|
||||
- [ ] Optionaler Countdown-Timer (Zahlungsfrist)
|
||||
- [ ] `monero:`-URI generieren (Standard: [SLIP-0021](https://github.com/satoshilabs/slips/blob/master/slip-0021.md))
|
||||
- [ ] QR-Code anzeigen und als PNG downloaden
|
||||
- [ ] Link kopieren (für Messenger, E-Mail etc.)
|
||||
- [ ] Responsive Design, Dark Mode
|
||||
|
||||
### 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)
|
||||
|
||||
### v3 — Professionelle Features
|
||||
|
||||
- [ ] PDF-Rechnung generieren (Logo, Betrag in Fiat, XMR-Betrag, QR, Fälligkeitsdatum)
|
||||
- [ ] Einbettbarer `<iframe>`-Widget für beliebige Websites
|
||||
- [ ] Mehrsprachigkeit (DE, EN, FR, ES)
|
||||
- [ ] Rechnungshistorie (LocalStorage, exportierbar als CSV)
|
||||
- [ ] „Pay Button" Generator (HTML-Snippet zum Einbetten)
|
||||
|
||||
---
|
||||
|
||||
## Warum das zur Monero-Philosophie passt
|
||||
|
||||
- **Zero Trust:** App läuft im Browser, kein Server sieht Daten
|
||||
- **Open Source:** MIT-Lizenz, forkbar, selbst hostbar
|
||||
- **Keine Accounts:** Nichts zu registrieren, nichts zu verlieren
|
||||
- **Kein KYC:** Weder Sender noch Empfänger müssen sich ausweisen
|
||||
- **Kein Custody:** Coins gehen direkt in die eigene Wallet
|
||||
- **Offline-fähig (v1):** QR-Generator funktioniert ohne Internetverbindung
|
||||
|
||||
---
|
||||
|
||||
## Abgrenzung zu bestehenden Tools
|
||||
|
||||
**Kein Konkurrenz zu BTCPay:** BTCPay ist für Shops mit hohem Volumen.
|
||||
xmrpay.link ist für Einzelpersonen, Freelancer, Aktivisten — alle, die
|
||||
schnell und ohne Overhead eine Zahlung anfragen wollen.
|
||||
|
||||
**Kein Konkurrenz zu Wallets:** Wallets bleiben die primäre Lösung
|
||||
für den täglichen Gebrauch. xmrpay.link ergänzt mit Teil- und Präsentationslogik
|
||||
(PDF, Link, Timer), die Wallets nicht bieten.
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitshinweise (für Nutzer)
|
||||
|
||||
- Der **Spend-Key verlässt nie den Browser** — nur View-Key wird für Monitoring verwendet
|
||||
- Remote Node sieht nur: "Wurde an diese Adresse gezahlt?" — keine Wallet-Zuordnung
|
||||
- Für maximale Privatsphäre: eigenen Node via Tor verbinden (konfigurierbar)
|
||||
- LocalStorage-Daten bleiben lokal — nichts wird übertragen
|
||||
|
||||
---
|
||||
|
||||
## Mögliche Domain-Namen
|
||||
|
||||
| Domain | Begründung |
|
||||
|---|---|
|
||||
| `xmrpay.link` | ⭐ Kurz, klar, `monero:` URI passt dazu, TLD `.link` passt perfekt |
|
||||
| `xmr.invoice` | Elegant, aber `.invoice` TLD existiert nicht |
|
||||
| `payxmr.dev` | Developer-affin, gut für GitHub-Kontext |
|
||||
| `xmrbill.com` | Einprägsam, beschreibend |
|
||||
| `monero.page` | Sauber, aber evtl. zu generisch |
|
||||
| `xmrlink.io` | Klar, crypto-affine TLD |
|
||||
|
||||
**Empfehlung: `xmrpay.link`** — weil der Name sofort sagt was die App tut,
|
||||
`.link` auf den Share-Gedanken einzahlt, und der Name kurz genug ist
|
||||
um ihn mündlich weiterzugeben.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur (geplant)
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
xmrpay.link/
|
||||
├── index.html # Single-Page-App Entry Point
|
||||
├── app.js # Haupt-Logik (URI-Builder, QR, Fiat-Kurs)
|
||||
├── monitor.js # View-Key Monitoring (v2)
|
||||
├── invoice.js # PDF-Generierung (v3)
|
||||
├── style.css
|
||||
├── index.html # Single-page app
|
||||
├── app.js / app.min.js # Main logic (URI builder, QR, fiat rates, TX proof)
|
||||
├── i18n.js / i18n.min.js # Internationalization (DE, EN)
|
||||
├── style.css # Dark theme, responsive, WCAG AA
|
||||
├── sw.js # Service Worker (offline support)
|
||||
├── favicon.svg # Monero coin logo
|
||||
├── s.php # Short URL redirect
|
||||
├── api/
|
||||
│ ├── shorten.php # Short URL creation
|
||||
│ ├── rates.php # CoinGecko proxy with server-side cache
|
||||
│ ├── node.php # Monero RPC proxy (4-node failover)
|
||||
│ └── verify.php # TX proof storage/retrieval
|
||||
├── data/ # JSON storage (auto-generated)
|
||||
├── fonts/ # Self-hosted Inter + JetBrains Mono
|
||||
├── lib/
|
||||
│ ├── qrcode.min.js
|
||||
│ ├── monero.js # monero-javascript WASM build
|
||||
│ └── jspdf.min.js
|
||||
│ ├── qrcode.min.js # QR code generator
|
||||
│ ├── jspdf.min.js # PDF generation (lazy-loaded)
|
||||
│ └── xmr-crypto.bundle.js # Ed25519 + Keccak-256 (lazy-loaded)
|
||||
├── README.md
|
||||
└── LICENSE # MIT
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Beitragen / Entwickeln
|
||||
## Invoice Lifecycle
|
||||
|
||||
**Optional Deadline:** When creating an invoice, you can set an expiration deadline (7/14/30 days or custom).
|
||||
|
||||
**Lazy Cleanup:** When a deadline is enabled, the short URL and payment proof are automatically deleted if accessed after expiration:
|
||||
- Accessing an expired short URL returns **HTTP 410 Gone** and removes the entry
|
||||
- Retrieving a proof for an expired invoice returns `verified: false` and cleans up the entry
|
||||
- No background jobs or cron tasks required
|
||||
|
||||
**No deadline?** Invoices without a deadline persist indefinitely (no auto-cleanup).
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DEIN-USERNAME/xmrpay.link
|
||||
git clone https://gitea.schmidt.eco/schmidt1024/xmrpay.link.git
|
||||
cd xmrpay.link
|
||||
# Keine Build-Tools nötig für v1 — einfach index.html im Browser öffnen
|
||||
# Für v2+: kleines Dev-Server-Setup empfohlen (z.B. `npx serve .`)
|
||||
# Serve with any web server that supports PHP
|
||||
# No build tools, no npm, no database required
|
||||
python3 -m http.server 8080 # For development (no PHP features)
|
||||
```
|
||||
|
||||
Pull Requests willkommen. Issues auf GitHub. Kein Discord, kein Slack —
|
||||
das Repo ist die Kommunikation.
|
||||
Requirements for full functionality:
|
||||
- PHP 8.x with `curl` extension
|
||||
- Nginx or Apache (for `/s/` short URL rewrites)
|
||||
- Writable `data/` directory
|
||||
|
||||
### Production Deploy (Safe)
|
||||
|
||||
Use the provided deploy script to avoid deleting runtime files in `data/`:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This script deploys with `rsync --delete` but explicitly excludes `data/`.
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
## Security
|
||||
|
||||
- **No private keys** — TX proof uses the sender's TX key, not the receiver's view key
|
||||
- **Client-side crypto** — Ed25519 verification runs in the browser
|
||||
- **No tracking** — zero cookies, no analytics, no external scripts
|
||||
- **RPC proxy** — allowlisted methods only, rate-limited
|
||||
- **Self-hostable** — run your own instance for full control
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Embeddable `<iframe>` payment widget
|
||||
- [ ] Invoice history (LocalStorage, CSV export)
|
||||
- [ ] "Pay Button" generator (HTML snippet)
|
||||
- [x] Auto-cleanup: Lazy-delete invoices after deadline expires
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — fork it, host it, improve it.
|
||||
|
||||
---
|
||||
|
||||
*Gebaut mit ❤️ für die Monero-Community. Inspiriert von [monero.eco](https://monero.eco).*
|
||||
|
||||
94
api/_helpers.php
Normal file
94
api/_helpers.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared security helpers for xmrpay.link API
|
||||
*/
|
||||
|
||||
// ── Security headers ──────────────────────────────────────────────────────────
|
||||
function send_security_headers(): void {
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
||||
}
|
||||
|
||||
// ── Origin verification ───────────────────────────────────────────────────────
|
||||
function verify_origin(): void {
|
||||
$allowed = [
|
||||
'https://xmrpay.link',
|
||||
'http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion',
|
||||
];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
// Allow same-origin (no Origin header from direct same-origin requests)
|
||||
if ($origin === '') return;
|
||||
if (!in_array($origin, $allowed, true)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Origin not allowed']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC secret ───────────────────────────────────────────────────────────────
|
||||
// Auto-generated on first run, stored outside webroot in data/secret.key
|
||||
function get_hmac_secret(): string {
|
||||
$secretFile = __DIR__ . '/../data/secret.key';
|
||||
if (file_exists($secretFile)) {
|
||||
$raw = file_get_contents($secretFile);
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
return trim($raw);
|
||||
}
|
||||
}
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
$dir = dirname($secretFile);
|
||||
if (!is_dir($dir)) mkdir($dir, 0750, true);
|
||||
file_put_contents($secretFile, $secret, LOCK_EX);
|
||||
chmod($secretFile, 0600);
|
||||
return $secret;
|
||||
}
|
||||
|
||||
// ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
// Returns false when limit exceeded, true otherwise
|
||||
function check_rate_limit(string $action, int $limit, int $window_seconds): bool {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$rateDir = __DIR__ . '/../data/rate/';
|
||||
if (!is_dir($rateDir)) @mkdir($rateDir, 0755, true);
|
||||
$rateFile = $rateDir . $action . '_' . md5($ip) . '.json';
|
||||
$now = time();
|
||||
$times = [];
|
||||
if (file_exists($rateFile)) {
|
||||
$raw = file_get_contents($rateFile);
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : [];
|
||||
$times = is_array($decoded) ? $decoded : [];
|
||||
$times = array_values(array_filter($times, fn($t) => is_numeric($t) && (int)$t > $now - $window_seconds));
|
||||
}
|
||||
if (count($times) >= $limit) return false;
|
||||
$times[] = $now;
|
||||
file_put_contents($rateFile, json_encode($times), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Atomic JSON read/write ────────────────────────────────────────────────────
|
||||
// Returns [file_handle, data_array] — caller must call write_json_locked() to finish
|
||||
function read_json_locked(string $file): array {
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) mkdir($dir, 0750, true);
|
||||
$fp = fopen($file, 'c+');
|
||||
if ($fp === false) {
|
||||
throw new RuntimeException('Unable to open file: ' . $file);
|
||||
}
|
||||
flock($fp, LOCK_EX);
|
||||
$size = filesize($file);
|
||||
$size = is_int($size) ? $size : 0;
|
||||
$raw = $size > 0 ? fread($fp, $size) : '';
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : [];
|
||||
$data = is_array($decoded) ? $decoded : [];
|
||||
return [$fp, $data];
|
||||
}
|
||||
|
||||
function write_json_locked($fp, array $data): void {
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
58
api/check-short.php
Normal file
58
api/check-short.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
/**
|
||||
* Short URL Integrity Verification API
|
||||
* GET: Return the hash and HMAC signature for client-side verification.
|
||||
*
|
||||
* Security: Allows client-side detection of server-side tampering.
|
||||
* The HMAC secret is stored in data/secret.key (auto-generated on first run).
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
send_security_headers();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
|
||||
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid code']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dbFile = __DIR__ . '/../data/urls.json';
|
||||
if (!file_exists($dbFile)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Invoice not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawUrls = file_get_contents($dbFile);
|
||||
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
|
||||
$urls = is_array($decodedUrls) ? $decodedUrls : [];
|
||||
if (!isset($urls[$code])) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Invoice not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = $urls[$code];
|
||||
$hash = is_array($data) ? ($data['h'] ?? '') : $data;
|
||||
$hash = is_string($hash) ? $hash : '';
|
||||
$signature = is_array($data) ? $data['s'] : null;
|
||||
$expiryTs = is_array($data) ? intval($data['e'] ?? 0) : 0;
|
||||
|
||||
// Re-derive expected signature so client can verify
|
||||
$expected = $signature ? hash_hmac('sha256', $hash, get_hmac_secret()) : null;
|
||||
|
||||
echo json_encode([
|
||||
'code' => $code,
|
||||
'hash' => $hash,
|
||||
'signature' => $expected,
|
||||
'expiry_ts' => $expiryTs > 0 ? $expiryTs : null
|
||||
]);
|
||||
145
api/node.php
Normal file
145
api/node.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
/**
|
||||
* Monero Daemon RPC Proxy
|
||||
* Forwards allowed RPC requests to Monero nodes, bypassing CORS.
|
||||
* The private view key NEVER passes through this proxy.
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
send_security_headers();
|
||||
verify_origin();
|
||||
|
||||
// Only POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => '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)) {
|
||||
$rawRate = file_get_contents($rateFile);
|
||||
$decodedRate = is_string($rawRate) ? json_decode($rawRate, true) : [];
|
||||
$rateData = is_array($decodedRate) ? $decodedRate : [];
|
||||
// Clean old entries
|
||||
$rateData = array_values(array_filter($rateData, fn($t) => is_numeric($t) && (int)$t > $now - 60));
|
||||
}
|
||||
|
||||
if (count($rateData) >= 60) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rateData[] = $now;
|
||||
file_put_contents($rateFile, json_encode($rateData));
|
||||
|
||||
// Parse request
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
|
||||
if (!is_array($input) || !isset($input['method']) || !is_string($input['method'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$method = $input['method'];
|
||||
$params = isset($input['params']) && is_array($input['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)) {
|
||||
$rawCache = file_get_contents($cacheFile);
|
||||
$cache = is_string($rawCache) ? json_decode($rawCache, true) : null;
|
||||
if (is_array($cache) && ($cache['time'] ?? 0) > $now - 300 && isset($cache['node']) && is_string($cache['node'])) {
|
||||
$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);
|
||||
if ($ch === false) {
|
||||
$lastError = 'cURL init failed';
|
||||
continue;
|
||||
}
|
||||
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]);
|
||||
63
api/rates.php
Normal file
63
api/rates.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Server-side cache: fetch from CoinGecko at most once per 2 minutes
|
||||
$cacheFile = __DIR__ . '/../data/rates_cache.json';
|
||||
$cacheTTL = 120; // seconds
|
||||
|
||||
if (file_exists($cacheFile)) {
|
||||
$rawCached = file_get_contents($cacheFile);
|
||||
$cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
|
||||
$cachedTime = is_array($cached) && isset($cached['_time']) && is_numeric($cached['_time']) ? (int)$cached['_time'] : 0;
|
||||
if (is_array($cached) && (time() - $cachedTime) < $cacheTTL) {
|
||||
unset($cached['_time']);
|
||||
header('Cache-Control: public, max-age=60');
|
||||
echo json_encode($cached);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$currencies = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : 'eur,usd,chf,gbp,jpy,rub,brl';
|
||||
$currencies = preg_replace('/[^a-z,]/', '', strtolower($currencies));
|
||||
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=' . $currencies;
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
http_response_code(502);
|
||||
echo json_encode(['error' => 'Failed to initialize request']);
|
||||
exit;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: xmrpay.link/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response !== false && $httpCode === 200) {
|
||||
$data = json_decode($response, true);
|
||||
if (is_array($data)) {
|
||||
$data['_time'] = time();
|
||||
file_put_contents($cacheFile, json_encode($data));
|
||||
unset($data['_time']);
|
||||
header('Cache-Control: public, max-age=60');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// On error, serve stale cache if available
|
||||
if (file_exists($cacheFile)) {
|
||||
$rawCached = file_get_contents($cacheFile);
|
||||
$cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
|
||||
if (is_array($cached)) {
|
||||
unset($cached['_time']);
|
||||
header('Cache-Control: public, max-age=30');
|
||||
echo json_encode($cached);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
http_response_code(502);
|
||||
echo json_encode(['error' => 'Failed to fetch rates']);
|
||||
79
api/shorten.php
Normal file
79
api/shorten.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
send_security_headers();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
verify_origin();
|
||||
|
||||
if (!check_rate_limit('shorten', 20, 3600)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$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);
|
||||
echo json_encode(['error' => 'Invalid data']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$secret = get_hmac_secret();
|
||||
|
||||
[$fp, $urls] = read_json_locked($dbFile);
|
||||
if (!is_array($urls)) {
|
||||
$urls = [];
|
||||
}
|
||||
|
||||
// Check if this hash already exists
|
||||
foreach ($urls as $code => $data) {
|
||||
$stored_hash = is_array($data) ? ($data['h'] ?? null) : $data;
|
||||
if (!is_string($stored_hash)) {
|
||||
continue;
|
||||
}
|
||||
if ($stored_hash === $hash) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
echo json_encode(['code' => $code]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate short code (6 chars)
|
||||
function generateCode(int $length = 6): string {
|
||||
$chars = 'abcdefghijkmnpqrstuvwxyz23456789';
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
$code = generateCode();
|
||||
while (isset($urls[$code])) {
|
||||
$code = generateCode();
|
||||
}
|
||||
|
||||
$signature = hash_hmac('sha256', $hash, $secret);
|
||||
$urls[$code] = ['h' => $hash, 's' => $signature];
|
||||
if ($expiryTs > 0) {
|
||||
$urls[$code]['e'] = $expiryTs;
|
||||
}
|
||||
|
||||
write_json_locked($fp, $urls);
|
||||
|
||||
echo json_encode(['code' => $code]);
|
||||
219
api/verify.php
Normal file
219
api/verify.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
/**
|
||||
* TX Proof Storage API
|
||||
* POST: Store verified payment proof for an invoice
|
||||
* GET: Retrieve payment status for an invoice
|
||||
*
|
||||
* Privacy: Only TX hash, amount, and confirmations are stored.
|
||||
* Payee address is NEVER stored — verification happens client-side only.
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
send_security_headers();
|
||||
|
||||
$dbFile = __DIR__ . '/../data/proofs.json';
|
||||
|
||||
function fetch_transaction_confirmations(string $txHash): ?int {
|
||||
$nodes = [
|
||||
'http://node.xmr.rocks:18089',
|
||||
'http://node.community.rino.io:18081',
|
||||
'http://node.sethforprivacy.com:18089',
|
||||
'http://xmr-node.cakewallet.com:18081',
|
||||
];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$ch = curl_init($node . '/gettransactions');
|
||||
if ($ch === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = json_encode((object)['txs_hashes' => [$txHash]]);
|
||||
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);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded) || !isset($decoded['txs']) || !is_array($decoded['txs']) || !isset($decoded['txs'][0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tx = $decoded['txs'][0];
|
||||
if (!is_array($tx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return intval($tx['confirmations'] ?? 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET: Retrieve proof
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
if (!check_rate_limit('verify_get', 30, 60)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||
exit;
|
||||
}
|
||||
$code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
|
||||
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
|
||||
echo json_encode(['verified' => false]);
|
||||
exit;
|
||||
}
|
||||
$rawProofs = file_exists($dbFile) ? file_get_contents($dbFile) : null;
|
||||
$decodedProofs = is_string($rawProofs) ? json_decode($rawProofs, true) : [];
|
||||
$proofs = is_array($decodedProofs) ? $decodedProofs : [];
|
||||
if (isset($proofs[$code])) {
|
||||
$proofEntry = $proofs[$code];
|
||||
$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);
|
||||
/** @var array<string, mixed> $allProofs */
|
||||
if (isset($allProofs[$code])) {
|
||||
unset($allProofs[$code]);
|
||||
write_json_locked($fp, $allProofs);
|
||||
}
|
||||
echo json_encode(['verified' => false]);
|
||||
} else {
|
||||
if (is_array($proofEntry) && ($proofEntry['status'] ?? 'paid') === 'pending' && isset($proofEntry['tx_hash']) && is_string($proofEntry['tx_hash'])) {
|
||||
$latestConfirmations = fetch_transaction_confirmations($proofEntry['tx_hash']);
|
||||
if ($latestConfirmations !== null && $latestConfirmations > intval($proofEntry['confirmations'] ?? 0)) {
|
||||
$proofEntry['confirmations'] = $latestConfirmations;
|
||||
if ($latestConfirmations >= 10) {
|
||||
$proofEntry['status'] = 'paid';
|
||||
}
|
||||
|
||||
[$fp, $allProofs] = read_json_locked($dbFile);
|
||||
/** @var array<string, mixed> $allProofs */
|
||||
$allProofs[$code] = $proofEntry;
|
||||
write_json_locked($fp, $allProofs);
|
||||
}
|
||||
}
|
||||
|
||||
$response = ['verified' => true];
|
||||
if (is_array($proofEntry)) {
|
||||
foreach ($proofEntry as $k => $v) {
|
||||
if (is_string($k)) {
|
||||
$response[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode($response);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['verified' => false]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST: Store proof
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
verify_origin();
|
||||
|
||||
if (!check_rate_limit('verify_post', 10, 3600)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
|
||||
if (!is_array($input)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid JSON']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$code = isset($input['code']) && is_string($input['code']) ? $input['code'] : '';
|
||||
$txHash = isset($input['tx_hash']) && is_string($input['tx_hash']) ? $input['tx_hash'] : '';
|
||||
$amount = floatval($input['amount'] ?? 0);
|
||||
$confirmations = intval($input['confirmations'] ?? 0);
|
||||
|
||||
// Validate
|
||||
if (!preg_match('/^[a-z0-9]{4,10}$/', $code)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid code']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid tx_hash']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify the short URL code exists (read-only, no lock needed here)
|
||||
$urlsFile = __DIR__ . '/../data/urls.json';
|
||||
if (!file_exists($urlsFile)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Invoice not found']);
|
||||
exit;
|
||||
}
|
||||
$rawUrls = file_get_contents($urlsFile);
|
||||
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
|
||||
$urls = is_array($decodedUrls) ? $decodedUrls : [];
|
||||
if (!isset($urls[$code])) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Invoice not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Store proof with atomic lock
|
||||
[$fp, $proofs] = read_json_locked($dbFile);
|
||||
if (!is_array($proofs)) {
|
||||
$proofs = [];
|
||||
}
|
||||
|
||||
$status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid';
|
||||
|
||||
// Allow overwriting a pending proof with more confirmations or a final paid status
|
||||
if (isset($proofs[$code])) {
|
||||
$existing = $proofs[$code];
|
||||
$canOverwrite = ($existing['status'] ?? 'paid') === 'pending'
|
||||
&& ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0));
|
||||
if (!$canOverwrite) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$proofs[$code] = [
|
||||
'tx_hash' => strtolower($txHash),
|
||||
'amount' => $amount,
|
||||
'confirmations' => $confirmations,
|
||||
'status' => $status,
|
||||
'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]);
|
||||
|
||||
1
app.min.js
vendored
Normal file
1
app.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
18
branding/logo-doc-coin-monero.svg
Normal file
18
branding/logo-doc-coin-monero.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="xmrpay.link document with Monero coin concept">
|
||||
<g transform="translate(-13.8 -13.4) scale(1.21)">
|
||||
<path d="M28 18h44l18 18v60a10 10 0 0 1-10 10H28a10 10 0 0 1-10-10V28a10 10 0 0 1 10-10z" fill="#ffffff"/>
|
||||
<path d="M72 18v18h18" fill="#e5e7eb"/>
|
||||
<g fill="none" stroke="#111827" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M28 18h44l18 18v60a10 10 0 0 1-10 10H28a10 10 0 0 1-10-10V28a10 10 0 0 1 10-10z" stroke-width="4"/>
|
||||
<path d="M72 18v18h18" stroke-width="4"/>
|
||||
<rect x="30" y="42" width="36" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
<rect x="30" y="54" width="28" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
<rect x="30" y="66" width="22" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
</g>
|
||||
<circle cx="84" cy="86" r="24" fill="#f26821"/>
|
||||
<circle cx="84" cy="86" r="24" fill="none" stroke="#111827" stroke-width="4"/>
|
||||
<g transform="translate(57.3 59.3) scale(0.175)">
|
||||
<path d="m 270.75,190.58 h -0.72 -37.14 v -104.12 l -80.69,80.69 -80.69,-80.69 v 104.12 h -37.14 -0.72 a 124.61,124.61 0 0 0 12.42,26.92 h 52.36 v -66.05 l 53.77,53.77 53.77,-53.77 v 66.05 h 52.36 a 124.61,124.61 0 0 0 12.42,-26.92 z" fill="#ffffff"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
18
favicon.svg
Normal file
18
favicon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="xmrpay.link document with Monero coin concept">
|
||||
<g transform="translate(-13.8 -13.4) scale(1.21)">
|
||||
<path d="M28 18h44l18 18v60a10 10 0 0 1-10 10H28a10 10 0 0 1-10-10V28a10 10 0 0 1 10-10z" fill="#ffffff"/>
|
||||
<path d="M72 18v18h18" fill="#e5e7eb"/>
|
||||
<g fill="none" stroke="#111827" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M28 18h44l18 18v60a10 10 0 0 1-10 10H28a10 10 0 0 1-10-10V28a10 10 0 0 1 10-10z" stroke-width="4"/>
|
||||
<path d="M72 18v18h18" stroke-width="4"/>
|
||||
<rect x="30" y="42" width="36" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
<rect x="30" y="54" width="28" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
<rect x="30" y="66" width="22" height="4" rx="2" fill="#111827" stroke="none"/>
|
||||
</g>
|
||||
<circle cx="84" cy="86" r="24" fill="#f26821"/>
|
||||
<circle cx="84" cy="86" r="24" fill="none" stroke="#111827" stroke-width="4"/>
|
||||
<g transform="translate(57.3 59.3) scale(0.175)">
|
||||
<path d="m 270.75,190.58 h -0.72 -37.14 v -104.12 l -80.69,80.69 -80.69,-80.69 v 104.12 h -37.14 -0.72 a 124.61,124.61 0 0 0 12.42,26.92 h 52.36 v -66.05 l 53.77,53.77 53.77,-53.77 v 66.05 h 52.36 a 124.61,124.61 0 0 0 12.42,-26.92 z" fill="#ffffff"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
fonts/inter-400.woff2
Normal file
BIN
fonts/inter-400.woff2
Normal file
Binary file not shown.
BIN
fonts/inter-cyrillic.woff2
Normal file
BIN
fonts/inter-cyrillic.woff2
Normal file
Binary file not shown.
BIN
fonts/jetbrains-400.woff2
Normal file
BIN
fonts/jetbrains-400.woff2
Normal file
Binary file not shown.
476
i18n.js
Normal file
476
i18n.js
Normal file
@@ -0,0 +1,476 @@
|
||||
var I18n = (function () {
|
||||
'use strict';
|
||||
|
||||
var languages = {
|
||||
en: { name: 'English' },
|
||||
de: { name: 'Deutsch' },
|
||||
fr: { name: 'Français' },
|
||||
it: { name: 'Italiano' },
|
||||
es: { name: 'Español' },
|
||||
pt: { name: 'Português' },
|
||||
ru: { name: 'Русский' }
|
||||
};
|
||||
|
||||
var footer = 'Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a>';
|
||||
|
||||
var translations = {
|
||||
en: {
|
||||
subtitle: 'Monero payment request in seconds',
|
||||
label_addr: 'XMR Address',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Amount',
|
||||
label_desc: 'Description (optional)',
|
||||
placeholder_desc: 'e.g. Invoice #42, freelance work...',
|
||||
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',
|
||||
btn_download_pdf: 'PDF Invoice',
|
||||
pdf_title: 'Payment Request',
|
||||
pdf_address: 'XMR Address',
|
||||
pdf_amount: 'Amount',
|
||||
pdf_desc: 'Description',
|
||||
pdf_deadline: 'Payment deadline',
|
||||
pdf_deadline_days: '{d} days',
|
||||
pdf_date: 'Date',
|
||||
pdf_scan_qr: 'Scan QR code to pay',
|
||||
pdf_footer: 'Created with xmrpay.link',
|
||||
qr_hint: 'Click QR to save',
|
||||
footer: footer,
|
||||
aria_currency: 'Currency',
|
||||
label_share_link: 'Shareable link',
|
||||
btn_new_request: 'New payment request',
|
||||
toast_copied: 'Copied!',
|
||||
countdown_expired: 'Payment deadline expired',
|
||||
countdown_remaining_days: 'Deadline: {d} days, {h} hrs',
|
||||
countdown_remaining_hours: 'Deadline: {h}:{m} hrs',
|
||||
rates_offline: 'Rates unavailable — XMR amount only',
|
||||
btn_prove_payment: 'Prove payment',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 hex characters...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 hex characters...',
|
||||
btn_verify_proof: 'Verify payment',
|
||||
proof_verifying: 'Verifying...',
|
||||
proof_verified: 'Payment confirmed: {amount} XMR',
|
||||
proof_no_match: 'No matching output — TX key or address mismatch',
|
||||
proof_tx_not_found: 'Transaction not found',
|
||||
proof_error: 'Verification error',
|
||||
status_paid: 'Paid',
|
||||
status_pending: 'Pending',
|
||||
proof_confirmed_pending: 'Output found: {amount} XMR — {n}/10 confirmations. Auto-refreshing…',
|
||||
toast_integrity_warning: 'Warning: signature mismatch detected'
|
||||
},
|
||||
de: {
|
||||
subtitle: 'Monero-Zahlungsanforderung in Sekunden',
|
||||
label_addr: 'XMR-Adresse',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Betrag',
|
||||
label_desc: 'Beschreibung (optional)',
|
||||
placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...',
|
||||
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',
|
||||
btn_download_pdf: 'PDF Rechnung',
|
||||
pdf_title: 'Zahlungsanforderung',
|
||||
pdf_address: 'XMR-Adresse',
|
||||
pdf_amount: 'Betrag',
|
||||
pdf_desc: 'Beschreibung',
|
||||
pdf_deadline: 'Zahlungsfrist',
|
||||
pdf_deadline_days: '{d} Tage',
|
||||
pdf_date: 'Datum',
|
||||
pdf_scan_qr: 'QR-Code scannen zum Bezahlen',
|
||||
pdf_footer: 'Erstellt mit xmrpay.link',
|
||||
qr_hint: 'Klick auf QR zum Speichern',
|
||||
footer: footer,
|
||||
aria_currency: 'Währung',
|
||||
label_share_link: 'Teilbarer Link',
|
||||
btn_new_request: 'Neue Zahlungsanforderung',
|
||||
toast_copied: 'Kopiert!',
|
||||
countdown_expired: 'Zahlungsfrist abgelaufen',
|
||||
countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h} Std.',
|
||||
countdown_remaining_hours: 'Zahlungsfrist: {h}:{m} Std.',
|
||||
rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich',
|
||||
btn_prove_payment: 'Zahlung nachweisen',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 Hex-Zeichen...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 Hex-Zeichen...',
|
||||
btn_verify_proof: 'Zahlung verifizieren',
|
||||
proof_verifying: 'Verifiziere...',
|
||||
proof_verified: 'Zahlung bestätigt: {amount} XMR',
|
||||
proof_no_match: 'Kein passender Output — TX Key oder Adresse stimmt nicht',
|
||||
proof_tx_not_found: 'Transaktion nicht gefunden',
|
||||
proof_error: 'Fehler bei der Verifizierung',
|
||||
status_paid: 'Bezahlt',
|
||||
status_pending: 'Ausstehend',
|
||||
proof_confirmed_pending: 'Output gefunden: {amount} XMR — {n}/10 Bestätigungen. Wird aktualisiert…',
|
||||
toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt'
|
||||
},
|
||||
fr: {
|
||||
subtitle: 'Demande de paiement Monero en quelques secondes',
|
||||
label_addr: 'Adresse XMR',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Montant',
|
||||
label_desc: 'Description (facultatif)',
|
||||
placeholder_desc: 'ex. Facture #42, travail freelance...',
|
||||
label_timer: 'Date limite de paiement (facultatif)',
|
||||
days: 'jours',
|
||||
placeholder_timer_custom: 'Jours',
|
||||
btn_generate: 'Créer une demande de paiement',
|
||||
btn_open_wallet: 'Ouvrir dans le wallet',
|
||||
btn_copy_addr: 'Copier l\'adresse',
|
||||
btn_download_pdf: 'Facture PDF',
|
||||
pdf_title: 'Demande de paiement',
|
||||
pdf_address: 'Adresse XMR',
|
||||
pdf_amount: 'Montant',
|
||||
pdf_desc: 'Description',
|
||||
pdf_deadline: 'Date limite de paiement',
|
||||
pdf_deadline_days: '{d} jours',
|
||||
pdf_date: 'Date',
|
||||
pdf_scan_qr: 'Scanner le QR code pour payer',
|
||||
pdf_footer: 'Créé avec xmrpay.link',
|
||||
qr_hint: 'Cliquez sur le QR pour enregistrer',
|
||||
footer: footer,
|
||||
aria_currency: 'Devise',
|
||||
label_share_link: 'Lien partageable',
|
||||
btn_new_request: 'Nouvelle demande de paiement',
|
||||
toast_copied: 'Copié !',
|
||||
countdown_expired: 'Délai de paiement expiré',
|
||||
countdown_remaining_days: 'Délai : {d} jours, {h} h',
|
||||
countdown_remaining_hours: 'Délai : {h}:{m} h',
|
||||
rates_offline: 'Taux indisponibles — montant en XMR uniquement',
|
||||
btn_prove_payment: 'Prouver le paiement',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 caractères hexadécimaux...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 caractères hexadécimaux...',
|
||||
btn_verify_proof: 'Vérifier le paiement',
|
||||
proof_verifying: 'Vérification...',
|
||||
proof_verified: 'Paiement confirmé : {amount} XMR',
|
||||
proof_no_match: 'Aucun output correspondant — TX Key ou adresse incorrecte',
|
||||
proof_tx_not_found: 'Transaction introuvable',
|
||||
proof_error: 'Erreur de vérification',
|
||||
status_paid: 'Payé',
|
||||
status_pending: 'En attente',
|
||||
proof_confirmed_pending: 'Sortie trouvée : {amount} XMR — {n}/10 confirmations. Actualisation automatique…',
|
||||
toast_integrity_warning: 'Avertissement : détection d\'une non-concordance de signature'
|
||||
},
|
||||
it: {
|
||||
subtitle: 'Richiesta di pagamento Monero in pochi secondi',
|
||||
label_addr: 'Indirizzo XMR',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Importo',
|
||||
label_desc: 'Descrizione (facoltativo)',
|
||||
placeholder_desc: 'es. Fattura #42, lavoro freelance...',
|
||||
label_timer: 'Scadenza pagamento (facoltativo)',
|
||||
days: 'giorni',
|
||||
placeholder_timer_custom: 'Giorni',
|
||||
btn_generate: 'Crea richiesta di pagamento',
|
||||
btn_open_wallet: 'Apri nel wallet',
|
||||
btn_copy_addr: 'Copia indirizzo',
|
||||
btn_download_pdf: 'Fattura PDF',
|
||||
pdf_title: 'Richiesta di pagamento',
|
||||
pdf_address: 'Indirizzo XMR',
|
||||
pdf_amount: 'Importo',
|
||||
pdf_desc: 'Descrizione',
|
||||
pdf_deadline: 'Scadenza pagamento',
|
||||
pdf_deadline_days: '{d} giorni',
|
||||
pdf_date: 'Data',
|
||||
pdf_scan_qr: 'Scansiona il QR per pagare',
|
||||
pdf_footer: 'Creato con xmrpay.link',
|
||||
qr_hint: 'Clicca sul QR per salvare',
|
||||
footer: footer,
|
||||
aria_currency: 'Valuta',
|
||||
label_share_link: 'Link condivisibile',
|
||||
btn_new_request: 'Nuova richiesta di pagamento',
|
||||
toast_copied: 'Copiato!',
|
||||
countdown_expired: 'Scadenza pagamento superata',
|
||||
countdown_remaining_days: 'Scadenza: {d} giorni, {h} ore',
|
||||
countdown_remaining_hours: 'Scadenza: {h}:{m} ore',
|
||||
rates_offline: 'Tassi non disponibili — solo importo in XMR',
|
||||
btn_prove_payment: 'Dimostra pagamento',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 caratteri esadecimali...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 caratteri esadecimali...',
|
||||
btn_verify_proof: 'Verifica pagamento',
|
||||
proof_verifying: 'Verifica in corso...',
|
||||
proof_verified: 'Pagamento confermato: {amount} XMR',
|
||||
proof_no_match: 'Nessun output corrispondente — TX Key o indirizzo errato',
|
||||
proof_tx_not_found: 'Transazione non trovata',
|
||||
proof_error: 'Errore di verifica',
|
||||
status_paid: 'Pagato',
|
||||
status_pending: 'In attesa',
|
||||
proof_confirmed_pending: 'Output trovato: {amount} XMR — {n}/10 conferme. Aggiornamento automatico…',
|
||||
toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma'
|
||||
},
|
||||
es: {
|
||||
subtitle: 'Solicitud de pago Monero en segundos',
|
||||
label_addr: 'Dirección XMR',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Monto',
|
||||
label_desc: 'Descripción (opcional)',
|
||||
placeholder_desc: 'ej. Factura #42, trabajo freelance...',
|
||||
label_timer: 'Plazo de pago (opcional)',
|
||||
days: 'días',
|
||||
placeholder_timer_custom: 'Días',
|
||||
btn_generate: 'Crear solicitud de pago',
|
||||
btn_open_wallet: 'Abrir en wallet',
|
||||
btn_copy_addr: 'Copiar dirección',
|
||||
btn_download_pdf: 'Factura PDF',
|
||||
pdf_title: 'Solicitud de pago',
|
||||
pdf_address: 'Dirección XMR',
|
||||
pdf_amount: 'Monto',
|
||||
pdf_desc: 'Descripción',
|
||||
pdf_deadline: 'Plazo de pago',
|
||||
pdf_deadline_days: '{d} días',
|
||||
pdf_date: 'Fecha',
|
||||
pdf_scan_qr: 'Escanear QR para pagar',
|
||||
pdf_footer: 'Creado con xmrpay.link',
|
||||
qr_hint: 'Clic en QR para guardar',
|
||||
footer: footer,
|
||||
aria_currency: 'Moneda',
|
||||
label_share_link: 'Enlace compartible',
|
||||
btn_new_request: 'Nueva solicitud de pago',
|
||||
toast_copied: '¡Copiado!',
|
||||
countdown_expired: 'Plazo de pago vencido',
|
||||
countdown_remaining_days: 'Plazo: {d} días, {h} h',
|
||||
countdown_remaining_hours: 'Plazo: {h}:{m} h',
|
||||
rates_offline: 'Tasas no disponibles — solo monto en XMR',
|
||||
btn_prove_payment: 'Demostrar pago',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 caracteres hexadecimales...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 caracteres hexadecimales...',
|
||||
btn_verify_proof: 'Verificar pago',
|
||||
proof_verifying: 'Verificando...',
|
||||
proof_verified: 'Pago confirmado: {amount} XMR',
|
||||
proof_no_match: 'Ningún output coincidente — TX Key o dirección incorrecta',
|
||||
proof_tx_not_found: 'Transacción no encontrada',
|
||||
proof_error: 'Error de verificación',
|
||||
status_paid: 'Pagado',
|
||||
status_pending: 'Pendiente',
|
||||
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmaciones. Actualización automática…',
|
||||
toast_integrity_warning: 'Advertencia: desajuste de firma detectado'
|
||||
},
|
||||
pt: {
|
||||
subtitle: 'Pedido de pagamento Monero em segundos',
|
||||
label_addr: 'Endereço XMR',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Valor',
|
||||
label_desc: 'Descrição (opcional)',
|
||||
placeholder_desc: 'ex. Fatura #42, trabalho freelance...',
|
||||
label_timer: 'Prazo de pagamento (opcional)',
|
||||
days: 'dias',
|
||||
placeholder_timer_custom: 'Dias',
|
||||
btn_generate: 'Criar pedido de pagamento',
|
||||
btn_open_wallet: 'Abrir na wallet',
|
||||
btn_copy_addr: 'Copiar endereço',
|
||||
btn_download_pdf: 'Fatura PDF',
|
||||
pdf_title: 'Pedido de pagamento',
|
||||
pdf_address: 'Endereço XMR',
|
||||
pdf_amount: 'Valor',
|
||||
pdf_desc: 'Descrição',
|
||||
pdf_deadline: 'Prazo de pagamento',
|
||||
pdf_deadline_days: '{d} dias',
|
||||
pdf_date: 'Data',
|
||||
pdf_scan_qr: 'Digitalizar QR para pagar',
|
||||
pdf_footer: 'Criado com xmrpay.link',
|
||||
qr_hint: 'Clique no QR para guardar',
|
||||
footer: footer,
|
||||
aria_currency: 'Moeda',
|
||||
label_share_link: 'Link partilhável',
|
||||
btn_new_request: 'Novo pedido de pagamento',
|
||||
toast_copied: 'Copiado!',
|
||||
countdown_expired: 'Prazo de pagamento expirado',
|
||||
countdown_remaining_days: 'Prazo: {d} dias, {h} h',
|
||||
countdown_remaining_hours: 'Prazo: {h}:{m} h',
|
||||
rates_offline: 'Taxas indisponíveis — apenas valor em XMR',
|
||||
btn_prove_payment: 'Comprovar pagamento',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 caracteres hexadecimais...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 caracteres hexadecimais...',
|
||||
btn_verify_proof: 'Verificar pagamento',
|
||||
proof_verifying: 'A verificar...',
|
||||
proof_verified: 'Pagamento confirmado: {amount} XMR',
|
||||
proof_no_match: 'Nenhum output correspondente — TX Key ou endereço incorreto',
|
||||
proof_tx_not_found: 'Transação não encontrada',
|
||||
proof_error: 'Erro de verificação',
|
||||
status_paid: 'Pago',
|
||||
status_pending: 'Pendente',
|
||||
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmações. Atualização automática…',
|
||||
toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada'
|
||||
},
|
||||
ru: {
|
||||
subtitle: 'Запрос на оплату Monero за секунды',
|
||||
label_addr: 'Адрес XMR',
|
||||
placeholder_addr: '8...',
|
||||
label_amount: 'Сумма',
|
||||
label_desc: 'Описание (необязательно)',
|
||||
placeholder_desc: 'напр. Счёт #42, фриланс...',
|
||||
label_timer: 'Срок оплаты (необязательно)',
|
||||
days: 'дней',
|
||||
placeholder_timer_custom: 'Дней',
|
||||
btn_generate: 'Создать запрос на оплату',
|
||||
btn_open_wallet: 'Открыть в кошельке',
|
||||
btn_copy_addr: 'Копировать адрес',
|
||||
btn_download_pdf: 'PDF счёт',
|
||||
pdf_title: 'Запрос на оплату',
|
||||
pdf_address: 'Адрес XMR',
|
||||
pdf_amount: 'Сумма',
|
||||
pdf_desc: 'Описание',
|
||||
pdf_deadline: 'Срок оплаты',
|
||||
pdf_deadline_days: '{d} дней',
|
||||
pdf_date: 'Дата',
|
||||
pdf_scan_qr: 'Сканируйте QR для оплаты',
|
||||
pdf_footer: 'Создано с помощью xmrpay.link',
|
||||
qr_hint: 'Нажмите на QR для сохранения',
|
||||
footer: footer,
|
||||
aria_currency: 'Валюта',
|
||||
label_share_link: 'Ссылка для отправки',
|
||||
btn_new_request: 'Новый запрос на оплату',
|
||||
toast_copied: 'Скопировано!',
|
||||
countdown_expired: 'Срок оплаты истёк',
|
||||
countdown_remaining_days: 'Срок: {d} дней, {h} ч',
|
||||
countdown_remaining_hours: 'Срок: {h}:{m} ч',
|
||||
rates_offline: 'Курсы недоступны — только сумма в XMR',
|
||||
btn_prove_payment: 'Подтвердить оплату',
|
||||
label_tx_hash: 'Transaction ID (TX Hash)',
|
||||
placeholder_tx_hash: '64 шестнадцатеричных символа...',
|
||||
label_tx_key: 'Transaction Key (TX Key)',
|
||||
placeholder_tx_key: '64 шестнадцатеричных символа...',
|
||||
btn_verify_proof: 'Проверить оплату',
|
||||
proof_verifying: 'Проверка...',
|
||||
proof_verified: 'Оплата подтверждена: {amount} XMR',
|
||||
proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес',
|
||||
proof_tx_not_found: 'Транзакция не найдена',
|
||||
proof_error: 'Ошибка проверки',
|
||||
status_paid: 'Оплачено',
|
||||
status_pending: 'Ожидание',
|
||||
proof_confirmed_pending: 'Выход найден: {amount} XMR — {n}/10 подтверждений. Авт. обновление…',
|
||||
toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи'
|
||||
}
|
||||
};
|
||||
|
||||
var currentLang = 'en';
|
||||
|
||||
function detectLang() {
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('xmrpay_lang'); } catch (e) {}
|
||||
if (saved && translations[saved]) return saved;
|
||||
|
||||
var navLangs = navigator.languages || [navigator.language || 'en'];
|
||||
for (var i = 0; i < navLangs.length; i++) {
|
||||
var code = navLangs[i].substring(0, 2).toLowerCase();
|
||||
if (translations[code]) return code;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function applyDOM(t) {
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
el.textContent = t[el.getAttribute('data-i18n')] || '';
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||
el.placeholder = t[el.getAttribute('data-i18n-placeholder')] || '';
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
|
||||
el.innerHTML = t[el.getAttribute('data-i18n-html')] || '';
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) {
|
||||
el.setAttribute('aria-label', t[el.getAttribute('data-i18n-aria')] || '');
|
||||
});
|
||||
}
|
||||
|
||||
function apply(lang) {
|
||||
currentLang = lang;
|
||||
var t = translations[lang];
|
||||
document.documentElement.lang = lang;
|
||||
try { localStorage.setItem('xmrpay_lang', lang); } catch (e) {}
|
||||
|
||||
applyDOM(t);
|
||||
|
||||
// Update dropdown active state
|
||||
document.querySelectorAll('.lang-option').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||
});
|
||||
|
||||
// Notify listeners
|
||||
for (var i = 0; i < onChangeCallbacks.length; i++) {
|
||||
onChangeCallbacks[i](lang);
|
||||
}
|
||||
}
|
||||
|
||||
var onChangeCallbacks = [];
|
||||
function onChange(fn) {
|
||||
onChangeCallbacks.push(fn);
|
||||
}
|
||||
|
||||
function buildDropdown() {
|
||||
var dropdown = document.getElementById('langDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
var keys = Object.keys(languages);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var code = keys[i];
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'lang-option';
|
||||
btn.setAttribute('data-lang', code);
|
||||
btn.textContent = languages[code].name;
|
||||
if (code === currentLang) btn.classList.add('active');
|
||||
btn.addEventListener('click', (function (c) {
|
||||
return function () {
|
||||
apply(c);
|
||||
closePicker();
|
||||
};
|
||||
})(code));
|
||||
dropdown.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
var picker = document.getElementById('langPicker');
|
||||
if (picker) picker.classList.remove('open');
|
||||
}
|
||||
|
||||
function initPicker() {
|
||||
var picker = document.getElementById('langPicker');
|
||||
var toggle = document.getElementById('langToggle');
|
||||
if (!picker || !toggle) return;
|
||||
|
||||
toggle.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
picker.classList.toggle('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!picker.contains(e.target)) closePicker();
|
||||
});
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
return (translations[currentLang] && translations[currentLang][key]) || key;
|
||||
}
|
||||
|
||||
function getLang() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
// Init
|
||||
currentLang = detectLang();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
buildDropdown();
|
||||
initPicker();
|
||||
apply(currentLang);
|
||||
});
|
||||
|
||||
return { t: t, apply: apply, getLang: getLang, onChange: onChange };
|
||||
})();
|
||||
1
i18n.min.js
vendored
Normal file
1
i18n.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
129
index.html
Normal file
129
index.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>xmrpay.link — Monero Invoice Generator</title>
|
||||
<meta name="description" content="Create Monero payment requests in seconds. No account registration, no KYC. Minimal backend for short URLs only.">
|
||||
<link rel="icon" id="favicon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="preload" href="fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="style.css?v=20260326-3">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
|
||||
<p data-i18n="subtitle">Monero payment request in seconds</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label for="addr" data-i18n="label_addr">XMR Address</label>
|
||||
<input type="text" id="addr" data-i18n-placeholder="placeholder_addr" placeholder="8..." spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="amount" data-i18n="label_amount">Amount</label>
|
||||
<div class="amount-row">
|
||||
<input type="number" id="amount" placeholder="0.00" min="0" step="any">
|
||||
<select id="currency" data-i18n-aria="aria_currency" aria-label="Currency">
|
||||
<option value="XMR">XMR</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD" selected>USD</option>
|
||||
<option value="CHF">CHF</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="RUB">RUB</option>
|
||||
<option value="BRL">BRL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiat-hint" id="fiatHint"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="desc" data-i18n="label_desc">Description (optional)</label>
|
||||
<textarea id="desc" data-i18n-placeholder="placeholder_desc" placeholder="e.g. Invoice #42, freelance work..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label data-i18n="label_timer">Payment deadline (optional)</label>
|
||||
<div class="deadline-badges" id="deadlineBadges">
|
||||
<button type="button" class="badge" data-days="7">7 <span data-i18n="days">days</span></button>
|
||||
<button type="button" class="badge" data-days="14">14 <span data-i18n="days">days</span></button>
|
||||
<button type="button" class="badge" data-days="30">30 <span data-i18n="days">days</span></button>
|
||||
<input type="number" id="timerCustom" data-i18n-placeholder="placeholder_timer_custom" placeholder="Days" min="1" step="1" class="badge-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="generate" disabled data-i18n="btn_generate">Create payment request</button>
|
||||
</div>
|
||||
|
||||
<div id="result" class="card">
|
||||
<div class="qr-container" id="qr"></div>
|
||||
<div class="payment-summary" id="paymentSummary"></div>
|
||||
<div class="payment-status" id="paymentStatus"></div>
|
||||
<div class="countdown" id="countdown"></div>
|
||||
<div class="uri-box" id="uri" style="display:none"></div>
|
||||
<div class="share-link-box" id="shareLinkBox">
|
||||
<label data-i18n="label_share_link">Shareable link</label>
|
||||
<div class="share-link-row">
|
||||
<input type="text" id="shareLink" readonly data-i18n-aria="label_share_link">
|
||||
<button class="btn btn-secondary btn-icon" id="copyShareLink" title="Copy">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" id="openWallet" data-i18n="btn_open_wallet">Open in wallet</button>
|
||||
<button class="btn btn-secondary" id="copyAddr" data-i18n="btn_copy_addr">Copy address</button>
|
||||
<button class="btn btn-secondary" id="downloadPdf" data-i18n="btn_download_pdf">PDF Invoice</button>
|
||||
</div>
|
||||
<!-- TX Proof Verification -->
|
||||
<div class="proof-section" id="proofSection">
|
||||
<button class="btn btn-proof" id="proofToggle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span data-i18n="btn_prove_payment">Prove payment</span>
|
||||
</button>
|
||||
<div class="proof-panel" id="proofPanel">
|
||||
<div class="field">
|
||||
<label for="txHash" data-i18n="label_tx_hash">Transaction ID (TX Hash)</label>
|
||||
<input type="text" id="txHash" data-i18n-placeholder="placeholder_tx_hash" placeholder="64 hex characters..." spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="txKey" data-i18n="label_tx_key">Transaction Key (TX Key)</label>
|
||||
<input type="text" id="txKey" data-i18n-placeholder="placeholder_tx_key" placeholder="64 hex characters..." spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="verifyProof" disabled data-i18n="btn_verify_proof">Verify payment</button>
|
||||
<div class="proof-result" id="proofResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request">New payment request</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p data-i18n-html="footer">Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a></p>
|
||||
</footer>
|
||||
|
||||
<div class="lang-picker" id="langPicker">
|
||||
<button class="lang-toggle" id="langToggle" aria-label="Language">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lang-dropdown" id="langDropdown"></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="lib/qrcode.min.js?v=20260326-3" defer></script>
|
||||
<script src="i18n.min.js?v=20260326-3" defer></script>
|
||||
<script src="app.min.js?v=20260326-3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
373
lib/jspdf.min.js
vendored
Normal file
373
lib/jspdf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/qrcode.min.js
vendored
Normal file
1
lib/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
lib/xmr-crypto.bundle.js
Normal file
13
lib/xmr-crypto.bundle.js
Normal file
File diff suppressed because one or more lines are too long
234
privacy.html
Normal file
234
privacy.html
Normal file
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>xmrpay.link — Privacy & Terms</title>
|
||||
<meta name="description" content="Privacy policy and terms of use for xmrpay.link.">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="style.css?v=20260326-3">
|
||||
<style>
|
||||
main.legal-main {
|
||||
max-width: 920px;
|
||||
}
|
||||
.legal-card {
|
||||
line-height: 1.55;
|
||||
}
|
||||
.legal-card h2 {
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--accent-text);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.legal-card h3 {
|
||||
margin: 1rem 0 0.4rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.legal-card p,
|
||||
.legal-card li {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.legal-card ul {
|
||||
margin: 0 0 0 1.2rem;
|
||||
padding: 0;
|
||||
}
|
||||
.legal-card li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.legal-lang {
|
||||
display: none;
|
||||
}
|
||||
.legal-lang.active {
|
||||
display: block;
|
||||
}
|
||||
.legal-top-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
|
||||
<p>Privacy & Terms</p>
|
||||
</header>
|
||||
|
||||
<main class="legal-main">
|
||||
<div class="card legal-card">
|
||||
<a class="legal-top-link" href="/">← Back to invoice generator</a>
|
||||
|
||||
<section class="legal-lang" data-lang="en">
|
||||
<h2>English</h2>
|
||||
<h3>Privacy Policy</h3>
|
||||
<p>xmrpay.link is designed to minimize data collection. No account is required.</p>
|
||||
<ul>
|
||||
<li><strong>Rate limiting:</strong> abuse protection uses short-lived, hashed-IP request records in application rate-limit files. The application does not store raw IP addresses in those records.</li>
|
||||
<li><strong>Short links:</strong> invoice hash data is stored for generated short URLs.</li>
|
||||
<li><strong>Payment proof:</strong> if used, tx hash, amount, confirmations and timestamp are stored. No Monero address is stored in the proof database.</li>
|
||||
<li><strong>No tracking:</strong> no analytics, no ads, no profiling.</li>
|
||||
</ul>
|
||||
<h3>Terms of Use</h3>
|
||||
<ul>
|
||||
<li>Service is provided "as is" without warranties.</li>
|
||||
<li>You are responsible for legal compliance in your jurisdiction.</li>
|
||||
<li>Abuse, unlawful usage or attacks against the service are prohibited.</li>
|
||||
<li>Availability is not guaranteed; features may change at any time.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="de">
|
||||
<h2>Deutsch</h2>
|
||||
<h3>Datenschutzerklaerung</h3>
|
||||
<p>xmrpay.link ist auf minimale Datenerhebung ausgelegt. Es ist kein Konto erforderlich.</p>
|
||||
<ul>
|
||||
<li><strong>Rate-Limiting:</strong> Missbrauchsschutz nutzt kurzlebige, gehashte IP-Request-Eintraege in Rate-Limit-Dateien der Anwendung. Rohe IP-Adressen werden darin nicht gespeichert.</li>
|
||||
<li><strong>Kurzlinks:</strong> Rechnungs-Hash-Daten werden fuer erzeugte Kurzlinks gespeichert.</li>
|
||||
<li><strong>Zahlungsnachweis:</strong> falls genutzt, werden TX-Hash, Betrag, Bestaetigungen und Zeitstempel gespeichert. Keine Monero-Adresse wird in der Proof-Datenbank gespeichert.</li>
|
||||
<li><strong>Kein Tracking:</strong> keine Analytics, keine Werbung, kein Profiling.</li>
|
||||
</ul>
|
||||
<h3>Nutzungsbedingungen</h3>
|
||||
<ul>
|
||||
<li>Der Dienst wird ohne Gewaehr bereitgestellt.</li>
|
||||
<li>Du bist fuer die Einhaltung der Gesetze in deiner Jurisdiktion verantwortlich.</li>
|
||||
<li>Missbrauch, rechtswidrige Nutzung oder Angriffe auf den Dienst sind verboten.</li>
|
||||
<li>Die Verfuegbarkeit ist nicht garantiert; Funktionen koennen jederzeit geaendert werden.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="fr">
|
||||
<h2>Francais</h2>
|
||||
<h3>Politique de confidentialite</h3>
|
||||
<p>xmrpay.link est concu pour minimiser la collecte de donnees. Aucun compte n'est requis.</p>
|
||||
<ul>
|
||||
<li><strong>Limitation de debit:</strong> la protection anti-abus utilise des enregistrements de requetes IP hachees et de courte duree dans les fichiers de limitation de l'application. L'application n'y stocke pas d'adresses IP brutes.</li>
|
||||
<li><strong>Liens courts:</strong> les donnees de hachage de facture sont stockees pour les liens courts generes.</li>
|
||||
<li><strong>Preuve de paiement:</strong> si utilisee, le hash tx, le montant, les confirmations et l'horodatage sont stockes. Aucune adresse Monero n'est stockee dans la base de preuves.</li>
|
||||
<li><strong>Pas de suivi:</strong> pas d'analytics, pas de publicite, pas de profilage.</li>
|
||||
</ul>
|
||||
<h3>Conditions d'utilisation</h3>
|
||||
<ul>
|
||||
<li>Le service est fourni "tel quel" sans garantie.</li>
|
||||
<li>Vous etes responsable du respect des lois de votre juridiction.</li>
|
||||
<li>Les abus, l'utilisation illegale ou les attaques contre le service sont interdits.</li>
|
||||
<li>La disponibilite n'est pas garantie; les fonctionnalites peuvent changer a tout moment.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="it">
|
||||
<h2>Italiano</h2>
|
||||
<h3>Informativa sulla privacy</h3>
|
||||
<p>xmrpay.link e progettato per ridurre al minimo la raccolta dati. Non e richiesto alcun account.</p>
|
||||
<ul>
|
||||
<li><strong>Rate limiting:</strong> la protezione anti-abuso usa record di richieste IP hashati e di breve durata nei file di rate limit dell'applicazione. L'applicazione non salva indirizzi IP in chiaro in quei record.</li>
|
||||
<li><strong>Link brevi:</strong> i dati hash della fattura vengono salvati per i link brevi generati.</li>
|
||||
<li><strong>Prova di pagamento:</strong> se usata, vengono salvati tx hash, importo, conferme e timestamp. Nessun indirizzo Monero viene salvato nel database delle prove.</li>
|
||||
<li><strong>Nessun tracciamento:</strong> niente analytics, niente pubblicita, niente profilazione.</li>
|
||||
</ul>
|
||||
<h3>Termini di utilizzo</h3>
|
||||
<ul>
|
||||
<li>Il servizio e fornito "cosi com'e" senza garanzie.</li>
|
||||
<li>Sei responsabile del rispetto delle leggi della tua giurisdizione.</li>
|
||||
<li>Abusi, uso illecito o attacchi al servizio sono vietati.</li>
|
||||
<li>La disponibilita non e garantita; le funzionalita possono cambiare in qualsiasi momento.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="es">
|
||||
<h2>Espanol</h2>
|
||||
<h3>Politica de privacidad</h3>
|
||||
<p>xmrpay.link esta disenado para minimizar la recopilacion de datos. No se requiere cuenta.</p>
|
||||
<ul>
|
||||
<li><strong>Limitacion de tasa:</strong> la proteccion antiabuso usa registros de solicitudes con IP hasheada y de corta duracion en archivos de rate limit de la aplicacion. La aplicacion no almacena direcciones IP en claro en esos registros.</li>
|
||||
<li><strong>Enlaces cortos:</strong> se almacenan datos hash de factura para enlaces cortos generados.</li>
|
||||
<li><strong>Prueba de pago:</strong> si se usa, se almacenan tx hash, monto, confirmaciones y marca temporal. No se almacena ninguna direccion Monero en la base de pruebas.</li>
|
||||
<li><strong>Sin rastreo:</strong> sin analytics, sin anuncios, sin perfilado.</li>
|
||||
</ul>
|
||||
<h3>Terminos de uso</h3>
|
||||
<ul>
|
||||
<li>El servicio se ofrece "tal cual" sin garantias.</li>
|
||||
<li>Eres responsable de cumplir las leyes de tu jurisdiccion.</li>
|
||||
<li>Se prohibe el abuso, uso ilegal o ataques contra el servicio.</li>
|
||||
<li>La disponibilidad no esta garantizada; las funciones pueden cambiar en cualquier momento.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="pt">
|
||||
<h2>Portugues</h2>
|
||||
<h3>Politica de privacidade</h3>
|
||||
<p>xmrpay.link foi projetado para minimizar a coleta de dados. Nao e necessaria conta.</p>
|
||||
<ul>
|
||||
<li><strong>Limite de taxa:</strong> a protecao contra abuso usa registros de requisicoes com IP hasheado e de curta duracao em arquivos de rate limit da aplicacao. A aplicacao nao armazena enderecos IP em texto puro nesses registros.</li>
|
||||
<li><strong>Links curtos:</strong> dados hash da fatura sao armazenados para links curtos gerados.</li>
|
||||
<li><strong>Comprovacao de pagamento:</strong> se usada, tx hash, valor, confirmacoes e carimbo de data/hora sao armazenados. Nenhum endereco Monero e armazenado no banco de comprovacoes.</li>
|
||||
<li><strong>Sem rastreamento:</strong> sem analytics, sem anuncios, sem perfilamento.</li>
|
||||
</ul>
|
||||
<h3>Termos de uso</h3>
|
||||
<ul>
|
||||
<li>O servico e fornecido "como esta" sem garantias.</li>
|
||||
<li>Voce e responsavel por cumprir as leis da sua jurisdicao.</li>
|
||||
<li>Abuso, uso ilegal ou ataques contra o servico sao proibidos.</li>
|
||||
<li>A disponibilidade nao e garantida; recursos podem mudar a qualquer momento.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="legal-lang" data-lang="ru">
|
||||
<h2>Russkiy</h2>
|
||||
<h3>Politika konfidentsialnosti</h3>
|
||||
<p>xmrpay.link sozdan s minimalnym sborom dannykh. Akkount ne trebuetsya.</p>
|
||||
<ul>
|
||||
<li><strong>Ogranichenie zaprosov:</strong> zashchita ot zloupotrebleniy ispolzuet kratkozhivushchie zapisi zaprosov s kheshirovannym IP v faylakh ogranicheniya prilozheniya. Prilozhenie ne khranit syrye IP-adresa v etikh zapisyakh.</li>
|
||||
<li><strong>Korotkie ssylki:</strong> khesh-dannye scheta sokhranyayutsya dlya sozdannykh korotkikh ssylok.</li>
|
||||
<li><strong>Podtverzhdenie platezha:</strong> pri ispolzovanii sokhranyayutsya tx hash, summa, podtverzhdeniya i metka vremeni. Adres Monero v baze podtverzhdeniy ne khranitsya.</li>
|
||||
<li><strong>Bez trekinga:</strong> bez analitiki, bez reklamy, bez profilirovaniya.</li>
|
||||
</ul>
|
||||
<h3>Usloviya ispolzovaniya</h3>
|
||||
<ul>
|
||||
<li>Servis predostavlyaetsya "kak est" bez garantiy.</li>
|
||||
<li>Vy nesete otvetstvennost za soblyudenie zakonov vashey yurisdiktsii.</li>
|
||||
<li>Zloupotrebleniya, nezakonnoe ispolzovanie i ataki na servis zapreshcheny.</li>
|
||||
<li>Dostupnost ne garantiruetsya; funktsii mogut izmenyatsya v lyuboe vremya.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p style="margin-top:1rem;color:var(--text-muted);font-size:0.82rem;">Last updated: 2026-03-26</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p data-i18n-html="footer">Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a></p>
|
||||
</footer>
|
||||
|
||||
<div class="lang-picker" id="langPicker">
|
||||
<button class="lang-toggle" id="langToggle" aria-label="Language">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lang-dropdown" id="langDropdown"></div>
|
||||
</div>
|
||||
|
||||
<script src="i18n.min.js?v=20260326-3" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var supported = ['en', 'de', 'fr', 'it', 'es', 'pt', 'ru'];
|
||||
var sections = document.querySelectorAll('.legal-lang');
|
||||
|
||||
function applyLang(lang) {
|
||||
var activeLang = supported.indexOf(lang) !== -1 ? lang : 'en';
|
||||
sections.forEach(function (el) {
|
||||
el.classList.toggle('active', el.getAttribute('data-lang') === activeLang);
|
||||
});
|
||||
}
|
||||
|
||||
applyLang(I18n.getLang());
|
||||
I18n.onChange(function (lang) {
|
||||
applyLang(lang);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
64
s.php
Normal file
64
s.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
$pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : null;
|
||||
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
|
||||
$code = trim($pathInfo ?? $queryCode, '/');
|
||||
|
||||
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
|
||||
$dbFile = __DIR__ . '/data/urls.json';
|
||||
if (!file_exists($dbFile)) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawUrls = file_get_contents($dbFile);
|
||||
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
|
||||
$urls = is_array($decodedUrls) ? $decodedUrls : [];
|
||||
|
||||
if (!isset($urls[$code])) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Support both old format (string) and new format (array with hash & signature)
|
||||
$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');
|
||||
/** @var array<string, mixed> $urls */
|
||||
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 !== '') {
|
||||
require_once __DIR__ . '/api/_helpers.php';
|
||||
$expected_sig = hash_hmac('sha256', $hash, get_hmac_secret());
|
||||
if (!hash_equals($expected_sig, $signature)) {
|
||||
// Signature mismatch — possible tampering, log and proceed (graceful degradation)
|
||||
error_log("xmrpay: Signature mismatch for code $code");
|
||||
}
|
||||
}
|
||||
|
||||
$host = isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'xmrpay.link';
|
||||
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
|
||||
header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302);
|
||||
exit;
|
||||
36
scripts/deploy.sh
Executable file
36
scripts/deploy.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Safe deploy: never delete server-side runtime data/ files.
|
||||
#
|
||||
# Configuration (required):
|
||||
# DEPLOY_HOST e.g. root@example.com or deploy@example.com
|
||||
# DEPLOY_TARGET e.g. /home/user/web/xmrpay.link/public_html
|
||||
#
|
||||
# Optional local config file (not committed):
|
||||
# scripts/.deploy.env
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/.deploy.env"
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
fi
|
||||
|
||||
HOST="${DEPLOY_HOST:-}"
|
||||
TARGET="${DEPLOY_TARGET:-}"
|
||||
|
||||
if [[ -z "$HOST" || -z "$TARGET" ]]; then
|
||||
echo "Missing deploy configuration." >&2
|
||||
echo "Set DEPLOY_HOST and DEPLOY_TARGET (env vars or scripts/.deploy.env)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'data/' \
|
||||
./ "$HOST:$TARGET"
|
||||
|
||||
echo "Deploy complete (data/ preserved)."
|
||||
708
style.css
Normal file
708
style.css
Normal file
@@ -0,0 +1,708 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: optional;
|
||||
src: url('fonts/inter-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('fonts/inter-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('fonts/jetbrains-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-input: #222;
|
||||
--border: #333;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--accent: #c74a00;
|
||||
--accent-hover: #a83f00;
|
||||
--accent-text: #e87830;
|
||||
--success: #4caf50;
|
||||
--error: #f44336;
|
||||
--radius: 8px;
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.lang-picker {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.lang-toggle:hover,
|
||||
.lang-picker.open .lang-toggle {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.lang-picker.open .lang-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lang-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.lang-option:hover {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lang-option.active {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
header h1 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header h1 a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
header h1 span {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
textarea:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--bg-input) inset !important;
|
||||
-webkit-text-fill-color: var(--text) !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
input.valid {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.amount-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.amount-row select {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fiat-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.3rem;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
|
||||
.fiat-hint.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.deadline-badges {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.45rem 0.8rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: #aaa;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-input {
|
||||
width: 70px;
|
||||
padding: 0.45rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #3a2215;
|
||||
color: #a0a0a0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.6rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#result {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#result.visible {
|
||||
display: block;
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.qr-container canvas,
|
||||
.qr-container img {
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.qr-container canvas:hover,
|
||||
.qr-container img:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.payment-summary {
|
||||
text-align: center;
|
||||
padding: 0.5rem 0 0.8rem;
|
||||
}
|
||||
|
||||
.summary-amount {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.payment-summary.paid-confirmed .summary-amount {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.summary-fiat {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.summary-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.uri-details {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.uri-details summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.uri-details summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.uri-box {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.share-link-box {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.share-link-box label {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.share-link-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.share-link-row input {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions .btn-secondary {
|
||||
flex: 1 1 30%;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- TX Proof Section --- */
|
||||
|
||||
.proof-section {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-proof {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.btn-proof:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.proof-panel {
|
||||
display: none;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.proof-panel.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.proof-result {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.proof-result.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.proof-result.success {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.proof-result.error {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.proof-result.warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.payment-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.payment-status.paid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.payment-status.pending {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paid-stamp {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-12deg);
|
||||
border: 4px solid var(--success);
|
||||
border-radius: 12px;
|
||||
padding: 0.4rem 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: var(--success);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 3px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.qr-container.paid canvas,
|
||||
.qr-container.paid img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.pending-stamp {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.qr-container.confirming canvas,
|
||||
.qr-container.confirming img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paid-info {
|
||||
color: var(--success);
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.pending-info {
|
||||
color: #f59e0b;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
margin-top: 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent-text);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--accent-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.countdown.expired {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.countdown.active {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
main {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.qr-container canvas,
|
||||
.qr-container img {
|
||||
max-width: 80vw;
|
||||
height: auto;
|
||||
}
|
||||
.summary-amount {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
73
sw.js
Normal file
73
sw.js
Normal file
@@ -0,0 +1,73 @@
|
||||
var CACHE_NAME = 'xmrpay-v4';
|
||||
var ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.min.js?v=20260326-3',
|
||||
'/i18n.min.js?v=20260326-3',
|
||||
'/style.css',
|
||||
'/lib/qrcode.min.js?v=20260326-3',
|
||||
'/favicon.svg',
|
||||
'/fonts/inter-400.woff2',
|
||||
'/fonts/jetbrains-400.woff2'
|
||||
// xmr-crypto.bundle.js and jspdf.min.js are lazy-loaded and runtime-cached
|
||||
];
|
||||
|
||||
self.addEventListener('install', function (e) {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE_NAME).then(function (cache) {
|
||||
return cache.addAll(ASSETS);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (e) {
|
||||
e.waitUntil(
|
||||
caches.keys().then(function (names) {
|
||||
return Promise.all(
|
||||
names.filter(function (n) { return n !== CACHE_NAME; })
|
||||
.map(function (n) { return caches.delete(n); })
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (e) {
|
||||
var url = new URL(e.request.url);
|
||||
|
||||
// API calls — network only, don't cache
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(fetch(e.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation (HTML) — network first, fall back to cached index.html for offline
|
||||
// Invoice data is in the URL hash, so caching the document would cause stale state
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function () {
|
||||
return caches.match('/index.html');
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App assets — cache first, fallback to network
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(function (cached) {
|
||||
var networkFetch = fetch(e.request).then(function (response) {
|
||||
if (response.ok) {
|
||||
var clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(function (cache) {
|
||||
cache.put(e.request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}).catch(function () {
|
||||
return cached;
|
||||
});
|
||||
return cached || networkFetch;
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user