58 Commits

Author SHA1 Message Date
Alexander Schmidt
a8e7c38ce2 Add Docker self-hosting and CI/CD pipeline
- Dockerfile: Caddy + PHP-FPM + app in single Alpine container
- Caddyfile: auto-HTTPS, security headers, short URL rewrite
- docker-compose.yml: app + Watchtower for auto-updates
- install.sh: one-liner for fresh VPS setup
- GitHub Actions: build & push to Docker Hub + GHCR on tag

Self-host with:
  curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:26:30 +01:00
Alexander Schmidt
cdcf77adc4 Auto-inject version from git tags in deploy
Reads version from git describe, injects into i18n.js and index.html
before minification. No manual version bumping needed.
Tag with: git tag v1.1.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:06:57 +01:00
Alexander Schmidt
b4b1a18c71 Add version number to footer and fix line-height
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:02:54 +01:00
Alexander Schmidt
028575332f Add line break in footer i18n string
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:58:33 +01:00
Alexander Schmidt
381546e9f5 Fix deploy permissions and exclude credentials
- Add --chmod=D755,F644 to rsync (HestiaCP PHP-FPM needs world-readable)
- Exclude scripts/.deploy.env from deploy (contains server credentials)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:55:54 +01:00
Alexander Schmidt
2a609e6315 Add CSP, SRI, and auto-hash deploy pipeline
- Content Security Policy via <meta> tag (blocks exfiltration to foreign domains)
- Subresource Integrity on all static and dynamically loaded scripts
- Nginx security headers snippet (HSTS, CSP, frame-ancestors on all responses)
- Auto-minify and SRI hash update in deploy.sh (prevents stale hashes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:51:01 +01:00
Alexander Schmidt
fcfabb7e58 Clarify trust model and wallet-native default in README 2026-03-26 15:28:29 +01:00
Alexander Schmidt
2c12e72222 Add wallet URI copy and shortlink trust toggle 2026-03-26 15:11:11 +01:00
Alexander Schmidt
324bb87236 Fix deploy dry-run flag and generalize env example 2026-03-26 14:52:31 +01:00
Alexander Schmidt
f16df0a143 Harden deployment with data backups and restore script 2026-03-26 14:25:35 +01:00
Alexander Schmidt
7885651890 Add deploy env ignore and example template 2026-03-26 14:15:04 +01:00
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
Alexander Schmidt
1edf8bb324 docs: update README — 7 languages, 8 currencies, remove completed roadmap items 2026-03-25 18:28:35 +01:00
Alexander Schmidt
6b45a6346c feat: more currencies, auto-detection, globe-only language toggle
- Add GBP, JPY, RUB, BRL currencies
- Auto-detect currency from browser locale (de-CH→CHF, ru→RUB, etc.)
- USD as default fallback
- Language toggle: globe icon only (compact on mobile), full names in dropdown
- Countdown text updates on language switch
- CoinGecko proxy supports dynamic currency list
2026-03-25 18:25:27 +01:00
Alexander Schmidt
9ef0988627 feat: 7 languages — EN, DE, FR, IT, ES, PT, RU
- Full translations for all UI strings in 7 languages
- Language picker shows native names (Français, Italiano, Español, Português, Русский)
- Auto-detection via navigator.languages
- Cyrillic font subset for Russian (Inter, 19KB)
- Footer shared across all languages (untranslated links)
2026-03-25 18:15:07 +01:00
Alexander Schmidt
2b84a16055 feat: Tor hidden service, PDF paid details, subaddress placeholder
- Tor onion: mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion
- Onion link in footer and README
- PDF "BEZAHLT" block shows TX hash + date in second line
- Address placeholder 8... (encourages subaddress usage)
2026-03-25 17:37:19 +01:00
Alexander Schmidt
af7824b080 fix: PDF approx symbol — use ~ instead of unicode ≈ (unsupported by jsPDF Helvetica) 2026-03-25 17:23:32 +01:00
Alexander Schmidt
b30f3051e4 docs: rewrite README in English for release
- Complete English README with feature overview, tech stack, project structure
- Self-hosting instructions and security notes
- Accent color contrast fix (--accent-text for text on dark backgrounds)
- CoinGecko rates proxy: User-Agent header + 2min server-side cache
2026-03-25 17:21:37 +01:00
Alexander Schmidt
8af78dbb53 perf: 100% Lighthouse score — contrast, CLS, caching fixes
- Font preload eliminates layout shift (CLS 0)
- Dual accent colors: --accent for filled buttons, --accent-text for text on dark bg
- All WCAG AA contrast ratios met (buttons, links, badges, countdown)
- Language picker: position absolute instead of fixed
- CoinGecko rates proxied with 2min server-side cache (no CORS, no rate limit)
- English as default inline text (no empty→text shift)
2026-03-25 17:18:41 +01:00
Alexander Schmidt
dbe6e45ef3 perf: eliminate CLS with inline default text, EN as default language
- All visible text pre-rendered in HTML (no empty→text shift)
- English as default language (i18n fallback + HTML inline text)
- German auto-detected via navigator.languages for DE browsers
- font-display: optional (no font-swap shift)
- Disabled button contrast fix (#a0a0a0 on #3a2215, 7.2:1)
- CoinGecko rates proxied via /api/rates.php with User-Agent
2026-03-25 17:08:11 +01:00
Alexander Schmidt
9b6cca5d4b perf: CoinGecko proxy, font-display optional, contrast fix
- Route CoinGecko API through /api/rates.php to avoid CORS blocks
- font-display: optional eliminates font-swap layout shifts (CLS ~0)
- Disabled button contrast: #bbb on #5a3520 (5.8:1 ratio)
- Nginx font caching: 1 year, immutable
2026-03-25 17:00:20 +01:00
Alexander Schmidt
6bad55113b perf: self-host fonts, eliminate CLS, a11y and contrast fixes
- Self-host Inter and JetBrains Mono (woff2, 69KB total)
- Remove Google Fonts dependency entirely (no external requests)
- Service Worker pre-caches font files
- Eliminate font-swap layout shifts
- WCAG contrast fix: disabled button uses solid color instead of opacity
- Footer link always underlined for distinguishability
- Translated aria-labels via data-i18n-aria
- Dimmed QR with "Bezahlt" stamp on paid invoices
- Hide wallet/address buttons when invoice is paid
2026-03-25 16:56:10 +01:00
Alexander Schmidt
c2abb49a42 feat: UI polish, a11y, performance optimizations
- Payment summary card with prominent amount display
- "Bezahlt" stamp over dimmed QR code with TX details below
- Hide wallet/address buttons when paid, show only PDF
- URI box removed (was technical noise)
- Smart countdown: "29 Tage, 23 Std." instead of ticking seconds
- Dynamic page title for shared invoices
- Font fallbacks with size-adjust to prevent layout shifts
- Async Google Fonts loading, proper preconnect hints
- Deferred script loading (defer attribute)
- Minified JS (app.min.js, i18n.min.js)
- WCAG contrast fixes for badges and disabled button
- Footer link always underlined for a11y
- Translated aria-labels via data-i18n-aria
- i18n onChange callback for dynamic content updates
- Result card fade-in animation, responsive QR on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:50:55 +01:00
Alexander Schmidt
68a871d00c feat: PDF invoice, payment summary, UI polish
- PDF invoice generation with jsPDF (lazy-loaded, includes paid status)
- Payment summary card: amount, fiat equivalent, description prominently displayed
- URI box hidden behind collapsible "Show Monero URI" details
- Smart countdown: "29 Tage, 23 Std." instead of ticking seconds
- Dynamic page title: "0.017 XMR — Rechnung #42 | xmrpay.link"
- Result card fade-in animation
- Responsive QR code on mobile
- Rename .btn-monitor to .btn-proof
- "In Wallet öffnen" unified as <button>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:32:50 +01:00
Alexander Schmidt
2aff54a765 feat: Monero coin favicon with paid indicator
- Official Monero coin logo as SVG favicon
- Dynamic green dot badge on favicon when invoice is paid
- Paid status shown both in page content and browser tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:52:22 +01:00
Alexander Schmidt
a0236a7c43 fix: store TX proof under correct invoice code
The proof was being stored under a newly generated short URL code
instead of the original invoice code. Now tracks invoiceCode from
the hash parameter (c=CODE) or from the first shortenUrl call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:45:54 +01:00
Alexander Schmidt
025e3a06c0 feat: replace view-key monitor with TX proof verification
Remove v2 view-key payment monitor (privacy concern — nobody should
enter their private view key on a website). Replace with TX proof
verification where the sender provides TX Hash + TX Key from their
wallet. The proof is cryptographically verified client-side and
stored with the invoice for persistent "Paid" status.

- Remove monitor.js and all view-key monitoring UI/logic
- Add TX proof section: sender enters TX Hash + TX Key
- Client-side verification via check_tx_key equivalent (noble-curves)
- api/verify.php stores/retrieves payment proofs per invoice
- Short URL redirect now includes invoice code for status lookup
- Invoice link shows "Paid" badge once proof is verified
- Deadline badges (7/14/30 days) for payment terms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:37:09 +01:00
Alexander Schmidt
59125ecea0 feat: v2 — view-key payment confirmation with live monitoring
- Payment monitor: enter private view key to track incoming payments
- Scans mempool + last 100 blocks via PHP proxy with 4-node failover
- Lightweight crypto: 30KB noble-curves bundle (Ed25519 + Keccak-256)
- Subaddress support (network byte 42 detection, a*D validation)
- Confirmation progress bar (0-10 confirmations)
- Underpayment detection
- Deadline badges (7/14/30 days) replacing minutes input
- QR code: standard colors (black on white) for wallet scanner compatibility
- QR hint positioned below QR code
- View key masked input, never stored or transmitted

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:09:46 +01:00
Alexander Schmidt
2ffc3cec1f fix: streamline result UI — wallet button, clickable QR, autofill fix
- Replace "Link kopieren" + "QR speichern" with "In Wallet öffnen" button
- QR code clickable to save as PNG with subtle hint text
- Fix chromium autofill overriding dark input backgrounds
- Center button text and remove underline on link-buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:16:23 +01:00
Alexander Schmidt
e9b66fda24 feat: complete v1 — QR invoice generator with i18n, short URLs, offline support
- XMR address validation (standard, subaddress, integrated)
- Amount in XMR/EUR/USD/CHF with CoinGecko conversion
- QR code generation with monero: URI
- Shareable short URLs (/s/abc123) via self-hosted PHP backend
- i18n (DE/EN) with browser language detection
- Service worker for offline capability
- Dark mode, responsive design

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:38:44 +01:00
10 changed files with 165 additions and 168 deletions

View File

@@ -7,12 +7,10 @@ on:
env: env:
IMAGE_NAME: xmrpay IMAGE_NAME: xmrpay
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: DOCKER
permissions: permissions:
contents: read contents: read
packages: write packages: write

246
README.md
View File

@@ -1,111 +1,89 @@
# xmrpay — Monero Invoice Generator # xmrpay.link — Monero Invoice Generator
> Create Monero payment requests in seconds. No accounts. No tracking. No KYC. > Private. Self-hosted. No accounts. No tracking. No bullshit.
**[Demo: xmrpay.link](https://xmrpay.link)** — for real payments, self-host your own instance. **[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)**
--- ---
## Self-Host in 60 Seconds ## What is this?
You need a VPS with a domain pointing to it. Then: **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.
```bash Enter your address, the amount, an optional description — and get a wallet-native `monero:` URI, QR code, and PDF invoice. Short links are optional.
curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com
```
Done. HTTPS is automatic (via Caddy + Let's Encrypt). ### Architecture & Transparency
### Requirements xmrpay.link uses a **minimal backend** for the following specific purposes:
| | Minimum | Recommended | | Component | Where it runs | What the server sees |
|---|---|---| |-----------|--------------|---------------------|
| **CPU** | 1 vCPU | 2 vCPU | | QR code generation | Browser only | Nothing |
| **RAM** | 1 GB | 2 GB | | PDF invoice | Browser only | Nothing |
| **Disk** | 10 GB | 20 GB | | Payment (TX) verification | Browser only | Nothing |
| **OS** | Any Linux with Docker | Ubuntu 22+, Debian 12+ | | Fiat exchange rates | Server (CoinGecko proxy) | Your IP address |
| **Domain** | A-Record pointing to server IP | | | Short URL storage | Server | Invoice hash (address + amount + description), HMAC-signed |
| **Cost** | ~3 EUR/month (Hetzner, Contabo, etc.) | | | Payment proof storage | Server | TX hash + amount**not** your XMR address |
### Updates **Self-hosting** eliminates trust in the public instance.
**No short links** (use wallet URI / long `/#...` URL / QR code) = no shortlink lookup dependency.
Watchtower runs alongside xmrpay and automatically pulls new images every 6 hours. No action needed. ### Trust Model (Important)
Manual update: - **Default mode:** wallet-native URI + QR (no shortlink lookup).
- **Short links are opt-in:** convenience feature with a trust trade-off.
- **Public instance caution:** if a server is fully compromised, first-access shortlink resolution can be manipulated.
- **Best security posture:** use wallet URI directly or self-host.
```bash ### Security Model
cd /opt/xmrpay && docker compose pull && docker compose up -d
```
### Configuration - **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.
- **Clear scope:** HMAC improves integrity checks, but it is not a complete defense against a fully compromised server.
After install, the config is at `/opt/xmrpay/.env`: ---
```bash ## Why?
DOMAIN=your-domain.com
XMRPAY_IMAGE=schmidt1024/xmrpay:latest
```
### Docker Images | Solution | Problem |
| Registry | Pull command |
|---|---| |---|---|
| Docker Hub | `docker pull schmidt1024/xmrpay:latest` | | **BTCPay Server** | Requires own server, complex setup |
| GitHub (GHCR) | `docker pull ghcr.io/schmidt1024/xmrpay:latest` | | **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 |
### Manual Setup (without install script) **The gap:** There's no simple, privacy-respecting tool for freelancers, small merchants, and creators that works without setup and still allows payment confirmation.
```bash
mkdir -p /opt/xmrpay && cd /opt/xmrpay
curl -fsSL https://raw.githubusercontent.com/schmidt1024/xmrpay/master/docker-compose.yml -o docker-compose.yml
cat > .env <<EOF
DOMAIN=your-domain.com
XMRPAY_IMAGE=schmidt1024/xmrpay:latest
EOF
docker compose pull && docker compose up -d
```
### Uninstall
```bash
cd /opt/xmrpay && docker compose down -v
```
---
## Why Self-Host?
**Any server you don't control can steal your funds.** The JavaScript loaded from a third-party instance can swap the address in the QR code or exfiltrate payment data. No HMAC, no SRI hash, no URL fragment can fully prevent this — because the server controls the code your browser runs.
Self-hosting eliminates this risk. You control the server, you control the code.
The public instance at [xmrpay.link](https://xmrpay.link) exists as a demo and for testing only.
--- ---
## Features ## Features
- **Invoice generation** — XMR address, amount (XMR or fiat), description, payment deadline ### Invoice Generation
- **Wallet-native URI** — `monero:` URI with QR code, works with any Monero wallet - XMR address input with validation (standard, subaddress, integrated)
- **PDF invoice** — downloadable with QR, amount, fiat equivalent, deadline - Amount in XMR or fiat (EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected)
- **Payment verification** — sender provides TX Hash + TX Key, cryptographic verification in browser - Description and payment deadline (7/14/30 days or custom)
- **Fiat conversion** — EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected from locale - Wallet-native `monero:` URI with copy action
- **Short URLs** — optional, with explicit trust trade-off warning - QR code for the same wallet-native URI
- **i18n** — English, German, French, Italian, Spanish, Portuguese, Russian - Optional short URL toggle (`/s/abc123`) with explicit trust trade-off hint
- **Offline-capable** — Service Worker for offline use - PDF invoice download (with QR, amount, fiat equivalent, deadline)
- **Privacy** — zero cookies, no analytics, no external scripts, self-hosted fonts - i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection
### Security ### 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
- **CSP** — Content Security Policy blocks exfiltration to foreign domains ### Performance & Privacy
- **SRI** — Subresource Integrity on all scripts, verified on every load - 100% Lighthouse score (Performance, Accessibility, Best Practices, SEO)
- **HMAC-signed short URLs** — detect server-side tampering - Offline-capable via Service Worker
- **Rate-limited APIs** — all write endpoints rate-limited per IP - Self-hosted fonts (no Google Fonts dependency)
- **No private keys** — TX proof uses the sender's TX key, not the receiver's view key - Zero external tracking, no cookies
- **Client-side crypto** — Ed25519 + Keccak-256 verification runs in browser - Dark mode, responsive design
--- ---
@@ -116,24 +94,112 @@ Frontend: HTML + Vanilla JS (no frameworks, no build step)
Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle) Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle)
QR: QRCode.js (client-side) QR: QRCode.js (client-side)
PDF: jsPDF (client-side, lazy-loaded) 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) Backend: Minimal PHP (URL shortener, rates proxy, proof storage)
Data: JSON files (no database) Data: JSON files (no database), LocalStorage (client-side)
Hosting: Caddy (auto-HTTPS) + PHP-FPM in Alpine Docker
``` ```
--- ---
## Development ## Project Structure
```
xmrpay.link/
├── 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 # QR code generator
│ ├── jspdf.min.js # PDF generation (lazy-loaded)
│ └── xmr-crypto.bundle.js # Ed25519 + Keccak-256 (lazy-loaded)
├── README.md
└── LICENSE # MIT
```
---
## 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 ```bash
git clone https://github.com/schmidt1024/xmrpay.git git clone https://gitea.schmidt.eco/schmidt1024/xmrpay.link.git
cd xmrpay cd xmrpay.link
# Serve with any web server that supports PHP
# Local Docker build # No build tools, no npm, no database required
docker build -t xmrpay:dev . python3 -m http.server 8080 # For development (no PHP features)
docker run -p 8080:80 -e DOMAIN=localhost xmrpay:dev
``` ```
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/`.
Hardening built in:
- Creates a remote pre-deploy backup archive of `data/`
- Keeps the latest N backups (`DEPLOY_BACKUP_KEEP`, default `14`)
- Supports dry runs (`DEPLOY_DRY_RUN=1`)
- Configurable via `scripts/.deploy.env`
Restore examples:
```bash
./scripts/restore-data.sh --list
./scripts/restore-data.sh --latest
./scripts/restore-data.sh --file data-YYYYMMDD-HHMMSS.tgz
```
---
## 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 ## License

View File

@@ -14,19 +14,13 @@ function send_security_headers(): void {
// ── Origin verification ─────────────────────────────────────────────────────── // ── Origin verification ───────────────────────────────────────────────────────
function verify_origin(): void { function verify_origin(): void {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Allow same-origin (no Origin header from direct same-origin requests)
if ($origin === '') return;
// Dynamically allow the host this instance runs on
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$self_origin = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? '');
$allowed = [ $allowed = [
$self_origin,
'https://xmrpay.link', 'https://xmrpay.link',
'http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion', '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)) { if (!in_array($origin, $allowed, true)) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Origin not allowed']); echo json_encode(['error' => 'Origin not allowed']);

15
app.js
View File

@@ -61,21 +61,6 @@
let pdfLoaded = false; let pdfLoaded = false;
let lastPaidData = null; let lastPaidData = null;
// --- Self-host Banner ---
(function () {
var PUBLIC_HOSTS = ['xmrpay.link', 'mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion'];
var banner = $('#selfHostBanner');
var dismiss = $('#dismissBanner');
if (!banner) return;
if (PUBLIC_HOSTS.indexOf(location.hostname) === -1) return;
if (sessionStorage.getItem('banner_dismissed')) return;
banner.hidden = false;
dismiss.addEventListener('click', function () {
banner.hidden = true;
sessionStorage.setItem('banner_dismissed', '1');
});
})();
// --- Currency Detection --- // --- Currency Detection ---
function detectCurrency() { function detectCurrency() {
var localeToCurrency = { var localeToCurrency = {

View File

@@ -13,7 +13,7 @@ var I18n = (function () {
var VERSION = '1.0.0'; var VERSION = '1.0.0';
var footer = 'Open Source &middot; No Tracking &middot; No KYC<br /><a href="https://github.com/schmidt1024/xmrpay" 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><br /><span class="version">v' + VERSION + '</span>'; var footer = 'Open Source &middot; No Tracking &middot; No KYC<br /><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><br /><span class="version">v' + VERSION + '</span>';
var translations = { var translations = {
en: { en: {
@@ -41,7 +41,6 @@ var I18n = (function () {
pdf_scan_qr: 'Scan QR code to pay', pdf_scan_qr: 'Scan QR code to pay',
pdf_footer: 'Created with xmrpay.link', pdf_footer: 'Created with xmrpay.link',
qr_hint: 'Click QR to save', qr_hint: 'Click QR to save',
self_host_banner: 'This is a public demo. For real payments, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">host your own instance</a> — it takes 60 seconds.',
footer: footer, footer: footer,
aria_currency: 'Currency', aria_currency: 'Currency',
label_share_link: 'Shareable link', label_share_link: 'Shareable link',
@@ -93,7 +92,6 @@ var I18n = (function () {
pdf_date: 'Datum', pdf_date: 'Datum',
pdf_scan_qr: 'QR-Code scannen zum Bezahlen', pdf_scan_qr: 'QR-Code scannen zum Bezahlen',
pdf_footer: 'Erstellt mit xmrpay.link', pdf_footer: 'Erstellt mit xmrpay.link',
self_host_banner: 'Dies ist eine öffentliche Demo. Für echte Zahlungen <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">eigene Instanz hosten</a> — dauert 60 Sekunden.',
qr_hint: 'Klick auf QR zum Speichern', qr_hint: 'Klick auf QR zum Speichern',
footer: footer, footer: footer,
aria_currency: 'Währung', aria_currency: 'Währung',
@@ -146,7 +144,6 @@ var I18n = (function () {
pdf_date: 'Date', pdf_date: 'Date',
pdf_scan_qr: 'Scanner le QR code pour payer', pdf_scan_qr: 'Scanner le QR code pour payer',
pdf_footer: 'Créé avec xmrpay.link', pdf_footer: 'Créé avec xmrpay.link',
self_host_banner: 'Ceci est une démo publique. Pour de vrais paiements, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">hébergez votre propre instance</a> — ça prend 60 secondes.',
qr_hint: 'Cliquez sur le QR pour enregistrer', qr_hint: 'Cliquez sur le QR pour enregistrer',
footer: footer, footer: footer,
aria_currency: 'Devise', aria_currency: 'Devise',
@@ -199,7 +196,6 @@ var I18n = (function () {
pdf_date: 'Data', pdf_date: 'Data',
pdf_scan_qr: 'Scansiona il QR per pagare', pdf_scan_qr: 'Scansiona il QR per pagare',
pdf_footer: 'Creato con xmrpay.link', pdf_footer: 'Creato con xmrpay.link',
self_host_banner: 'Questa è una demo pubblica. Per pagamenti reali, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">ospita la tua istanza</a> — ci vogliono 60 secondi.',
qr_hint: 'Clicca sul QR per salvare', qr_hint: 'Clicca sul QR per salvare',
footer: footer, footer: footer,
aria_currency: 'Valuta', aria_currency: 'Valuta',
@@ -252,7 +248,6 @@ var I18n = (function () {
pdf_date: 'Fecha', pdf_date: 'Fecha',
pdf_scan_qr: 'Escanear QR para pagar', pdf_scan_qr: 'Escanear QR para pagar',
pdf_footer: 'Creado con xmrpay.link', pdf_footer: 'Creado con xmrpay.link',
self_host_banner: 'Esta es una demo pública. Para pagos reales, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">aloja tu propia instancia</a> — toma 60 segundos.',
qr_hint: 'Clic en QR para guardar', qr_hint: 'Clic en QR para guardar',
footer: footer, footer: footer,
aria_currency: 'Moneda', aria_currency: 'Moneda',
@@ -305,7 +300,6 @@ var I18n = (function () {
pdf_date: 'Data', pdf_date: 'Data',
pdf_scan_qr: 'Digitalizar QR para pagar', pdf_scan_qr: 'Digitalizar QR para pagar',
pdf_footer: 'Criado com xmrpay.link', pdf_footer: 'Criado com xmrpay.link',
self_host_banner: 'Esta é uma demo pública. Para pagamentos reais, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">hospede sua própria instância</a> — leva 60 segundos.',
qr_hint: 'Clique no QR para guardar', qr_hint: 'Clique no QR para guardar',
footer: footer, footer: footer,
aria_currency: 'Moeda', aria_currency: 'Moeda',
@@ -358,7 +352,6 @@ var I18n = (function () {
pdf_date: 'Дата', pdf_date: 'Дата',
pdf_scan_qr: 'Сканируйте QR для оплаты', pdf_scan_qr: 'Сканируйте QR для оплаты',
pdf_footer: 'Создано с помощью xmrpay.link', pdf_footer: 'Создано с помощью xmrpay.link',
self_host_banner: 'Это публичная демо-версия. Для реальных платежей <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">разверните свой экземпляр</a> — это займёт 60 секунд.',
qr_hint: 'Нажмите на QR для сохранения', qr_hint: 'Нажмите на QR для сохранения',
footer: footer, footer: footer,
aria_currency: 'Валюта', aria_currency: 'Валюта',

View File

@@ -12,11 +12,6 @@
</head> </head>
<body> <body>
<div class="self-host-banner" id="selfHostBanner" hidden>
<span data-i18n-html="self_host_banner">This is a public demo. For real payments, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">host your own instance</a> — it takes 60 seconds.</span>
<button id="dismissBanner" aria-label="Dismiss">&times;</button>
</div>
<header> <header>
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1> <h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
<p data-i18n="subtitle">Monero payment request in seconds</p> <p data-i18n="subtitle">Monero payment request in seconds</p>
@@ -120,7 +115,7 @@
</main> </main>
<footer> <footer>
<p data-i18n-html="footer">Open Source &middot; No Tracking &middot; No KYC<br /><a href="https://github.com/schmidt1024/xmrpay" 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><br /><span class="version">v1.0.0</span></p> <p data-i18n-html="footer">Open Source &middot; No Tracking &middot; No KYC<br /><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><br /><span class="version">v1.0.0</span></p>
</footer> </footer>
<div class="lang-picker" id="langPicker"> <div class="lang-picker" id="langPicker">
@@ -137,7 +132,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="lib/qrcode.min.js?v=20260326-3" integrity="sha384-3zSEDfvllQohrq0PHL1fOXJuC/jSOO34H46t6UQfobFOmxE5BpjjaIJY5F2/bMnU" crossorigin="anonymous" defer></script> <script src="lib/qrcode.min.js?v=20260326-3" integrity="sha384-3zSEDfvllQohrq0PHL1fOXJuC/jSOO34H46t6UQfobFOmxE5BpjjaIJY5F2/bMnU" crossorigin="anonymous" defer></script>
<script src="i18n.min.js?v=20260326-3" integrity="sha384-mKqQalrpTWCzSD1ErLtp+GqHpzJDm7D1jzHDMuhCW7ql2v9YkEzSfE4PNuTj4dqU" crossorigin="anonymous" defer></script> <script src="i18n.min.js?v=20260326-3" integrity="sha384-V43P6Gk3Zsd0cmqIx3V1Dffu0i3d3aFyb876gr5ez+1CIAzbFLFATfL1nuEZFzvC" crossorigin="anonymous" defer></script>
<script src="app.min.js?v=20260326-3" integrity="sha384-tdgiaUZYJ6E+/EqlbzOvxRvySKQZNdxjNktRV3K75fitMLdkR5DXuVU9XTppNku5" crossorigin="anonymous" defer></script> <script src="app.min.js?v=20260326-3" integrity="sha384-tdgiaUZYJ6E+/EqlbzOvxRvySKQZNdxjNktRV3K75fitMLdkR5DXuVU9XTppNku5" crossorigin="anonymous" defer></script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,7 @@ set -e
DOMAIN="${1:-}" DOMAIN="${1:-}"
INSTALL_DIR="/opt/xmrpay" INSTALL_DIR="/opt/xmrpay"
IMAGE="schmidt1024/xmrpay:latest" IMAGE="schmidt1024/xmrpay:latest"
COMPOSE_URL="https://raw.githubusercontent.com/schmidt1024/xmrpay/master/docker-compose.yml" COMPOSE_URL="https://raw.githubusercontent.com/schmidt1024/xmrpay.link/master/docker-compose.yml"
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────

View File

@@ -198,7 +198,7 @@
</main> </main>
<footer> <footer>
<p data-i18n-html="footer">Open Source &middot; No Tracking &middot; No KYC &middot; <a href="https://github.com/schmidt1024/xmrpay" 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> <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> </footer>
<div class="lang-picker" id="langPicker"> <div class="lang-picker" id="langPicker">

2
s.php
View File

@@ -1,5 +1,5 @@
<?php <?php
$pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) && $_SERVER['PATH_INFO'] !== '' ? $_SERVER['PATH_INFO'] : null; $pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : null;
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : ''; $queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
$code = trim($pathInfo ?? $queryCode, '/'); $code = trim($pathInfo ?? $queryCode, '/');

View File

@@ -58,40 +58,6 @@ body {
align-items: center; align-items: center;
} }
.self-host-banner {
background: var(--accent);
color: #fff;
text-align: center;
padding: 0.6rem 2.5rem 0.6rem 1rem;
font-size: 0.85rem;
line-height: 1.4;
position: relative;
}
.self-host-banner a {
color: #fff;
font-weight: 600;
text-decoration: underline;
}
.self-host-banner button {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #fff;
font-size: 1.2rem;
cursor: pointer;
opacity: 0.7;
padding: 0.25rem 0.5rem;
}
.self-host-banner button:hover {
opacity: 1;
}
header { header {
text-align: center; text-align: center;
padding: 2rem 1rem 1rem; padding: 2rem 1rem 1rem;