29 Commits

Author SHA1 Message Date
Alexander Schmidt
ec99e097c2 Harden deploy script config handling 2026-03-26 14:07:07 +01:00
Alexander Schmidt
7e389d6a1c Add safe deploy script preserving data directory 2026-03-26 13:55:59 +01:00
Alexander Schmidt
3cd8d03d9b Align privacy rate-limit wording with implementation 2026-03-26 13:53:07 +01:00
Alexander Schmidt
e52955f106 Update privacy terms: no persistent IP records 2026-03-26 13:48:40 +01:00
Alexander Schmidt
eae15de873 Preserve absolute invoice deadline across reloads 2026-03-26 13:43:30 +01:00
Alexander Schmidt
71383431f2 Keep short URL in share field when loaded via short link 2026-03-26 13:40:16 +01:00
Alexander Schmidt
09616adc81 Refresh pending proof confirmations on status lookup 2026-03-26 13:28:40 +01:00
Alexander Schmidt
090256ae4f Fix short link integrity check for code parameter 2026-03-26 13:26:05 +01:00
Alexander Schmidt
9c466d3814 Bump asset versions and rotate service worker cache 2026-03-26 13:24:18 +01:00
Alexander Schmidt
523bdae81c Fix false short URL integrity warning 2026-03-26 13:22:34 +01:00
Alexander Schmidt
c206a51f0b Add yellow favicon badge for pending invoices 2026-03-26 13:20:11 +01:00
Alexander Schmidt
9faec16b31 Regenerate minified translations for pending proof status 2026-03-26 13:15:33 +01:00
Alexander Schmidt
fa2f7a4ab1 Add document-and-coin favicon concept and sync paid favicon state 2026-03-26 13:10:30 +01:00
Alexander Schmidt
a5de8752dd Update README: mark auto-cleanup as complete, add Invoice Lifecycle section 2026-03-26 11:03:59 +01:00
Alexander Schmidt
4549a05b6d Add type annotations to fix Intelephense type checking errors 2026-03-26 11:03:15 +01:00
Alexander Schmidt
c8df4df881 Update cache-busting version to 20260326-2 for cleanup feature 2026-03-26 11:02:20 +01:00
Alexander Schmidt
9999c00d59 Implement lazy-cleanup for expired invoices with deadline-based deletion 2026-03-26 11:01:32 +01:00
Alexander Schmidt
458ee78362 Add deadline cleanup feature to roadmap 2026-03-26 10:54:21 +01:00
Alexander Schmidt
ded24ce575 Add cache-busting version params for frontend assets 2026-03-26 10:11:13 +01:00
Alexander Schmidt
600154493e Fix paid/pending invoice status UI and date handling 2026-03-26 10:06:08 +01:00
Alexander Schmidt
fa9f2243ae refactor: reuse shared style.css and language switcher on privacy page 2026-03-26 08:01:59 +01:00
Alexander Schmidt
cffdee2cb6 fix: harden PHP type handling across all endpoints 2026-03-26 07:57:11 +01:00
Alexander Schmidt
2154d5996d feat: add multilingual privacy and terms page + footer link 2026-03-26 07:50:57 +01:00
Alexander Schmidt
27cb9e0fec fix: footer 'Minimal Backend' → 'No Tracking' 2026-03-26 07:39:55 +01:00
Alexander Schmidt
69c66aea38 fix: remove duplicate <?php tag in verify.php (HTTP 500) 2026-03-26 07:36:35 +01:00
Alexander Schmidt
1bbf309029 feat: confirmation-aware TX verification (10-conf threshold)
- 0-9 confs: show amber 'Pending/N/10' stamp on QR, auto-poll every 60s
- ≥10 confs: show green 'Paid' stamp (Monero standard lock)
- verify.php: store status ('pending'|'paid'), allow upward updates
- i18n: add status_pending + proof_confirmed_pending (all 7 langs)
- style.css: add .proof-result.warning, .pending-stamp, .qr-container.confirming
- Polling stops on resetForm; short-URL viewers also poll verify.php
2026-03-26 07:30:43 +01:00
Alexander Schmidt
14f73875de fix: remove duplicate <?php tag in check-short.php 2026-03-26 07:15:28 +01:00
Alexander Schmidt
38f23d6627 Security hardening: rate limiting, atomic locks, origin check, honest docs
API / Security:
- Add api/_helpers.php: shared send_security_headers(), verify_origin(),
  get_hmac_secret(), check_rate_limit(), read_json_locked(), write_json_locked()
- shorten.php: remove Access-Control-Allow-Origin:*, restrict to same-origin,
  rate-limit 20 req/h per IP, atomic JSON read+lock, HMAC secret from file
- verify.php: rate-limit GET (30/min) and POST (10/h) per IP, atomic lock,
  prevent overwriting existing proofs, origin check on POST
- node.php: fix rate limit from 1000 to 60 req/min, add security headers,
  origin check
- check-short.php: add security headers, re-derive signature server-side
- s.php: use file-based HMAC secret via get_hmac_secret(), hash_equals()
  for timing-safe comparison

Service Worker:
- sw.js: navigation requests (mode=navigate) never served from cache;
  network-first with offline fallback to prevent stale invoice state

Documentation (honest claims):
- README: tagline "No backend" -> "No tracking"; new Architecture table
  listing exactly what server sees for each feature; Security Model section
- index.html: meta description and footer updated from "No Backend" to
  "Minimal Backend"
- i18n.js footer: already updated in previous commit
2026-03-26 07:13:02 +01:00
Alexander Schmidt
96dd4bfc72 Security: Add HMAC validation for short URLs + improve privacy documentation
- Implement HMAC-SHA256 signatures on short URLs to detect server-side tampering
- Add client-side signature verification with hostname-derived secret
- New API endpoint: /api/check-short.php for integrity verification
- Update verify.php with privacy notice (addresses not stored)
- Update README to clarify minimal backend requirement (short URLs, rate caching, proof storage)
- Add toast warning when signature mismatch detected
- Support both old and new format in s.php for backward compatibility
- Update all i18n translations (EN, DE, FR, IT, ES, PT, RU)

Addresses security concern: Server compromise could previously result in address
substitution for short-linked invoices. Now client-side verification detects tampering.
2026-03-26 06:52:20 +01:00
19 changed files with 1088 additions and 115 deletions

View File

@@ -1,6 +1,6 @@
# xmrpay.link — Monero Invoice Generator
> Private. Self-hosted. No accounts. No backend. No bullshit.
> Private. Self-hosted. No accounts. No tracking. No bullshit.
**[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)**
@@ -8,10 +8,33 @@
## What is this?
**xmrpay.link** is a client-side web app that lets anyone create a professional Monero payment request in under 30 seconds — no node, no registration, no KYC, no third parties.
**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.
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.
---
## Why?
@@ -35,14 +58,14 @@ Enter your address, the amount, an optional description — and get a QR code, a
- 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`)
- 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 permanently with the invoice
- Payment status stored with the invoice (server stores proof, but not your address)
- Invoice link shows "Paid" badge after verification
- Standard and subaddress support
@@ -97,6 +120,19 @@ xmrpay.link/
---
## 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
@@ -112,6 +148,16 @@ Requirements for full functionality:
- 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/`.
---
## Security
@@ -129,6 +175,7 @@ Requirements for full functionality:
- [ ] Embeddable `<iframe>` payment widget
- [ ] Invoice history (LocalStorage, CSV export)
- [ ] "Pay Button" generator (HTML snippet)
- [x] Auto-cleanup: Lazy-delete invoices after deadline expires
---

94
api/_helpers.php Normal file
View 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
View 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
]);

View File

@@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/_helpers.php';
/**
* Monero Daemon RPC Proxy
* Forwards allowed RPC requests to Monero nodes, bypassing CORS.
@@ -6,6 +8,8 @@
*/
header('Content-Type: application/json');
send_security_headers();
verify_origin();
// Only POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -36,12 +40,14 @@ $now = time();
$rateData = [];
if (file_exists($rateFile)) {
$rateData = json_decode(file_get_contents($rateFile), true) ?: [];
$rawRate = file_get_contents($rateFile);
$decodedRate = is_string($rawRate) ? json_decode($rawRate, true) : [];
$rateData = is_array($decodedRate) ? $decodedRate : [];
// Clean old entries
$rateData = array_filter($rateData, fn($t) => $t > $now - 60);
$rateData = array_values(array_filter($rateData, fn($t) => is_numeric($t) && (int)$t > $now - 60));
}
if (count($rateData) >= 1000) {
if (count($rateData) >= 60) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
@@ -51,15 +57,16 @@ $rateData[] = $now;
file_put_contents($rateFile, json_encode($rateData));
// Parse request
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['method'])) {
$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 = $input['params'] ?? [];
$params = isset($input['params']) && is_array($input['params']) ? $input['params'] : [];
// Determine endpoint type
$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC);
@@ -75,8 +82,9 @@ if (!$isJsonRpc && !$isHttp) {
$cacheFile = __DIR__ . '/../data/node_cache.json';
$cachedNode = null;
if (file_exists($cacheFile)) {
$cache = json_decode(file_get_contents($cacheFile), true);
if ($cache && ($cache['time'] ?? 0) > $now - 300) {
$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'];
}
}
@@ -104,6 +112,10 @@ foreach ($orderedNodes as $node) {
}
$ch = curl_init($url);
if ($ch === false) {
$lastError = 'cURL init failed';
continue;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,

View File

@@ -6,8 +6,10 @@ $cacheFile = __DIR__ . '/../data/rates_cache.json';
$cacheTTL = 120; // seconds
if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached && (time() - ($cached['_time'] ?? 0)) < $cacheTTL) {
$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);
@@ -15,10 +17,15 @@ if (file_exists($cacheFile)) {
}
}
$currencies = $_GET['c'] ?? 'eur,usd,chf,gbp,jpy,rub,brl';
$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,
@@ -30,7 +37,7 @@ curl_close($ch);
if ($response !== false && $httpCode === 200) {
$data = json_decode($response, true);
if ($data) {
if (is_array($data)) {
$data['_time'] = time();
file_put_contents($cacheFile, json_encode($data));
unset($data['_time']);
@@ -42,8 +49,9 @@ if ($response !== false && $httpCode === 200) {
// On error, serve stale cache if available
if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) {
$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);

View File

@@ -1,8 +1,8 @@
<?php
require_once __DIR__ . '/_helpers.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
send_security_headers();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -11,37 +11,50 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
$dataDir = __DIR__ . '/../data';
$dbFile = $dataDir . '/urls.json';
verify_origin();
if (!is_dir($dataDir)) {
mkdir($dataDir, 0750, true);
if (!check_rate_limit('shorten', 20, 3600)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$hash = $input['hash'] ?? '';
$dbFile = __DIR__ . '/../data/urls.json';
if (empty($hash) || strlen($hash) > 500) {
$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;
}
// Load existing URLs
$urls = [];
if (file_exists($dbFile)) {
$urls = json_decode(file_get_contents($dbFile), true) ?: [];
$secret = get_hmac_secret();
[$fp, $urls] = read_json_locked($dbFile);
if (!is_array($urls)) {
$urls = [];
}
// Check if this hash already exists
$existing = array_search($hash, $urls);
if ($existing !== false) {
echo json_encode(['code' => $existing]);
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($length = 6) {
function generateCode(int $length = 6): string {
$chars = 'abcdefghijkmnpqrstuvwxyz23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
@@ -55,7 +68,12 @@ while (isset($urls[$code])) {
$code = generateCode();
}
$urls[$code] = $hash;
file_put_contents($dbFile, json_encode($urls, JSON_UNESCAPED_UNICODE), LOCK_EX);
$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]);

View File

@@ -1,28 +1,123 @@
<?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';
$proofs = [];
if (file_exists($dbFile)) {
$proofs = json_decode(file_get_contents($dbFile), true) ?: [];
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') {
$code = $_GET['code'] ?? '';
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])) {
echo json_encode(array_merge(['verified' => true], $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]);
}
@@ -36,15 +131,24 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
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 = $input['code'] ?? '';
$txHash = $input['tx_hash'] ?? '';
$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);
@@ -60,27 +164,56 @@ if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) {
exit;
}
// Verify the short URL code exists
// 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;
}
$urls = json_decode(file_get_contents($urlsFile), true) ?: [];
$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
// 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()
];
file_put_contents($dbFile, json_encode($proofs, JSON_PRETTY_PRINT));
// 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]);

252
app.js
View File

@@ -8,6 +8,7 @@
const XMR_INTEGRATED_REGEX = /^4[1-9A-HJ-NP-Za-km-z]{105}$/;
const CACHE_DURATION = 60000; // 1 min
const RATE_RETRY_DELAY = 10000; // 10s retry on failure
const XMR_CONF_REQUIRED = 10; // Monero standard output lock
// --- State ---
let fiatRates = null;
@@ -16,6 +17,8 @@
let countdownTick = null;
let ratesFailed = false;
let invoiceCode = null; // short URL code for this invoice
let confirmPollInterval = null;
let pendingTxData = null; // { txHash, xmrAmount } for confirmation polling
// --- DOM ---
const $ = (s) => document.querySelector(s);
@@ -26,6 +29,7 @@
const timerCustom = $('#timerCustom');
const deadlineBadges = $('#deadlineBadges');
let selectedDays = 0;
let deadlineEndMs = null;
const generateBtn = $('#generate');
const resultSection = $('#result');
const qrContainer = $('#qr');
@@ -128,11 +132,13 @@
if (btn.classList.contains('active')) {
btn.classList.remove('active');
selectedDays = 0;
deadlineEndMs = null;
timerCustom.value = '';
} else {
deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
selectedDays = days;
deadlineEndMs = null;
timerCustom.value = '';
}
});
@@ -140,6 +146,7 @@
timerCustom.addEventListener('input', function () {
deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); });
selectedDays = parseInt(timerCustom.value) || 0;
deadlineEndMs = null;
});
// PDF
@@ -159,6 +166,7 @@
currencySelect.value = 'EUR';
descInput.value = '';
selectedDays = 0;
deadlineEndMs = null;
timerCustom.value = '';
deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); });
fiatHint.textContent = '';
@@ -168,10 +176,12 @@
resultSection.classList.remove('visible');
if (countdownInterval) clearInterval(countdownInterval);
qrContainer.innerHTML = '';
qrContainer.classList.remove('paid', 'confirming');
uriBox.textContent = '';
shareLinkInput.value = '';
// Reset proof
invoiceCode = null;
stopConfirmationPolling();
proofPanel.classList.remove('open');
txHashInput.value = '';
txKeyInput.value = '';
@@ -266,21 +276,27 @@
return uri;
}
function buildHash(addr, xmrAmount, desc, timer) {
function buildHash(addr, xmrAmount, desc, timer, deadlineTs) {
const params = new URLSearchParams();
params.set('a', addr);
if (xmrAmount) params.set('x', xmrAmount.toFixed(12));
if (desc) params.set('d', desc);
if (timer) params.set('t', timer);
if (deadlineTs) params.set('te', deadlineTs);
return params.toString();
}
async function shortenUrl(hash) {
try {
// Calculate expiry timestamp if deadline is set
let expiryTs = null;
if (selectedDays && selectedDays > 0) {
expiryTs = Math.floor((Date.now() + selectedDays * 86400000) / 1000);
}
const res = await fetch('/api/shorten.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: hash })
body: JSON.stringify({ hash: hash, expiry_ts: expiryTs })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
@@ -310,12 +326,23 @@
buildSummary(xmrAmount, desc, timer);
updatePageTitle(xmrAmount, desc);
// Share link — show long URL immediately, then replace with short
const hash = buildHash(addr, xmrAmount, desc, timer);
// Share link — keep existing short URL if present; otherwise shorten new hash
var deadlineTs = null;
if (timer && timer > 0) {
if (!deadlineEndMs) {
deadlineEndMs = Date.now() + timer * 86400000;
}
deadlineTs = Math.floor(deadlineEndMs / 1000);
}
const hash = buildHash(addr, xmrAmount, desc, timer, deadlineTs);
if (invoiceCode) {
shareLinkInput.value = location.origin + '/s/' + invoiceCode;
} else {
shareLinkInput.value = location.origin + '/#' + hash;
shortenUrl(hash).then(function (shortUrl) {
if (shortUrl) shareLinkInput.value = shortUrl;
});
}
// QR
qrContainer.innerHTML = '';
@@ -374,11 +401,20 @@
}
}
const deadlineTs = parseInt(params.get('te') || '0');
if (deadlineTs > 0) {
deadlineEndMs = deadlineTs * 1000;
}
// Check for short URL code and load payment status
const code = params.get('c');
if (code) {
invoiceCode = code;
setTimeout(function () { loadPaymentStatus(code); }, 200);
// Verify short URL integrity (detect tampering)
setTimeout(function () {
verifyShortUrlIntegrity(code, hash);
loadPaymentStatus(code);
}, 200);
}
// Auto-generate
@@ -386,6 +422,39 @@
return true;
}
// Verify that the redirected hash still matches the stored short URL mapping.
function verifyShortUrlIntegrity(code, currentHash) {
fetch('/api/check-short.php?code=' + encodeURIComponent(code))
.then(function (res) {
if (!res.ok) throw new Error('Integrity check failed');
return res.json();
})
.then(function (data) {
if (!data.hash) {
return;
}
if (data.expiry_ts && parseInt(data.expiry_ts) > 0) {
deadlineEndMs = parseInt(data.expiry_ts) * 1000;
if (resultSection.classList.contains('visible')) {
startCountdown();
}
}
var params = new URLSearchParams(currentHash);
params.delete('c');
var normalizedHash = params.toString();
if (data.hash !== normalizedHash) {
console.warn('xmrpay: Short URL hash mismatch detected for code', code);
showToast(I18n.t('toast_integrity_warning'));
}
})
.catch(function (e) {
console.warn('xmrpay: Could not verify short URL integrity:', e);
});
}
function buildSummary(xmrAmount, desc, days) {
var html = '';
if (xmrAmount) {
@@ -400,6 +469,8 @@
html += '<div class="summary-desc">' + desc.replace(/</g, '&lt;') + '</div>';
}
paymentSummary.innerHTML = html;
paymentSummary.classList.remove('paid-confirmed');
resetFavicon();
}
function updatePageTitle(xmrAmount, desc) {
@@ -416,9 +487,10 @@
countdownEl.textContent = '';
countdownEl.className = 'countdown';
if (!selectedDays || selectedDays <= 0) return;
if ((!selectedDays || selectedDays <= 0) && !deadlineEndMs) return;
const end = Date.now() + selectedDays * 86400000;
const end = deadlineEndMs || (Date.now() + selectedDays * 86400000);
deadlineEndMs = end;
countdownEl.classList.add('active');
function tick() {
@@ -811,23 +883,33 @@
if (found) {
var xmrAmount = Number(totalAmount) / 1e12;
var confs = tx.confirmations || 0;
if (confs >= XMR_CONF_REQUIRED) {
proofResult.className = 'proof-result active success';
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
// Store proof with invoice
if (invoiceCode) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: invoiceCode,
tx_hash: txHash,
amount: xmrAmount,
confirmations: tx.confirmations || 0
})
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
});
}
showPaidStatus({ amount: xmrAmount, tx_hash: txHash });
showPaidStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
} else {
proofResult.className = 'proof-result active warning';
proofResult.textContent = I18n.t('proof_confirmed_pending')
.replace('{amount}', xmrAmount.toFixed(6)).replace('{n}', confs);
if (invoiceCode) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'pending' })
});
}
showPendingStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
startConfirmationPolling(txHash, xmrAmount);
}
} else {
proofResult.className = 'proof-result active error';
proofResult.textContent = I18n.t('proof_no_match');
@@ -844,7 +926,25 @@
fetch('/api/verify.php?code=' + encodeURIComponent(code))
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.verified) {
if (!data.verified) return;
if (data.status === 'pending') {
showPendingStatus(data);
// Poll verify.php for updates pushed by the sender's browser
confirmPollInterval = setInterval(function () {
fetch('/api/verify.php?code=' + encodeURIComponent(code))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.verified) return;
if (d.status === 'paid') {
stopConfirmationPolling();
showPaidStatus(d);
} else {
showPendingStatus(d);
}
})
.catch(function () {});
}, 60000);
} else {
showPaidStatus(data);
}
})
@@ -852,6 +952,10 @@
}
function showPaidStatus(data) {
if (!data.verified_at) {
data = Object.assign({}, data, { verified_at: Math.floor(Date.now() / 1000) });
}
paymentStatus.className = 'payment-status paid';
// Stamp over QR + dim QR
@@ -876,13 +980,13 @@
year: 'numeric', month: 'long', day: 'numeric'
});
}
hint.textContent = data.amount.toFixed(6) + ' XMR — TX ' +
data.tx_hash.substring(0, 8) + '...' + dateStr;
hint.textContent = 'TX ' + data.tx_hash.substring(0, 8) + '...' + dateStr;
hint.className = 'qr-hint paid-info';
}
paymentStatus.innerHTML = '';
lastPaidData = data;
paymentSummary.classList.add('paid-confirmed');
// Hide unnecessary buttons when paid
openWalletBtn.style.display = 'none';
@@ -892,29 +996,127 @@
setPaidFavicon();
}
function setPaidFavicon() {
function showPendingStatus(data) {
var confs = data.confirmations || 0;
paymentStatus.className = 'payment-status pending';
paymentSummary.classList.remove('paid-confirmed');
qrContainer.classList.add('confirming');
setPendingFavicon();
var existingStamp = qrContainer.querySelector('.paid-stamp');
if (!existingStamp) {
var stamp = document.createElement('div');
stamp.className = 'paid-stamp pending-stamp';
qrContainer.appendChild(stamp);
existingStamp = stamp;
}
existingStamp.textContent = confs === 0 ? I18n.t('status_pending') : (confs + '/10');
var hint = qrContainer.querySelector('.qr-hint');
if (hint) {
hint.textContent = 'TX ' + data.tx_hash.substring(0, 8) + '... — ' +
(confs === 0 ? I18n.t('status_pending') : (confs + '/10'));
hint.className = 'qr-hint pending-info';
}
}
function startConfirmationPolling(txHash, xmrAmount) {
stopConfirmationPolling();
pendingTxData = { txHash: txHash, xmrAmount: xmrAmount };
confirmPollInterval = setInterval(pollConfirmations, 60000);
}
function stopConfirmationPolling() {
if (confirmPollInterval) {
clearInterval(confirmPollInterval);
confirmPollInterval = null;
}
pendingTxData = null;
}
async function pollConfirmations() {
if (!pendingTxData) return;
var txHash = pendingTxData.txHash;
var xmrAmount = pendingTxData.xmrAmount;
try {
var res = await fetch('/api/node.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'gettransactions', params: { txs_hashes: [txHash] } })
});
var data = await res.json();
var txs = data.txs || [];
if (txs.length === 0) return;
var confs = txs[0].confirmations || 0;
if (confs >= XMR_CONF_REQUIRED) {
stopConfirmationPolling();
proofResult.className = 'proof-result active success';
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
if (invoiceCode) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
});
}
showPaidStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
} else {
showPendingStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
proofResult.className = 'proof-result active warning';
proofResult.textContent = I18n.t('proof_confirmed_pending')
.replace('{amount}', xmrAmount.toFixed(6)).replace('{n}', confs);
}
} catch (e) {
// silent — try again next interval
}
}
function getBaseFaviconHref() {
var link = document.getElementById('favicon');
if (!link) return 'favicon.svg';
var baseHref = link.getAttribute('data-base-href') || link.getAttribute('href') || 'favicon.svg';
if (!link.getAttribute('data-base-href')) {
link.setAttribute('data-base-href', baseHref);
}
return baseHref;
}
function resetFavicon() {
var link = document.getElementById('favicon');
if (!link) return;
link.href = getBaseFaviconHref();
}
function setStatusFaviconBadge(color) {
var canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
var ctx = canvas.getContext('2d');
// Draw Monero logo
// Draw current base favicon and overlay status badge
var img = new Image();
img.onload = function () {
ctx.drawImage(img, 0, 0, 32, 32);
// Green dot (bottom-right)
ctx.beginPath();
ctx.arc(25, 25, 7, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.beginPath();
ctx.arc(25, 25, 5.5, 0, Math.PI * 2);
ctx.fillStyle = '#4caf50';
ctx.fillStyle = color;
ctx.fill();
// Set favicon
var link = document.getElementById('favicon');
link.href = canvas.toDataURL('image/png');
};
img.src = 'favicon.svg';
img.src = getBaseFaviconHref();
}
function setPaidFavicon() {
setStatusFaviconBadge('#4caf50');
}
function setPendingFavicon() {
setStatusFaviconBadge('#f59e0b');
}
})();

2
app.min.js vendored

File diff suppressed because one or more lines are too long

View 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

View File

@@ -1,4 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304.39 304.39">
<circle cx="152.2" cy="152.2" r="152.2" fill="#f26821"/>
<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="#fff"/>
<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>

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 1.3 KiB

37
i18n.js
View File

@@ -11,7 +11,7 @@ var I18n = (function () {
ru: { name: 'Русский' }
};
var footer = 'Open Source &middot; No Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a>';
var footer = 'Open Source &middot; No Tracking &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> &middot; <a href="/privacy.html">Privacy &amp; Terms</a>';
var translations = {
en: {
@@ -58,7 +58,10 @@ var I18n = (function () {
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_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',
@@ -104,7 +107,10 @@ var I18n = (function () {
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_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',
@@ -150,7 +156,10 @@ var I18n = (function () {
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_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',
@@ -196,7 +205,10 @@ var I18n = (function () {
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_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',
@@ -242,7 +254,10 @@ var I18n = (function () {
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_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',
@@ -288,7 +303,10 @@ var I18n = (function () {
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_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 за секунды',
@@ -334,7 +352,10 @@ var I18n = (function () {
proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес',
proof_tx_not_found: 'Транзакция не найдена',
proof_error: 'Ошибка проверки',
status_paid: 'Оплачено'
status_paid: 'Оплачено',
status_pending: 'Ожидание',
proof_confirmed_pending: 'Выход найден: {amount} XMR — {n}/10 подтверждений. Авт. обновление…',
toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи'
}
};

2
i18n.min.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -4,10 +4,10 @@
<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, no backend, no KYC.">
<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">
<link rel="stylesheet" href="style.css?v=20260326-3">
</head>
<body>
@@ -106,7 +106,7 @@
</main>
<footer>
<p data-i18n-html="footer">Open Source &middot; No Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a></p>
<p data-i18n-html="footer">Open Source &middot; No Tracking &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> &middot; <a href="/privacy.html">Privacy &amp; Terms</a></p>
</footer>
<div class="lang-picker" id="langPicker">
@@ -122,8 +122,8 @@
<div class="toast" id="toast"></div>
<script src="lib/qrcode.min.js" defer></script>
<script src="i18n.min.js" defer></script>
<script src="app.min.js" defer></script>
<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>

234
privacy.html Normal file
View 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 &amp; Terms</p>
</header>
<main class="legal-main">
<div class="card legal-card">
<a class="legal-top-link" href="/">&larr; 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 &middot; No Tracking &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> &middot; <a href="/privacy.html">Privacy &amp; 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>

44
s.php
View File

@@ -1,5 +1,7 @@
<?php
$code = trim($_SERVER['PATH_INFO'] ?? $_GET['c'] ?? '', '/');
$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);
@@ -14,7 +16,9 @@ if (!file_exists($dbFile)) {
exit;
}
$urls = json_decode(file_get_contents($dbFile), true) ?: [];
$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);
@@ -22,7 +26,39 @@ if (!isset($urls[$code])) {
exit;
}
$hash = $urls[$code]['hash'] ?? $urls[$code];
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
// 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
View 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)."

View File

@@ -403,6 +403,10 @@ textarea {
font-family: var(--mono);
}
.payment-summary.paid-confirmed .summary-amount {
color: var(--success);
}
.summary-fiat {
font-size: 0.85rem;
color: var(--text-muted);
@@ -556,6 +560,12 @@ textarea {
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;
}
@@ -564,6 +574,10 @@ textarea {
display: block;
}
.payment-status.pending {
display: block;
}
.paid-stamp {
position: absolute;
top: 50%;
@@ -587,6 +601,16 @@ textarea {
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;
}
@@ -597,6 +621,12 @@ textarea {
font-family: var(--mono);
}
.pending-info {
color: #f59e0b;
font-size: 0.75rem;
font-family: var(--mono);
}
.btn-new {
margin-top: 0.8rem;
background: transparent;

20
sw.js
View File

@@ -1,11 +1,12 @@
var CACHE_NAME = 'xmrpay-v3';
var CACHE_NAME = 'xmrpay-v4';
var ASSETS = [
'/',
'/index.html',
'/app.js',
'/i18n.js',
'/app.min.js?v=20260326-3',
'/i18n.min.js?v=20260326-3',
'/style.css',
'/lib/qrcode.min.js',
'/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
@@ -41,6 +42,17 @@ self.addEventListener('fetch', function (e) {
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) {