78 Commits

Author SHA1 Message Date
schmidt1024
604d6aa1f4 Update version to 1.2.1 in source files
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-30 16:50:37 +02:00
schmidt1024
acac49969d Fix critical CVEs by using official Caddy image instead of Alpine package
Copy Caddy binary from caddy:2-alpine multi-stage build to avoid
stale smallstep/certificates (CVE CVSS 10) and grpc vulnerabilities
shipped with the Alpine caddy package.
2026-03-30 16:39:15 +02:00
Alexander Schmidt
651e0d7ab0 Rebrand document.title from xmrpay.link to xmrpay
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 13:02:39 +01:00
Alexander Schmidt
e2b6684dcb Fix Turkish translation: add self_host_banner, fix tx_hash label, rates_offline, countdown unit 2026-03-27 13:00:48 +01:00
Schmidt
6149b52b42 Merge pull request #1 from barisbuyukakyol/master
Turkish translation added
2026-03-27 12:59:13 +01:00
Barış Büyükakyol
e7674475cf Update README.md 2026-03-27 14:06:24 +03:00
Barış Büyükakyol
3d917d386f Update i18n.min.js 2026-03-27 14:04:40 +03:00
Barış Büyükakyol
e1d9fcbf28 turkish translation added 2026-03-27 14:03:29 +03:00
Barış Büyükakyol
365871c077 Update privacy.html
turkish translation added
2026-03-27 13:59:45 +03:00
Alexander Schmidt
a5515a65f6 Remove inaccurate client-side claim from meta description 2026-03-27 11:00:42 +01:00
Alexander Schmidt
554286edfa Rebrand to xmrpay, improve meta description 2026-03-27 10:59:33 +01:00
Alexander Schmidt
487b5e9ec8 Fix privacy.html: add script-src to CSP so legal text renders 2026-03-27 10:49:39 +01:00
Alexander Schmidt
67a27f8f59 Fix banner z-index on mobile, update version to 1.1.1 2026-03-27 10:47:26 +01:00
Alexander Schmidt
de1b7b1074 Add Tor hidden service to Docker self-hosting setup
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 10:38:52 +01:00
Alexander Schmidt
41c332365b Add self-host banner and rewrite README for self-hosting focus
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 10:31:48 +01:00
Alexander Schmidt
d0b70acf39 Fix short URL redirect when PATH_INFO is empty string
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 10:09:08 +01:00
Alexander Schmidt
ffd9327e3e Allow self-hosted origins in API verification
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 09:44:11 +01:00
Alexander Schmidt
40b81a5dc8 Fix install.sh: correct GitHub repo name in compose URL 2026-03-27 09:32:34 +01:00
Alexander Schmidt
dc5582aa04 Point source links to GitHub repo
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-03-27 09:11:29 +01:00
Alexander Schmidt
643ced23e9 Fix GitHub Actions: add DOCKER environment, use Node.js 24 2026-03-27 09:09:38 +01:00
Alexander Schmidt
64eee4ebc5 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
2026-03-27 08:26:30 +01:00
Alexander Schmidt
5212f586c7 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
2026-03-27 08:06:57 +01:00
Alexander Schmidt
6fcc063ad9 Add version number to footer and fix line-height 2026-03-27 08:02:54 +01:00
Alexander Schmidt
2e71959fd1 Add line break in footer i18n string 2026-03-27 07:58:33 +01:00
Alexander Schmidt
25cb0e1a5d 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)
2026-03-27 07:55:54 +01:00
Alexander Schmidt
83e7d43a74 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)
2026-03-27 07:51:01 +01:00
Alexander Schmidt
2a3cc5682c Clarify trust model and wallet-native default in README 2026-03-26 15:28:29 +01:00
Alexander Schmidt
3aa8277530 Add wallet URI copy and shortlink trust toggle 2026-03-26 15:11:11 +01:00
Alexander Schmidt
6f43f34d68 Fix deploy dry-run flag and generalize env example 2026-03-26 14:52:31 +01:00
Alexander Schmidt
77bf794b73 Harden deployment with data backups and restore script 2026-03-26 14:25:35 +01:00
Alexander Schmidt
94c8ecb2aa Add deploy env ignore and example template 2026-03-26 14:15:04 +01:00
Alexander Schmidt
32d509fe9d Harden deploy script config handling 2026-03-26 14:07:07 +01:00
Alexander Schmidt
8ae736bbad Add safe deploy script preserving data directory 2026-03-26 13:55:59 +01:00
Alexander Schmidt
d01b7d0d27 Align privacy rate-limit wording with implementation 2026-03-26 13:53:07 +01:00
Alexander Schmidt
dddda450a7 Update privacy terms: no persistent IP records 2026-03-26 13:48:40 +01:00
Alexander Schmidt
758b2f3589 Preserve absolute invoice deadline across reloads 2026-03-26 13:43:30 +01:00
Alexander Schmidt
69f173bc2f Keep short URL in share field when loaded via short link 2026-03-26 13:40:16 +01:00
Alexander Schmidt
3dd1e55432 Refresh pending proof confirmations on status lookup 2026-03-26 13:28:40 +01:00
Alexander Schmidt
4b0cd3aaab Fix short link integrity check for code parameter 2026-03-26 13:26:05 +01:00
Alexander Schmidt
1e2ea6c24d Bump asset versions and rotate service worker cache 2026-03-26 13:24:18 +01:00
Alexander Schmidt
f6edc4cb58 Fix false short URL integrity warning 2026-03-26 13:22:34 +01:00
Alexander Schmidt
09a5ef703c Add yellow favicon badge for pending invoices 2026-03-26 13:20:11 +01:00
Alexander Schmidt
85039402a7 Regenerate minified translations for pending proof status 2026-03-26 13:15:33 +01:00
Alexander Schmidt
a2c3d8dd00 Add document-and-coin favicon concept and sync paid favicon state 2026-03-26 13:10:30 +01:00
Alexander Schmidt
9cc50188c0 Update README: mark auto-cleanup as complete, add Invoice Lifecycle section 2026-03-26 11:03:59 +01:00
Alexander Schmidt
0049077605 Add type annotations to fix Intelephense type checking errors 2026-03-26 11:03:15 +01:00
Alexander Schmidt
31623fd03e Update cache-busting version to 20260326-2 for cleanup feature 2026-03-26 11:02:20 +01:00
Alexander Schmidt
ee0d0d4124 Implement lazy-cleanup for expired invoices with deadline-based deletion 2026-03-26 11:01:32 +01:00
Alexander Schmidt
c4e3f3cd15 Add deadline cleanup feature to roadmap 2026-03-26 10:54:21 +01:00
Alexander Schmidt
6fd2d05163 Add cache-busting version params for frontend assets 2026-03-26 10:11:13 +01:00
Alexander Schmidt
d2684c3638 Fix paid/pending invoice status UI and date handling 2026-03-26 10:06:08 +01:00
Alexander Schmidt
dc330d2367 refactor: reuse shared style.css and language switcher on privacy page 2026-03-26 08:01:59 +01:00
Alexander Schmidt
2263fbf659 fix: harden PHP type handling across all endpoints 2026-03-26 07:57:11 +01:00
Alexander Schmidt
5d38946c53 feat: add multilingual privacy and terms page + footer link 2026-03-26 07:50:57 +01:00
Alexander Schmidt
59375e647c fix: footer 'Minimal Backend' → 'No Tracking' 2026-03-26 07:39:55 +01:00
Alexander Schmidt
761df8d26b fix: remove duplicate <?php tag in verify.php (HTTP 500) 2026-03-26 07:36:35 +01:00
Alexander Schmidt
4ac12eb083 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
403a08479c fix: remove duplicate <?php tag in check-short.php 2026-03-26 07:15:28 +01:00
Alexander Schmidt
2c3a8a0584 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
7e325abf7d 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
c1bd97948c docs: update README — 7 languages, 8 currencies, remove completed roadmap items 2026-03-25 18:28:35 +01:00
Alexander Schmidt
bde0e6f7e4 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
e7f3451f82 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
4c93e335f3 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
e36ec77bcd fix: PDF approx symbol — use ~ instead of unicode ≈ (unsupported by jsPDF Helvetica) 2026-03-25 17:23:32 +01:00
Alexander Schmidt
506c70e4b8 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
787168b248 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
cf3c43ff67 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
8d3e37239f 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
6a9a5b6a75 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
8bcdb33fa3 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
2026-03-25 16:50:55 +01:00
Alexander Schmidt
b8f2e24a42 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>
2026-03-25 16:32:50 +01:00
Alexander Schmidt
cf1b06b5c9 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
2026-03-25 09:52:22 +01:00
Alexander Schmidt
270a4a79a6 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.
2026-03-25 09:45:54 +01:00
Alexander Schmidt
32245fccdf 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
2026-03-25 09:37:09 +01:00
Alexander Schmidt
1acf990943 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
2026-03-25 09:09:46 +01:00
Alexander Schmidt
35552b7dff 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
2026-03-24 18:16:23 +01:00
Alexander Schmidt
bd796e46dc 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
2026-03-24 16:38:44 +01:00
36 changed files with 4847 additions and 158 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
data/
scripts/
branding/
README.md
LICENSE
nginx-security-headers.conf
docker-compose.yml
.dockerignore
.gitignore
app.js
i18n.js

98
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Build & Push Docker Image
on:
push:
tags:
- 'v*'
env:
IMAGE_NAME: xmrpay
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-latest
environment: DOCKER
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Inject version into source
run: |
sed -i "s|VERSION = '[^']*'|VERSION = '${{ steps.version.outputs.version }}'|" i18n.js
sed -i -E "s|(<span class=\"version\">v)[^<]*(</span>)|\1${{ steps.version.outputs.version }}\2|" index.html
- name: Minify JS
run: |
npm i -g terser
terser app.js -c -m -o app.min.js
terser i18n.js -c -m -o i18n.min.js
- name: Update SRI hashes
run: |
sri() { echo "sha384-$(openssl dgst -sha384 -binary "$1" | openssl base64 -A)"; }
H_STYLE=$(sri style.css)
H_QRCODE=$(sri lib/qrcode.min.js)
H_I18N=$(sri i18n.min.js)
H_JSPDF=$(sri lib/jspdf.min.js)
H_CRYPTO=$(sri lib/xmr-crypto.bundle.js)
# Update dynamic SRI in app.js and re-minify
sed -i -E \
-e "s|(jspdf\.min\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${H_JSPDF}|" \
-e "s|(xmr-crypto\.bundle\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${H_CRYPTO}|" \
app.js
terser app.js -c -m -o app.min.js
H_APP=$(sri app.min.js)
# Update index.html
sed -i -E \
-e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_STYLE}|" \
-e "s|(qrcode\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_QRCODE}|" \
-e "s|(i18n\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_I18N}|" \
-e "s|(app\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_APP}|" \
index.html
# Update privacy.html
sed -i -E \
-e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_STYLE}|" \
privacy.html
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
schmidt1024/${{ env.IMAGE_NAME }}:latest
schmidt1024/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest
ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
scripts/.deploy.env

29
Caddyfile Normal file
View File

@@ -0,0 +1,29 @@
(common) {
root * /srv
encode gzip
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'"
}
@shorturl path_regexp short ^/s/([a-zA-Z0-9]+)$
rewrite @shorturl /s.php?c={re.short.1}
php_fastcgi 127.0.0.1:9000
file_server
}
# Clearnet (auto-HTTPS)
{$DOMAIN:localhost} {
import common
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
# Tor hidden service (HTTP only, no TLS needed)
:8080 {
import common
}

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM caddy:2-alpine AS caddy
FROM php:8.3-fpm-alpine AS base
# Copy Caddy binary from official image (avoids stale Alpine package)
COPY --from=caddy /usr/bin/caddy /usr/sbin/caddy
# Install PHP curl extension (needed for API proxies)
RUN apk add --no-cache curl-dev \
&& docker-php-ext-install curl \
&& rm -rf /var/cache/apk/*
# PHP-FPM tuning for low-memory VPS
RUN { \
echo '[www]'; \
echo 'pm = ondemand'; \
echo 'pm.max_children = 8'; \
echo 'pm.process_idle_timeout = 60s'; \
} > /usr/local/etc/php-fpm.d/zz-tuning.conf
# App files
COPY index.html privacy.html style.css sw.js favicon.svg s.php /srv/
COPY app.min.js /srv/app.min.js
COPY i18n.min.js /srv/i18n.min.js
COPY api/ /srv/api/
COPY lib/ /srv/lib/
COPY fonts/ /srv/fonts/
# Writable data directory
RUN mkdir -p /srv/data && chown www-data:www-data /srv/data
# Caddyfile
COPY Caddyfile /etc/caddy/Caddyfile
# Entrypoint: start PHP-FPM + Caddy
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 80 443 8080
VOLUME ["/srv/data", "/data/caddy"]
ENTRYPOINT ["docker-entrypoint.sh"]

288
README.md
View File

@@ -1,172 +1,144 @@
# xmrpay.link — Serverless XMR Invoice Builder
# xmrpay — Monero Invoice Generator
> Privat. Selbst gehostet. Keine Accounts. Kein Backend. Kein Bullshit.
> Create Monero payment requests in seconds. No accounts. No tracking. No KYC.
**[Demo: xmrpay.link](https://xmrpay.link)** — for real payments, self-host your own instance.
---
## Idee
## Self-Host in 60 Seconds
**xmrpay.link** ist eine rein clientseitige Web-App, die es jedem ermöglicht,
in unter 30 Sekunden eine professionelle Monero-Zahlungsanforderung zu erstellen —
ohne eigenen Node, ohne Registration, ohne KYC, ohne Drittanbieter.
Du gibst deine Adresse ein, den Betrag, eine optionale Beschreibung —
und bekommst einen QR-Code, einen kopierbaren `monero:`-Link und eine
optionale PDF-Rechnung. Fertig.
---
## Das Problem (Warum es das noch nicht gibt)
| Lösung | Problem |
|---|---|
| **BTCPay Server** | Eigener Server nötig, komplexes Setup |
| **NOWPayments, Globee** | Custodial, KYC, Fees, Drittanbieter-Abhängigkeit |
| **Cake Wallet Invoice** | Mobil-only, kein Teilen ohne App |
| **MoneroPay** | Backend-Daemon nötig, nur für Entwickler |
| **Wallet-QR direkt** | Kein Betrag, keine Beschreibung, keine Bestätigung |
**Die Lücke:** Es gibt kein einfaches, datenschutzkonformes Tool für Freelancer,
kleine Händler und Creator, das ohne Setup funktioniert und trotzdem
Zahlungsbestätigung ermöglicht.
---
## Technologie-Stack
```
Frontend: HTML + Vanilla JS (oder leichtes Vue 3)
Crypto: monero-javascript (WASM, läuft im Browser)
Node: Frei wählbarer öffentlicher Remote Node (z.B. xmr.sh, node.community)
QR: QRCode.js (clientseitig)
PDF: jsPDF (clientseitig)
Hosting: Statische Site — GitHub Pages, Netlify, Vercel, Self-hosted
Backend: KEINES
Daten: LocalStorage (optional, nur lokal, nie übertragen)
```
**Kein PHP-Backend. Kein Node.js-Server. Kein Datenbank-Setup.**
Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann.
---
## Feature-Roadmap
### v1 — Der Kern (Static QR Generator)
- [ ] XMR-Adresse eingeben (mit Validierung)
- [ ] Betrag in XMR eingeben (optional: EUR/CHF/USD-Umrechnung via CoinGecko API)
- [ ] Beschreibung / Verwendungszweck
- [ ] Optionaler Countdown-Timer (Zahlungsfrist)
- [ ] `monero:`-URI generieren (Standard: [SLIP-0021](https://github.com/satoshilabs/slips/blob/master/slip-0021.md))
- [ ] QR-Code anzeigen und als PNG downloaden
- [ ] Link kopieren (für Messenger, E-Mail etc.)
- [ ] Responsive Design, Dark Mode
### v2 — View-Key Zahlungsbestätigung (Browser-basiert)
- [ ] View-Only-Key eingeben (privater Spend-Key bleibt lokal)
- [ ] Browser pollt Remote Node via Monero RPC (kein eigener Node nötig)
- [ ] Live-Anzeige: "Warte auf Zahlung..." → "✅ Zahlung eingegangen (X Bestätigungen)"
- [ ] Warnhinweis bei Unterzahlung
- [ ] Subaddress-Unterstützung (für mehrere parallele Rechnungen)
### v3 — Professionelle Features
- [ ] PDF-Rechnung generieren (Logo, Betrag in Fiat, XMR-Betrag, QR, Fälligkeitsdatum)
- [ ] Einbettbarer `<iframe>`-Widget für beliebige Websites
- [ ] Mehrsprachigkeit (DE, EN, FR, ES)
- [ ] Rechnungshistorie (LocalStorage, exportierbar als CSV)
- [ ] „Pay Button" Generator (HTML-Snippet zum Einbetten)
---
## Warum das zur Monero-Philosophie passt
- **Zero Trust:** App läuft im Browser, kein Server sieht Daten
- **Open Source:** MIT-Lizenz, forkbar, selbst hostbar
- **Keine Accounts:** Nichts zu registrieren, nichts zu verlieren
- **Kein KYC:** Weder Sender noch Empfänger müssen sich ausweisen
- **Kein Custody:** Coins gehen direkt in die eigene Wallet
- **Offline-fähig (v1):** QR-Generator funktioniert ohne Internetverbindung
---
## Abgrenzung zu bestehenden Tools
**Kein Konkurrenz zu BTCPay:** BTCPay ist für Shops mit hohem Volumen.
xmrpay.link ist für Einzelpersonen, Freelancer, Aktivisten — alle, die
schnell und ohne Overhead eine Zahlung anfragen wollen.
**Kein Konkurrenz zu Wallets:** Wallets bleiben die primäre Lösung
für den täglichen Gebrauch. xmrpay.link ergänzt mit Teil- und Präsentationslogik
(PDF, Link, Timer), die Wallets nicht bieten.
---
## Sicherheitshinweise (für Nutzer)
- Der **Spend-Key verlässt nie den Browser** — nur View-Key wird für Monitoring verwendet
- Remote Node sieht nur: "Wurde an diese Adresse gezahlt?" — keine Wallet-Zuordnung
- Für maximale Privatsphäre: eigenen Node via Tor verbinden (konfigurierbar)
- LocalStorage-Daten bleiben lokal — nichts wird übertragen
---
## Mögliche Domain-Namen
| Domain | Begründung |
|---|---|
| `xmrpay.link` | ⭐ Kurz, klar, `monero:` URI passt dazu, TLD `.link` passt perfekt |
| `xmr.invoice` | Elegant, aber `.invoice` TLD existiert nicht |
| `payxmr.dev` | Developer-affin, gut für GitHub-Kontext |
| `xmrbill.com` | Einprägsam, beschreibend |
| `monero.page` | Sauber, aber evtl. zu generisch |
| `xmrlink.io` | Klar, crypto-affine TLD |
**Empfehlung: `xmrpay.link`** — weil der Name sofort sagt was die App tut,
`.link` auf den Share-Gedanken einzahlt, und der Name kurz genug ist
um ihn mündlich weiterzugeben.
---
## Projektstruktur (geplant)
```
xmrpay.link/
├── index.html # Single-Page-App Entry Point
├── app.js # Haupt-Logik (URI-Builder, QR, Fiat-Kurs)
├── monitor.js # View-Key Monitoring (v2)
├── invoice.js # PDF-Generierung (v3)
├── style.css
├── lib/
│ ├── qrcode.min.js
│ ├── monero.js # monero-javascript WASM build
│ └── jspdf.min.js
├── README.md
└── LICENSE # MIT
```
---
## Beitragen / Entwickeln
You need a VPS with a domain pointing to it. Then:
```bash
git clone https://github.com/DEIN-USERNAME/xmrpay.link
cd xmrpay.link
# Keine Build-Tools nötig für v1 — einfach index.html im Browser öffnen
# Für v2+: kleines Dev-Server-Setup empfohlen (z.B. `npx serve .`)
curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com
```
Pull Requests willkommen. Issues auf GitHub. Kein Discord, kein Slack —
das Repo ist die Kommunikation.
Done. HTTPS is automatic (via Caddy + Let's Encrypt). A **Tor hidden service** (.onion) is included — the installer shows your onion address after setup.
### Requirements
| | Minimum | Recommended |
|---|---|---|
| **CPU** | 1 vCPU | 2 vCPU |
| **RAM** | 1 GB | 2 GB |
| **Disk** | 10 GB | 20 GB |
| **OS** | Any Linux with Docker | Ubuntu 22+, Debian 12+ |
| **Domain** | A-Record pointing to server IP | |
| **Cost** | ~3 EUR/month (Hetzner, Contabo, etc.) | |
### Updates
Watchtower runs alongside xmrpay and automatically pulls new images every 6 hours. No action needed.
Manual update:
```bash
cd /opt/xmrpay && docker compose pull && docker compose up -d
```
### Configuration
After install, the config is at `/opt/xmrpay/.env`:
```bash
DOMAIN=your-domain.com
XMRPAY_IMAGE=schmidt1024/xmrpay:latest
```
### Docker Images
| Registry | Pull command |
|---|---|
| Docker Hub | `docker pull schmidt1024/xmrpay:latest` |
| GitHub (GHCR) | `docker pull ghcr.io/schmidt1024/xmrpay:latest` |
### Manual Setup (without install script)
```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
# Show your onion address
docker exec xmrpay-tor cat /var/lib/tor/hidden_service/hostname
```
### Uninstall
```bash
cd /opt/xmrpay && docker compose down -v
```
---
## Lizenz
## 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
- **Invoice generation** — XMR address, amount (XMR or fiat), description, payment deadline
- **Wallet-native URI** — `monero:` URI with QR code, works with any Monero wallet
- **PDF invoice** — downloadable with QR, amount, fiat equivalent, deadline
- **Payment verification** — sender provides TX Hash + TX Key, cryptographic verification in browser
- **Fiat conversion** — EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected from locale
- **Short URLs** — optional, with explicit trust trade-off warning
- **i18n** — English, German, French, Italian, Spanish, Portuguese, Russian, Turkish
- **Offline-capable** — Service Worker for offline use
- **Privacy** — zero cookies, no analytics, no external scripts, self-hosted fonts
### Security
- **CSP** — Content Security Policy blocks exfiltration to foreign domains
- **SRI** — Subresource Integrity on all scripts, verified on every load
- **HMAC-signed short URLs** — detect server-side tampering
- **Rate-limited APIs** — all write endpoints rate-limited per IP
- **No private keys** — TX proof uses the sender's TX key, not the receiver's view key
- **Client-side crypto** — Ed25519 + Keccak-256 verification runs in browser
---
## Tech Stack
```
Frontend: HTML + Vanilla JS (no frameworks, no build step)
Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle)
QR: QRCode.js (client-side)
PDF: jsPDF (client-side, lazy-loaded)
Backend: Minimal PHP (URL shortener, rates proxy, proof storage)
Data: JSON files (no database)
Hosting: Caddy (auto-HTTPS) + PHP-FPM in Alpine Docker
```
---
## Development
```bash
git clone https://github.com/schmidt1024/xmrpay.git
cd xmrpay
# Local Docker build
docker build -t xmrpay:dev .
docker run -p 8080:80 -e DOMAIN=localhost xmrpay:dev
```
---
## License
MIT — fork it, host it, improve it.
---
*Gebaut mit ❤️ für die Monero-Community. Inspiriert von [monero.eco](https://monero.eco).*

100
api/_helpers.php Normal file
View File

@@ -0,0 +1,100 @@
<?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 {
$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 = [
$self_origin,
'https://xmrpay.link',
'http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion',
];
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
]);

145
api/node.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
require_once __DIR__ . '/_helpers.php';
/**
* Monero Daemon RPC Proxy
* Forwards allowed RPC requests to Monero nodes, bypassing CORS.
* The private view key NEVER passes through this proxy.
*/
header('Content-Type: application/json');
send_security_headers();
verify_origin();
// Only POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Nodes in priority order
$NODES = [
'http://node.xmr.rocks:18089',
'http://node.community.rino.io:18081',
'http://node.sethforprivacy.com:18089',
'http://xmr-node.cakewallet.com:18081',
];
// Allowed RPC methods (whitelist)
$ALLOWED_JSON_RPC = ['get_info', 'get_block', 'get_block_header_by_height'];
$ALLOWED_HTTP = ['get_transaction_pool', 'gettransactions', 'get_transaction_pool_hashes.bin'];
// Rate limiting (simple file-based, 60 requests/minute per IP)
$RATE_DIR = __DIR__ . '/../data/rate/';
if (!is_dir($RATE_DIR)) @mkdir($RATE_DIR, 0755, true);
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$rateFile = $RATE_DIR . md5($ip) . '.json';
$now = time();
$rateData = [];
if (file_exists($rateFile)) {
$rawRate = file_get_contents($rateFile);
$decodedRate = is_string($rawRate) ? json_decode($rawRate, true) : [];
$rateData = is_array($decodedRate) ? $decodedRate : [];
// Clean old entries
$rateData = array_values(array_filter($rateData, fn($t) => is_numeric($t) && (int)$t > $now - 60));
}
if (count($rateData) >= 60) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$rateData[] = $now;
file_put_contents($rateFile, json_encode($rateData));
// Parse request
$rawInput = file_get_contents('php://input');
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
if (!is_array($input) || !isset($input['method']) || !is_string($input['method'])) {
http_response_code(400);
echo json_encode(['error' => 'Missing method']);
exit;
}
$method = $input['method'];
$params = isset($input['params']) && is_array($input['params']) ? $input['params'] : [];
// Determine endpoint type
$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC);
$isHttp = in_array($method, $ALLOWED_HTTP);
if (!$isJsonRpc && !$isHttp) {
http_response_code(403);
echo json_encode(['error' => 'Method not allowed: ' . $method]);
exit;
}
// Cache last working node
$cacheFile = __DIR__ . '/../data/node_cache.json';
$cachedNode = null;
if (file_exists($cacheFile)) {
$rawCache = file_get_contents($cacheFile);
$cache = is_string($rawCache) ? json_decode($rawCache, true) : null;
if (is_array($cache) && ($cache['time'] ?? 0) > $now - 300 && isset($cache['node']) && is_string($cache['node'])) {
$cachedNode = $cache['node'];
}
}
// Order nodes: cached first
$orderedNodes = $NODES;
if ($cachedNode && in_array($cachedNode, $NODES)) {
$orderedNodes = array_merge([$cachedNode], array_filter($NODES, fn($n) => $n !== $cachedNode));
}
// Try nodes
$lastError = '';
foreach ($orderedNodes as $node) {
if ($isJsonRpc) {
$url = $node . '/json_rpc';
$body = json_encode([
'jsonrpc' => '2.0',
'id' => '0',
'method' => $method,
'params' => (object)$params
]);
} else {
$url = $node . '/' . $method;
$body = json_encode((object)$params);
}
$ch = curl_init($url);
if ($ch === false) {
$lastError = 'cURL init failed';
continue;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response !== false && $httpCode >= 200 && $httpCode < 300) {
// Cache this working node
file_put_contents($cacheFile, json_encode(['node' => $node, 'time' => $now]));
echo $response;
exit;
}
$lastError = $curlError ?: "HTTP $httpCode";
}
// All nodes failed
http_response_code(502);
echo json_encode(['error' => 'All nodes unreachable', 'detail' => $lastError]);

63
api/rates.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
header('Content-Type: application/json');
// Server-side cache: fetch from CoinGecko at most once per 2 minutes
$cacheFile = __DIR__ . '/../data/rates_cache.json';
$cacheTTL = 120; // seconds
if (file_exists($cacheFile)) {
$rawCached = file_get_contents($cacheFile);
$cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
$cachedTime = is_array($cached) && isset($cached['_time']) && is_numeric($cached['_time']) ? (int)$cached['_time'] : 0;
if (is_array($cached) && (time() - $cachedTime) < $cacheTTL) {
unset($cached['_time']);
header('Cache-Control: public, max-age=60');
echo json_encode($cached);
exit;
}
}
$currencies = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : 'eur,usd,chf,gbp,jpy,rub,brl';
$currencies = preg_replace('/[^a-z,]/', '', strtolower($currencies));
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=' . $currencies;
$ch = curl_init($url);
if ($ch === false) {
http_response_code(502);
echo json_encode(['error' => 'Failed to initialize request']);
exit;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: xmrpay.link/1.0'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response !== false && $httpCode === 200) {
$data = json_decode($response, true);
if (is_array($data)) {
$data['_time'] = time();
file_put_contents($cacheFile, json_encode($data));
unset($data['_time']);
header('Cache-Control: public, max-age=60');
echo json_encode($data);
exit;
}
}
// On error, serve stale cache if available
if (file_exists($cacheFile)) {
$rawCached = file_get_contents($cacheFile);
$cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
if (is_array($cached)) {
unset($cached['_time']);
header('Cache-Control: public, max-age=30');
echo json_encode($cached);
exit;
}
}
http_response_code(502);
echo json_encode(['error' => 'Failed to fetch rates']);

79
api/shorten.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/_helpers.php';
header('Content-Type: application/json');
send_security_headers();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
verify_origin();
if (!check_rate_limit('shorten', 20, 3600)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$dbFile = __DIR__ . '/../data/urls.json';
$rawInput = file_get_contents('php://input');
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
$hash = is_array($input) && isset($input['hash']) && is_string($input['hash']) ? $input['hash'] : '';
$expiryTs = is_array($input) && isset($input['expiry_ts']) ? intval($input['expiry_ts']) : 0;
if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,500}$/', $hash)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid data']);
exit;
}
$secret = get_hmac_secret();
[$fp, $urls] = read_json_locked($dbFile);
if (!is_array($urls)) {
$urls = [];
}
// Check if this hash already exists
foreach ($urls as $code => $data) {
$stored_hash = is_array($data) ? ($data['h'] ?? null) : $data;
if (!is_string($stored_hash)) {
continue;
}
if ($stored_hash === $hash) {
flock($fp, LOCK_UN);
fclose($fp);
echo json_encode(['code' => $code]);
exit;
}
}
// Generate short code (6 chars)
function generateCode(int $length = 6): string {
$chars = 'abcdefghijkmnpqrstuvwxyz23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return $code;
}
$code = generateCode();
while (isset($urls[$code])) {
$code = generateCode();
}
$signature = hash_hmac('sha256', $hash, $secret);
$urls[$code] = ['h' => $hash, 's' => $signature];
if ($expiryTs > 0) {
$urls[$code]['e'] = $expiryTs;
}
write_json_locked($fp, $urls);
echo json_encode(['code' => $code]);

219
api/verify.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
require_once __DIR__ . '/_helpers.php';
/**
* TX Proof Storage API
* POST: Store verified payment proof for an invoice
* GET: Retrieve payment status for an invoice
*
* Privacy: Only TX hash, amount, and confirmations are stored.
* Payee address is NEVER stored — verification happens client-side only.
*/
header('Content-Type: application/json');
send_security_headers();
$dbFile = __DIR__ . '/../data/proofs.json';
function fetch_transaction_confirmations(string $txHash): ?int {
$nodes = [
'http://node.xmr.rocks:18089',
'http://node.community.rino.io:18081',
'http://node.sethforprivacy.com:18089',
'http://xmr-node.cakewallet.com:18081',
];
foreach ($nodes as $node) {
$ch = curl_init($node . '/gettransactions');
if ($ch === false) {
continue;
}
$body = json_encode((object)['txs_hashes' => [$txHash]]);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
continue;
}
$decoded = json_decode($response, true);
if (!is_array($decoded) || !isset($decoded['txs']) || !is_array($decoded['txs']) || !isset($decoded['txs'][0])) {
continue;
}
$tx = $decoded['txs'][0];
if (!is_array($tx)) {
continue;
}
return intval($tx['confirmations'] ?? 0);
}
return null;
}
// GET: Retrieve proof
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (!check_rate_limit('verify_get', 30, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
echo json_encode(['verified' => false]);
exit;
}
$rawProofs = file_exists($dbFile) ? file_get_contents($dbFile) : null;
$decodedProofs = is_string($rawProofs) ? json_decode($rawProofs, true) : [];
$proofs = is_array($decodedProofs) ? $decodedProofs : [];
if (isset($proofs[$code])) {
$proofEntry = $proofs[$code];
$proofExpiry = is_array($proofEntry) ? intval($proofEntry['e'] ?? 0) : 0;
// Check if proof has expired (lazy cleanup)
if ($proofExpiry > 0 && time() > $proofExpiry) {
unset($proofs[$code]);
[$fp, $allProofs] = read_json_locked($dbFile);
/** @var array<string, mixed> $allProofs */
if (isset($allProofs[$code])) {
unset($allProofs[$code]);
write_json_locked($fp, $allProofs);
}
echo json_encode(['verified' => false]);
} else {
if (is_array($proofEntry) && ($proofEntry['status'] ?? 'paid') === 'pending' && isset($proofEntry['tx_hash']) && is_string($proofEntry['tx_hash'])) {
$latestConfirmations = fetch_transaction_confirmations($proofEntry['tx_hash']);
if ($latestConfirmations !== null && $latestConfirmations > intval($proofEntry['confirmations'] ?? 0)) {
$proofEntry['confirmations'] = $latestConfirmations;
if ($latestConfirmations >= 10) {
$proofEntry['status'] = 'paid';
}
[$fp, $allProofs] = read_json_locked($dbFile);
/** @var array<string, mixed> $allProofs */
$allProofs[$code] = $proofEntry;
write_json_locked($fp, $allProofs);
}
}
$response = ['verified' => true];
if (is_array($proofEntry)) {
foreach ($proofEntry as $k => $v) {
if (is_string($k)) {
$response[$k] = $v;
}
}
}
echo json_encode($response);
}
} else {
echo json_encode(['verified' => false]);
}
exit;
}
// POST: Store proof
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
verify_origin();
if (!check_rate_limit('verify_post', 10, 3600)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$rawInput = file_get_contents('php://input');
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
if (!is_array($input)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
$code = isset($input['code']) && is_string($input['code']) ? $input['code'] : '';
$txHash = isset($input['tx_hash']) && is_string($input['tx_hash']) ? $input['tx_hash'] : '';
$amount = floatval($input['amount'] ?? 0);
$confirmations = intval($input['confirmations'] ?? 0);
// Validate
if (!preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid code']);
exit;
}
if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid tx_hash']);
exit;
}
// Verify the short URL code exists (read-only, no lock needed here)
$urlsFile = __DIR__ . '/../data/urls.json';
if (!file_exists($urlsFile)) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
$rawUrls = file_get_contents($urlsFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
// Store proof with atomic lock
[$fp, $proofs] = read_json_locked($dbFile);
if (!is_array($proofs)) {
$proofs = [];
}
$status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid';
// Allow overwriting a pending proof with more confirmations or a final paid status
if (isset($proofs[$code])) {
$existing = $proofs[$code];
$canOverwrite = ($existing['status'] ?? 'paid') === 'pending'
&& ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0));
if (!$canOverwrite) {
flock($fp, LOCK_UN);
fclose($fp);
echo json_encode(['ok' => true]);
exit;
}
}
$proofs[$code] = [
'tx_hash' => strtolower($txHash),
'amount' => $amount,
'confirmations' => $confirmations,
'status' => $status,
'verified_at' => time()
];
// Copy expiry timestamp from URL if it exists
if (isset($urls[$code]) && is_array($urls[$code]) && isset($urls[$code]['e']) && $urls[$code]['e'] > 0) {
$proofs[$code]['e'] = $urls[$code]['e'];
}
write_json_locked($fp, $proofs);
echo json_encode(['ok' => true]);

1165
app.js Normal file

File diff suppressed because it is too large Load Diff

1
app.min.js vendored Normal file

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

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
xmrpay:
image: ${XMRPAY_IMAGE:-schmidt1024/xmrpay:latest}
container_name: xmrpay
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- DOMAIN=${DOMAIN:-localhost}
volumes:
- xmrpay-data:/srv/data
- caddy-data:/data/caddy
tor:
image: alpine:latest
container_name: xmrpay-tor
restart: unless-stopped
depends_on:
- xmrpay
entrypoint: /bin/sh
command:
- -c
- |
apk add --no-cache tor > /dev/null 2>&1
mkdir -p /var/lib/tor/hidden_service
chmod 700 /var/lib/tor/hidden_service
cat > /etc/tor/torrc <<EOF
SocksPort 0
HiddenServiceDir /var/lib/tor/hidden_service
HiddenServicePort 80 xmrpay:8080
EOF
echo "Starting Tor..."
tor -f /etc/tor/torrc
volumes:
- tor-keys:/var/lib/tor/hidden_service
watchtower:
image: containrrr/watchtower
container_name: watchtower
restart: unless-stopped
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=21600
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
xmrpay-data:
caddy-data:
tor-keys:

8
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
# Start PHP-FPM in background
php-fpm &
# Run Caddy in foreground
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile

18
favicon.svg Normal file
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

BIN
fonts/inter-400.woff2 Normal file

Binary file not shown.

BIN
fonts/inter-cyrillic.woff2 Normal file

Binary file not shown.

BIN
fonts/jetbrains-400.woff2 Normal file

Binary file not shown.

560
i18n.js Normal file
View File

@@ -0,0 +1,560 @@
var I18n = (function () {
'use strict';
var languages = {
en: { name: 'English' },
de: { name: 'Deutsch' },
fr: { name: 'Français' },
it: { name: 'Italiano' },
es: { name: 'Español' },
pt: { name: 'Português' },
ru: { name: 'Русский' },
tr: { name: 'Türkçe' }
};
var VERSION = '1.2.1';
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 translations = {
en: {
subtitle: 'Monero payment request in seconds',
label_addr: 'XMR Address',
placeholder_addr: '8...',
label_amount: 'Amount',
label_desc: 'Description (optional)',
placeholder_desc: 'e.g. Invoice #42, freelance work...',
label_timer: 'Payment deadline (optional)',
days: 'days',
placeholder_timer_custom: 'Days',
btn_generate: 'Create payment request',
btn_open_wallet: 'Open in wallet',
btn_copy_uri: 'Copy payment URI',
btn_copy_addr: 'Copy address',
btn_download_pdf: 'PDF Invoice',
pdf_title: 'Payment Request',
pdf_address: 'XMR Address',
pdf_amount: 'Amount',
pdf_desc: 'Description',
pdf_deadline: 'Payment deadline',
pdf_deadline_days: '{d} days',
pdf_date: 'Date',
pdf_scan_qr: 'Scan QR code to pay',
pdf_footer: 'Created with xmrpay',
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,
aria_currency: 'Currency',
label_share_link: 'Shareable link',
shortlink_toggle_label: 'Use short link (requires server trust)',
shortlink_toggle_hint: 'Trade-off: short links are convenient, but a compromised server could swap invoice data on first access.',
btn_new_request: 'New payment request',
toast_copied: 'Copied!',
countdown_expired: 'Payment deadline expired',
countdown_remaining_days: 'Deadline: {d} days, {h} hrs',
countdown_remaining_hours: 'Deadline: {h}:{m} hrs',
rates_offline: 'Rates unavailable — XMR amount only',
btn_prove_payment: 'Prove payment',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 hex characters...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 hex characters...',
btn_verify_proof: 'Verify payment',
proof_verifying: 'Verifying...',
proof_verified: 'Payment confirmed: {amount} XMR',
proof_no_match: 'No matching output — TX key or address mismatch',
proof_tx_not_found: 'Transaction not found',
proof_error: 'Verification error',
status_paid: 'Paid',
status_pending: 'Pending',
proof_confirmed_pending: 'Output found: {amount} XMR — {n}/10 confirmations. Auto-refreshing…',
toast_integrity_warning: 'Warning: signature mismatch detected'
},
de: {
subtitle: 'Monero-Zahlungsanforderung in Sekunden',
label_addr: 'XMR-Adresse',
placeholder_addr: '8...',
label_amount: 'Betrag',
label_desc: 'Beschreibung (optional)',
placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...',
label_timer: 'Zahlungsfrist (optional)',
days: 'Tage',
placeholder_timer_custom: 'Tage',
btn_generate: 'Zahlungsanforderung erstellen',
btn_open_wallet: 'In Wallet öffnen',
btn_copy_uri: 'Zahlungs-URI kopieren',
btn_copy_addr: 'Adresse kopieren',
btn_download_pdf: 'PDF Rechnung',
pdf_title: 'Zahlungsanforderung',
pdf_address: 'XMR-Adresse',
pdf_amount: 'Betrag',
pdf_desc: 'Beschreibung',
pdf_deadline: 'Zahlungsfrist',
pdf_deadline_days: '{d} Tage',
pdf_date: 'Datum',
pdf_scan_qr: 'QR-Code scannen zum Bezahlen',
pdf_footer: 'Erstellt mit xmrpay',
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',
footer: footer,
aria_currency: 'Währung',
label_share_link: 'Teilbarer Link',
shortlink_toggle_label: 'Kurzlink verwenden (Server-Vertrauen erforderlich)',
shortlink_toggle_hint: 'Trade-off: Kurzlinks sind bequem, aber ein kompromittierter Server könnte Rechnungsdaten beim ersten Aufruf austauschen.',
btn_new_request: 'Neue Zahlungsanforderung',
toast_copied: 'Kopiert!',
countdown_expired: 'Zahlungsfrist abgelaufen',
countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h} Std.',
countdown_remaining_hours: 'Zahlungsfrist: {h}:{m} Std.',
rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich',
btn_prove_payment: 'Zahlung nachweisen',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 Hex-Zeichen...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 Hex-Zeichen...',
btn_verify_proof: 'Zahlung verifizieren',
proof_verifying: 'Verifiziere...',
proof_verified: 'Zahlung bestätigt: {amount} XMR',
proof_no_match: 'Kein passender Output — TX Key oder Adresse stimmt nicht',
proof_tx_not_found: 'Transaktion nicht gefunden',
proof_error: 'Fehler bei der Verifizierung',
status_paid: 'Bezahlt',
status_pending: 'Ausstehend',
proof_confirmed_pending: 'Output gefunden: {amount} XMR — {n}/10 Bestätigungen. Wird aktualisiert…',
toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt'
},
fr: {
subtitle: 'Demande de paiement Monero en quelques secondes',
label_addr: 'Adresse XMR',
placeholder_addr: '8...',
label_amount: 'Montant',
label_desc: 'Description (facultatif)',
placeholder_desc: 'ex. Facture #42, travail freelance...',
label_timer: 'Date limite de paiement (facultatif)',
days: 'jours',
placeholder_timer_custom: 'Jours',
btn_generate: 'Créer une demande de paiement',
btn_open_wallet: 'Ouvrir dans le wallet',
btn_copy_uri: 'Copier l\'URI de paiement',
btn_copy_addr: 'Copier l\'adresse',
btn_download_pdf: 'Facture PDF',
pdf_title: 'Demande de paiement',
pdf_address: 'Adresse XMR',
pdf_amount: 'Montant',
pdf_desc: 'Description',
pdf_deadline: 'Date limite de paiement',
pdf_deadline_days: '{d} jours',
pdf_date: 'Date',
pdf_scan_qr: 'Scanner le QR code pour payer',
pdf_footer: 'Créé avec xmrpay',
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',
footer: footer,
aria_currency: 'Devise',
label_share_link: 'Lien partageable',
shortlink_toggle_label: 'Utiliser un lien court (confiance serveur requise)',
shortlink_toggle_hint: 'Compromis: les liens courts sont pratiques, mais un serveur compromis pourrait remplacer les donnees de facture au premier acces.',
btn_new_request: 'Nouvelle demande de paiement',
toast_copied: 'Copié !',
countdown_expired: 'Délai de paiement expiré',
countdown_remaining_days: 'Délai : {d} jours, {h} h',
countdown_remaining_hours: 'Délai : {h}:{m} h',
rates_offline: 'Taux indisponibles — montant en XMR uniquement',
btn_prove_payment: 'Prouver le paiement',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 caractères hexadécimaux...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 caractères hexadécimaux...',
btn_verify_proof: 'Vérifier le paiement',
proof_verifying: 'Vérification...',
proof_verified: 'Paiement confirmé : {amount} XMR',
proof_no_match: 'Aucun output correspondant — TX Key ou adresse incorrecte',
proof_tx_not_found: 'Transaction introuvable',
proof_error: 'Erreur de vérification',
status_paid: 'Payé',
status_pending: 'En attente',
proof_confirmed_pending: 'Sortie trouvée : {amount} XMR — {n}/10 confirmations. Actualisation automatique…',
toast_integrity_warning: 'Avertissement : détection d\'une non-concordance de signature'
},
it: {
subtitle: 'Richiesta di pagamento Monero in pochi secondi',
label_addr: 'Indirizzo XMR',
placeholder_addr: '8...',
label_amount: 'Importo',
label_desc: 'Descrizione (facoltativo)',
placeholder_desc: 'es. Fattura #42, lavoro freelance...',
label_timer: 'Scadenza pagamento (facoltativo)',
days: 'giorni',
placeholder_timer_custom: 'Giorni',
btn_generate: 'Crea richiesta di pagamento',
btn_open_wallet: 'Apri nel wallet',
btn_copy_uri: 'Copia URI pagamento',
btn_copy_addr: 'Copia indirizzo',
btn_download_pdf: 'Fattura PDF',
pdf_title: 'Richiesta di pagamento',
pdf_address: 'Indirizzo XMR',
pdf_amount: 'Importo',
pdf_desc: 'Descrizione',
pdf_deadline: 'Scadenza pagamento',
pdf_deadline_days: '{d} giorni',
pdf_date: 'Data',
pdf_scan_qr: 'Scansiona il QR per pagare',
pdf_footer: 'Creato con xmrpay',
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',
footer: footer,
aria_currency: 'Valuta',
label_share_link: 'Link condivisibile',
shortlink_toggle_label: 'Usa link breve (richiede fiducia nel server)',
shortlink_toggle_hint: 'Compromesso: i link brevi sono comodi, ma un server compromesso potrebbe sostituire i dati fattura al primo accesso.',
btn_new_request: 'Nuova richiesta di pagamento',
toast_copied: 'Copiato!',
countdown_expired: 'Scadenza pagamento superata',
countdown_remaining_days: 'Scadenza: {d} giorni, {h} ore',
countdown_remaining_hours: 'Scadenza: {h}:{m} ore',
rates_offline: 'Tassi non disponibili — solo importo in XMR',
btn_prove_payment: 'Dimostra pagamento',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 caratteri esadecimali...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 caratteri esadecimali...',
btn_verify_proof: 'Verifica pagamento',
proof_verifying: 'Verifica in corso...',
proof_verified: 'Pagamento confermato: {amount} XMR',
proof_no_match: 'Nessun output corrispondente — TX Key o indirizzo errato',
proof_tx_not_found: 'Transazione non trovata',
proof_error: 'Errore di verifica',
status_paid: 'Pagato',
status_pending: 'In attesa',
proof_confirmed_pending: 'Output trovato: {amount} XMR — {n}/10 conferme. Aggiornamento automatico…',
toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma'
},
es: {
subtitle: 'Solicitud de pago Monero en segundos',
label_addr: 'Dirección XMR',
placeholder_addr: '8...',
label_amount: 'Monto',
label_desc: 'Descripción (opcional)',
placeholder_desc: 'ej. Factura #42, trabajo freelance...',
label_timer: 'Plazo de pago (opcional)',
days: 'días',
placeholder_timer_custom: 'Días',
btn_generate: 'Crear solicitud de pago',
btn_open_wallet: 'Abrir en wallet',
btn_copy_uri: 'Copiar URI de pago',
btn_copy_addr: 'Copiar dirección',
btn_download_pdf: 'Factura PDF',
pdf_title: 'Solicitud de pago',
pdf_address: 'Dirección XMR',
pdf_amount: 'Monto',
pdf_desc: 'Descripción',
pdf_deadline: 'Plazo de pago',
pdf_deadline_days: '{d} días',
pdf_date: 'Fecha',
pdf_scan_qr: 'Escanear QR para pagar',
pdf_footer: 'Creado con xmrpay',
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',
footer: footer,
aria_currency: 'Moneda',
label_share_link: 'Enlace compartible',
shortlink_toggle_label: 'Usar enlace corto (requiere confiar en el servidor)',
shortlink_toggle_hint: 'Compromiso: los enlaces cortos son comodos, pero un servidor comprometido podria cambiar los datos de la factura en el primer acceso.',
btn_new_request: 'Nueva solicitud de pago',
toast_copied: '¡Copiado!',
countdown_expired: 'Plazo de pago vencido',
countdown_remaining_days: 'Plazo: {d} días, {h} h',
countdown_remaining_hours: 'Plazo: {h}:{m} h',
rates_offline: 'Tasas no disponibles — solo monto en XMR',
btn_prove_payment: 'Demostrar pago',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 caracteres hexadecimales...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 caracteres hexadecimales...',
btn_verify_proof: 'Verificar pago',
proof_verifying: 'Verificando...',
proof_verified: 'Pago confirmado: {amount} XMR',
proof_no_match: 'Ningún output coincidente — TX Key o dirección incorrecta',
proof_tx_not_found: 'Transacción no encontrada',
proof_error: 'Error de verificación',
status_paid: 'Pagado',
status_pending: 'Pendiente',
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmaciones. Actualización automática…',
toast_integrity_warning: 'Advertencia: desajuste de firma detectado'
},
pt: {
subtitle: 'Pedido de pagamento Monero em segundos',
label_addr: 'Endereço XMR',
placeholder_addr: '8...',
label_amount: 'Valor',
label_desc: 'Descrição (opcional)',
placeholder_desc: 'ex. Fatura #42, trabalho freelance...',
label_timer: 'Prazo de pagamento (opcional)',
days: 'dias',
placeholder_timer_custom: 'Dias',
btn_generate: 'Criar pedido de pagamento',
btn_open_wallet: 'Abrir na wallet',
btn_copy_uri: 'Copiar URI de pagamento',
btn_copy_addr: 'Copiar endereço',
btn_download_pdf: 'Fatura PDF',
pdf_title: 'Pedido de pagamento',
pdf_address: 'Endereço XMR',
pdf_amount: 'Valor',
pdf_desc: 'Descrição',
pdf_deadline: 'Prazo de pagamento',
pdf_deadline_days: '{d} dias',
pdf_date: 'Data',
pdf_scan_qr: 'Digitalizar QR para pagar',
pdf_footer: 'Criado com xmrpay',
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',
footer: footer,
aria_currency: 'Moeda',
label_share_link: 'Link partilhável',
shortlink_toggle_label: 'Usar link curto (requer confianca no servidor)',
shortlink_toggle_hint: 'Compromisso: links curtos sao praticos, mas um servidor comprometido pode trocar os dados da fatura no primeiro acesso.',
btn_new_request: 'Novo pedido de pagamento',
toast_copied: 'Copiado!',
countdown_expired: 'Prazo de pagamento expirado',
countdown_remaining_days: 'Prazo: {d} dias, {h} h',
countdown_remaining_hours: 'Prazo: {h}:{m} h',
rates_offline: 'Taxas indisponíveis — apenas valor em XMR',
btn_prove_payment: 'Comprovar pagamento',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 caracteres hexadecimais...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 caracteres hexadecimais...',
btn_verify_proof: 'Verificar pagamento',
proof_verifying: 'A verificar...',
proof_verified: 'Pagamento confirmado: {amount} XMR',
proof_no_match: 'Nenhum output correspondente — TX Key ou endereço incorreto',
proof_tx_not_found: 'Transação não encontrada',
proof_error: 'Erro de verificação',
status_paid: 'Pago',
status_pending: 'Pendente',
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmações. Atualização automática…',
toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada'
},
ru: {
subtitle: 'Запрос на оплату Monero за секунды',
label_addr: 'Адрес XMR',
placeholder_addr: '8...',
label_amount: 'Сумма',
label_desc: 'Описание (необязательно)',
placeholder_desc: 'напр. Счёт #42, фриланс...',
label_timer: 'Срок оплаты (необязательно)',
days: 'дней',
placeholder_timer_custom: 'Дней',
btn_generate: 'Создать запрос на оплату',
btn_open_wallet: 'Открыть в кошельке',
btn_copy_uri: 'Копировать платежный URI',
btn_copy_addr: 'Копировать адрес',
btn_download_pdf: 'PDF счёт',
pdf_title: 'Запрос на оплату',
pdf_address: 'Адрес XMR',
pdf_amount: 'Сумма',
pdf_desc: 'Описание',
pdf_deadline: 'Срок оплаты',
pdf_deadline_days: '{d} дней',
pdf_date: 'Дата',
pdf_scan_qr: 'Сканируйте QR для оплаты',
pdf_footer: 'Создано с помощью xmrpay',
self_host_banner: 'Это публичная демо-версия. Для реальных платежей <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">разверните свой экземпляр</a> — это займёт 60 секунд.',
qr_hint: 'Нажмите на QR для сохранения',
footer: footer,
aria_currency: 'Валюта',
label_share_link: 'Ссылка для отправки',
shortlink_toggle_label: 'Использовать короткую ссылку (нужно доверять серверу)',
shortlink_toggle_hint: 'Компромисс: короткие ссылки удобны, но скомпрометированный сервер может подменить данные счета при первом открытии.',
btn_new_request: 'Новый запрос на оплату',
toast_copied: 'Скопировано!',
countdown_expired: 'Срок оплаты истёк',
countdown_remaining_days: 'Срок: {d} дней, {h} ч',
countdown_remaining_hours: 'Срок: {h}:{m} ч',
rates_offline: 'Курсы недоступны — только сумма в XMR',
btn_prove_payment: 'Подтвердить оплату',
label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_tx_hash: '64 шестнадцатеричных символа...',
label_tx_key: 'Transaction Key (TX Key)',
placeholder_tx_key: '64 шестнадцатеричных символа...',
btn_verify_proof: 'Проверить оплату',
proof_verifying: 'Проверка...',
proof_verified: 'Оплата подтверждена: {amount} XMR',
proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес',
proof_tx_not_found: 'Транзакция не найдена',
proof_error: 'Ошибка проверки',
status_paid: 'Оплачено',
status_pending: 'Ожидание',
proof_confirmed_pending: 'Выход найден: {amount} XMR — {n}/10 подтверждений. Авт. обновление…',
toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи'
},
tr: {
subtitle: 'Saniyeler içerisinde Monero ödeme talebi',
label_addr: 'XMR Adresi',
placeholder_addr: '8...',
label_amount: 'Tutar',
label_desc: 'Açıklama (isteğe bağlı)',
placeholder_desc: 'örn. 42 numaralı Fatura, freelance iş için...',
label_timer: 'Ödeme için son tarih (isteğe bağlı)',
days: 'gün',
placeholder_timer_custom: 'Gün',
btn_generate: 'Ödeme talebi oluştur',
btn_open_wallet: 'Cüzdanda aç',
btn_copy_uri: 'URI kopyala',
btn_copy_addr: 'Adresi kopyala',
btn_download_pdf: 'Fatura (PDF)',
pdf_title: 'Ödeme Talebi',
pdf_address: 'XMR Adresi',
pdf_amount: 'Tutar',
pdf_desc: 'Açıklama',
pdf_deadline: 'Ödeme için son tarih',
pdf_deadline_days: '{d} gün',
pdf_date: 'Tarih',
pdf_scan_qr: 'Ödeme için QR kodu tara',
pdf_footer: 'xmrpay ile oluşturulmuştur',
qr_hint: 'QR kodu kaydetmek için tıkla',
self_host_banner: 'Bu bir herkese açık demodur. Gerçek ödemeler için <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">kendi sunucunuzu kurun</a> — sadece 60 saniye sürer.',
footer: footer,
aria_currency: 'Para Birimi',
label_share_link: 'Paylaşılabilir bağlantı',
shortlink_toggle_label: 'Kısaltılmış bağlantı kullan (sunucuya güven gerektirir)',
shortlink_toggle_hint: 'Olası dezavantaj: kısaltılmış bağlantılar kullanışlıdır, fakat ilgili sunucu güvende değil ise ilk erişimde fatura verileri değiştirilebilir.',
btn_new_request: 'Yeni ödeme talebi',
toast_copied: 'Kopyalandı!',
countdown_expired: 'Ödeme için son tarih süresi doldu',
countdown_remaining_days: 'Son Tarih: {d} gün, {h} saat',
countdown_remaining_hours: 'Son Tarih: {h}:{m} saat',
rates_offline: 'Kurlar mevcut değil — Yalnızca XMR tutarı',
btn_prove_payment: 'Ödemeyi doğrula',
label_tx_hash: 'İşlem kimliği (TX Hash)',
placeholder_tx_hash: '64 hex karakteri...',
label_tx_key: 'İşlem Anahtarı (TX Key)',
placeholder_tx_key: '64 hex karakteri...',
btn_verify_proof: 'Ödemeyi onayla',
proof_verifying: 'Onaylanıyor...',
proof_verified: 'Ödeme onaylandı: {amount} XMR',
proof_no_match: ıktılar eşleşmiyor — TX anahtarı ya da adresi eşleşmiyor',
proof_tx_not_found: 'İşlem bulunamadı',
proof_error: 'Onaylama hatası',
status_paid: 'Ödeme yapıldı',
status_pending: 'Beklemede',
proof_confirmed_pending: 'Bulunan çıktı: {amount} XMR — {n}/10 tamamlanan. Otomatik yenileniyor…',
toast_integrity_warning: 'Uyarı: eşleşmeyen imza tespit edildi'
}
};
var currentLang = 'en';
function detectLang() {
var saved = null;
try { saved = localStorage.getItem('xmrpay_lang'); } catch (e) {}
if (saved && translations[saved]) return saved;
var navLangs = navigator.languages || [navigator.language || 'en'];
for (var i = 0; i < navLangs.length; i++) {
var code = navLangs[i].substring(0, 2).toLowerCase();
if (translations[code]) return code;
}
return 'en';
}
function applyDOM(t) {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = t[el.getAttribute('data-i18n')] || '';
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
el.placeholder = t[el.getAttribute('data-i18n-placeholder')] || '';
});
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
el.innerHTML = t[el.getAttribute('data-i18n-html')] || '';
});
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) {
el.setAttribute('aria-label', t[el.getAttribute('data-i18n-aria')] || '');
});
}
function apply(lang) {
currentLang = lang;
var t = translations[lang];
document.documentElement.lang = lang;
try { localStorage.setItem('xmrpay_lang', lang); } catch (e) {}
applyDOM(t);
// Update dropdown active state
document.querySelectorAll('.lang-option').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
});
// Notify listeners
for (var i = 0; i < onChangeCallbacks.length; i++) {
onChangeCallbacks[i](lang);
}
}
var onChangeCallbacks = [];
function onChange(fn) {
onChangeCallbacks.push(fn);
}
function buildDropdown() {
var dropdown = document.getElementById('langDropdown');
if (!dropdown) return;
dropdown.innerHTML = '';
var keys = Object.keys(languages);
for (var i = 0; i < keys.length; i++) {
var code = keys[i];
var btn = document.createElement('button');
btn.className = 'lang-option';
btn.setAttribute('data-lang', code);
btn.textContent = languages[code].name;
if (code === currentLang) btn.classList.add('active');
btn.addEventListener('click', (function (c) {
return function () {
apply(c);
closePicker();
};
})(code));
dropdown.appendChild(btn);
}
}
function closePicker() {
var picker = document.getElementById('langPicker');
if (picker) picker.classList.remove('open');
}
function initPicker() {
var picker = document.getElementById('langPicker');
var toggle = document.getElementById('langToggle');
if (!picker || !toggle) return;
toggle.addEventListener('click', function (e) {
e.stopPropagation();
picker.classList.toggle('open');
});
document.addEventListener('click', function (e) {
if (!picker.contains(e.target)) closePicker();
});
}
function t(key) {
return (translations[currentLang] && translations[currentLang][key]) || key;
}
function getLang() {
return currentLang;
}
// Init
currentLang = detectLang();
document.addEventListener('DOMContentLoaded', function () {
buildDropdown();
initPicker();
apply(currentLang);
});
return { t: t, apply: apply, getLang: getLang, onChange: onChange };
})();

1
i18n.min.js vendored Normal file

File diff suppressed because one or more lines are too long

143
index.html Normal file
View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xmrpay — Monero Invoice Generator</title>
<meta name="description" content="Self-hosted Monero payment requests in seconds. No accounts, no KYC, no tracking. Generate QR codes, PDF invoices, and verify payments.">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'none'; base-uri 'none'">
<link rel="icon" id="favicon" href="favicon.svg" type="image/svg+xml">
<link rel="preload" href="fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="style.css?v=20260326-3" integrity="sha384-HrVyafi6sY5wzJh/jPfdCAq5WytRoWDiUnZ/Y05Xt2Oz1C+kLZLO47euo7q3fv46" crossorigin="anonymous">
</head>
<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>
<h1><a href="/" id="homeLink">xmr<span>pay</span></a></h1>
<p data-i18n="subtitle">Monero payment request in seconds</p>
</header>
<main>
<div class="card">
<div class="field">
<label for="addr" data-i18n="label_addr">XMR Address</label>
<input type="text" id="addr" data-i18n-placeholder="placeholder_addr" placeholder="8..." spellcheck="false" autocomplete="off">
</div>
<div class="field">
<label for="amount" data-i18n="label_amount">Amount</label>
<div class="amount-row">
<input type="number" id="amount" placeholder="0.00" min="0" step="any">
<select id="currency" data-i18n-aria="aria_currency" aria-label="Currency">
<option value="XMR">XMR</option>
<option value="EUR">EUR</option>
<option value="USD" selected>USD</option>
<option value="CHF">CHF</option>
<option value="GBP">GBP</option>
<option value="JPY">JPY</option>
<option value="RUB">RUB</option>
<option value="BRL">BRL</option>
</select>
</div>
<div class="fiat-hint" id="fiatHint"></div>
</div>
<div class="field">
<label for="desc" data-i18n="label_desc">Description (optional)</label>
<textarea id="desc" data-i18n-placeholder="placeholder_desc" placeholder="e.g. Invoice #42, freelance work..."></textarea>
</div>
<div class="field">
<label data-i18n="label_timer">Payment deadline (optional)</label>
<div class="deadline-badges" id="deadlineBadges">
<button type="button" class="badge" data-days="7">7 <span data-i18n="days">days</span></button>
<button type="button" class="badge" data-days="14">14 <span data-i18n="days">days</span></button>
<button type="button" class="badge" data-days="30">30 <span data-i18n="days">days</span></button>
<input type="number" id="timerCustom" data-i18n-placeholder="placeholder_timer_custom" placeholder="Days" min="1" step="1" class="badge-input">
</div>
</div>
<button class="btn btn-primary" id="generate" disabled data-i18n="btn_generate">Create payment request</button>
</div>
<div id="result" class="card">
<div class="qr-container" id="qr"></div>
<div class="payment-summary" id="paymentSummary"></div>
<div class="payment-status" id="paymentStatus"></div>
<div class="countdown" id="countdown"></div>
<div class="uri-box" id="uri" style="display:none"></div>
<div class="share-link-box" id="shareLinkBox">
<label data-i18n="label_share_link">Shareable link</label>
<div class="shortlink-toggle-row">
<label class="shortlink-toggle-label">
<input type="checkbox" id="useShortLink">
<span data-i18n="shortlink_toggle_label">Use short link (requires server trust)</span>
</label>
<div class="shortlink-tradeoff" data-i18n="shortlink_toggle_hint">Trade-off: short links are convenient, but a compromised server could swap invoice data on first access.</div>
</div>
<div class="share-link-row">
<input type="text" id="shareLink" readonly data-i18n-aria="label_share_link">
<button class="btn btn-secondary btn-icon" id="copyShareLink" title="Copy">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" id="openWallet" data-i18n="btn_open_wallet">Open in wallet</button>
<button class="btn btn-secondary" id="copyUri" data-i18n="btn_copy_uri">Copy payment URI</button>
<button class="btn btn-secondary" id="copyAddr" data-i18n="btn_copy_addr">Copy address</button>
<button class="btn btn-secondary" id="downloadPdf" data-i18n="btn_download_pdf">PDF Invoice</button>
</div>
<!-- TX Proof Verification -->
<div class="proof-section" id="proofSection">
<button class="btn btn-proof" id="proofToggle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span data-i18n="btn_prove_payment">Prove payment</span>
</button>
<div class="proof-panel" id="proofPanel">
<div class="field">
<label for="txHash" data-i18n="label_tx_hash">Transaction ID (TX Hash)</label>
<input type="text" id="txHash" data-i18n-placeholder="placeholder_tx_hash" placeholder="64 hex characters..." spellcheck="false" autocomplete="off">
</div>
<div class="field">
<label for="txKey" data-i18n="label_tx_key">Transaction Key (TX Key)</label>
<input type="text" id="txKey" data-i18n-placeholder="placeholder_tx_key" placeholder="64 hex characters..." spellcheck="false" autocomplete="off">
</div>
<button class="btn btn-primary" id="verifyProof" disabled data-i18n="btn_verify_proof">Verify payment</button>
<div class="proof-result" id="proofResult"></div>
</div>
</div>
<button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request">New payment request</button>
</div>
</main>
<footer>
<p data-i18n-html="footer">Open Source &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.2.1</span></p>
</footer>
<div class="lang-picker" id="langPicker">
<button class="lang-toggle" id="langToggle" aria-label="Language">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/>
</svg>
</button>
<div class="lang-dropdown" id="langDropdown"></div>
</div>
<div class="toast" id="toast"></div>
<script src="lib/qrcode.min.js?v=20260326-3" integrity="sha384-3zSEDfvllQohrq0PHL1fOXJuC/jSOO34H46t6UQfobFOmxE5BpjjaIJY5F2/bMnU" crossorigin="anonymous" defer></script>
<script src="i18n.min.js?v=20260326-3" integrity="sha384-GS62r/FP1LcB9Ec+ow+45oUWdQsjZKKwtPT6D/YXBfgGjUCjtpuxeLE3GMtbItgx" crossorigin="anonymous" defer></script>
<script src="app.min.js?v=20260326-3" integrity="sha384-Y8cPBLtvKkMhHUuD+ElA1hWJHo86yO5MRs8HTUvhuK9h+lwo9WT9eBvRM7mRgtCr" crossorigin="anonymous" defer></script>
</body>
</html>

76
install.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/bin/sh
set -e
# xmrpay.link — Self-hosting installer
# Usage: curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com
DOMAIN="${1:-}"
INSTALL_DIR="/opt/xmrpay"
IMAGE="schmidt1024/xmrpay:latest"
COMPOSE_URL="https://raw.githubusercontent.com/schmidt1024/xmrpay/master/docker-compose.yml"
# ── Helpers ───────────────────────────────────────────────────────────────────
info() { printf '\033[1;34m→\033[0m %s\n' "$1"; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$1"; }
fail() { printf '\033[1;31m✗\033[0m %s\n' "$1" >&2; exit 1; }
# ── Preflight ─────────────────────────────────────────────────────────────────
[ "$(id -u)" -eq 0 ] || fail "Run as root: curl -sL https://xmrpay.link/install.sh | sudo sh -s $DOMAIN"
[ -n "$DOMAIN" ] || fail "Usage: curl -sL https://xmrpay.link/install.sh | sh -s YOUR-DOMAIN.COM"
# ── Install Docker if missing ─────────────────────────────────────────────────
if ! command -v docker >/dev/null 2>&1; then
info "Installing Docker..."
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
ok "Docker installed"
else
ok "Docker found"
fi
# ── Set up xmrpay ────────────────────────────────────────────────────────────
info "Setting up xmrpay in $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
curl -fsSL "$COMPOSE_URL" -o docker-compose.yml
cat > .env <<EOF
DOMAIN=$DOMAIN
XMRPAY_IMAGE=$IMAGE
EOF
# ── Start ─────────────────────────────────────────────────────────────────────
info "Starting xmrpay..."
docker compose pull
docker compose up -d
ok "xmrpay is running!"
echo ""
echo " Clearnet: https://$DOMAIN"
# Wait for Tor to generate the onion address (up to 30s)
info "Waiting for Tor hidden service..."
ONION=""
for i in $(seq 1 30); do
ONION=$(docker exec xmrpay-tor cat /var/lib/tor/hidden_service/hostname 2>/dev/null || true)
[ -n "$ONION" ] && break
sleep 1
done
if [ -n "$ONION" ]; then
ok "Tor hidden service ready"
echo " Onion: http://$ONION"
else
echo " Onion: (still starting — run: docker exec xmrpay-tor cat /var/lib/tor/hidden_service/hostname)"
fi
echo ""
echo " Watchtower checks for updates every 6 hours."
echo " Data stored in Docker volume: xmrpay-data"
echo " Config: $INSTALL_DIR/.env"
echo ""

373
lib/jspdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
lib/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
lib/xmr-crypto.bundle.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
# Include this in your nginx server block:
# include /path/to/nginx-security-headers.conf;
#
# This ensures ALL responses (HTML, CSS, JS, fonts) get security headers,
# not just PHP/API responses.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'" always;

254
privacy.html Normal file
View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xmrpay — Privacy & Terms</title>
<meta name="description" content="Privacy policy and terms of use for xmrpay.">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; base-uri 'none'">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css?v=20260326-3" integrity="sha384-HrVyafi6sY5wzJh/jPfdCAq5WytRoWDiUnZ/Y05Xt2Oz1C+kLZLO47euo7q3fv46" crossorigin="anonymous">
<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></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>
<section class="legal-lang" data-lang="tr">
<h2>Türkçe</h2>
<h3>Mahremiyet Politikası</h3>
<p>xmrpay.link veri toplama işlemini asgari seviyeye indirecek şekilde tasarlanmıştır. Hesap oluşturmaya gerek yoktur.</p>
<ul>
<li><strong>Hız sınırlaması:</strong> kötüye kullanım karşıtı koruma mekanizması, IP'lerden gelen istekleri kısa süreli dosyalarda tutar. İlgili IP adresleri ham hali ile değil, hash'lenmiş olarak barındırılır.</li>
<li><strong>Kısaltılmış bağlantılar:</strong> fatura hash verileri, oluşturulan kısa URL'ler için saklanır.</li>
<li><strong>Ödeme onayı:</strong> eğer kullanıldılar ise; tx hash değeri, tutar, onaylamalar ve zaman mührü saklanır. Onay veritabanında hiçbir Monero adresi saklanmaz.</li>
<li><strong>Hiçbir şey takip edilmez:</strong> istatistik ve reklam olmadığı gibi herhangi bir profilleme eylemi barındırmaz.</li>
</ul>
<h3>Kullanım Koşulları</h3>
<ul>
<li>Tüm servis "olduğu gibi" herhangi bir garanti olmadan sunulmaktadır.</li>
<li>Tâbi olduğunuz yasalara uyum hususunda tüm sorumluluk size aittir.</li>
<li>Servisin herhangi bir nedenle istismarı, yasadışı alanda kullanımı ve/veya servise yönelik saldırılar yasaklı eylem statüsündedir.</li>
<li>İlgili kullanılabilirlik garanti edilmemiştir, tüm özellikler herhangi bir zaman aralığında değişebilir.</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://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>
</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" integrity="sha384-GS62r/FP1LcB9Ec+ow+45oUWdQsjZKKwtPT6D/YXBfgGjUCjtpuxeLE3GMtbItgx" crossorigin="anonymous" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var supported = ['en', 'de', 'fr', 'it', 'es', 'pt', 'ru', 'tr'];
var sections = document.querySelectorAll('.legal-lang');
function applyLang(lang) {
var activeLang = supported.indexOf(lang) !== -1 ? lang : 'en';
sections.forEach(function (el) {
el.classList.toggle('active', el.getAttribute('data-lang') === activeLang);
});
}
applyLang(I18n.getLang());
I18n.onChange(function (lang) {
applyLang(lang);
});
});
</script>
</body>
</html>

64
s.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
$pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) && $_SERVER['PATH_INFO'] !== '' ? $_SERVER['PATH_INFO'] : null;
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
$code = trim($pathInfo ?? $queryCode, '/');
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(404);
echo 'Not found';
exit;
}
$dbFile = __DIR__ . '/data/urls.json';
if (!file_exists($dbFile)) {
http_response_code(404);
echo 'Not found';
exit;
}
$rawUrls = file_get_contents($dbFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) {
http_response_code(404);
echo 'Not found';
exit;
}
// Support both old format (string) and new format (array with hash & signature)
$data = $urls[$code];
$hash = is_array($data) ? ($data['h'] ?? '') : $data;
$hash = is_string($hash) ? $hash : '';
$signature = is_array($data) ? ($data['s'] ?? null) : null;
$expiryTs = is_array($data) ? intval($data['e'] ?? 0) : 0;
// Check if URL has expired (lazy cleanup)
if ($expiryTs > 0 && time() > $expiryTs) {
require_once __DIR__ . '/api/_helpers.php';
// Delete expired URL
[$fp, $urls] = read_json_locked(__DIR__ . '/data/urls.json');
/** @var array<string, mixed> $urls */
if (isset($urls[$code])) {
unset($urls[$code]);
write_json_locked($fp, $urls);
}
http_response_code(410);
echo 'Gone';
exit;
}
// Verify HMAC signature if present (detect server-side tampering)
if (is_string($signature) && $signature !== '') {
require_once __DIR__ . '/api/_helpers.php';
$expected_sig = hash_hmac('sha256', $hash, get_hmac_secret());
if (!hash_equals($expected_sig, $signature)) {
// Signature mismatch — possible tampering, log and proceed (graceful degradation)
error_log("xmrpay: Signature mismatch for code $code");
}
}
$host = isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'xmrpay.link';
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302);
exit;

View File

@@ -0,0 +1,9 @@
# Copy this file to scripts/.deploy.env and fill in your values.
DEPLOY_HOST="USER@HOST"
DEPLOY_TARGET="/home/USER/web/xmrpay.link/public_html"
# Optional hardening settings:
# DEPLOY_BACKUP_ENABLE="1"
# DEPLOY_BACKUP_KEEP="14"
# DEPLOY_BACKUP_DIR="/home/USER/web/xmrpay.link/backups/xmrpay-data"
# DEPLOY_DRY_RUN="0"

140
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/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 hardening:
# DEPLOY_BACKUP_ENABLE=1 # 1=backup data before deploy, 0=disable
# DEPLOY_BACKUP_KEEP=14 # number of backup archives to keep
# DEPLOY_BACKUP_DIR=... # remote backup folder
# DEPLOY_DRY_RUN=0 # 1=rsync dry-run
#
# 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:-}"
BACKUP_ENABLE="${DEPLOY_BACKUP_ENABLE:-1}"
BACKUP_KEEP="${DEPLOY_BACKUP_KEEP:-14}"
BACKUP_DIR="${DEPLOY_BACKUP_DIR:-$TARGET/../backups/xmrpay-data}"
DRY_RUN="${DEPLOY_DRY_RUN:-0}"
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
if [[ "$BACKUP_ENABLE" == "1" ]]; then
echo "Creating remote pre-deploy data backup..."
ssh "$HOST" "
set -euo pipefail
TARGET='$TARGET'
DATA_DIR=\"\$TARGET/data\"
BACKUP_DIR='$BACKUP_DIR'
KEEP='$BACKUP_KEEP'
mkdir -p \"\$BACKUP_DIR\"
if [ -d \"\$DATA_DIR\" ]; then
TS=\$(date +%Y%m%d-%H%M%S)
ARCHIVE=\"\$BACKUP_DIR/data-\$TS.tgz\"
tar -C \"\$TARGET\" -czf \"\$ARCHIVE\" data
echo \"backup_created=\$ARCHIVE\"
COUNT=0
for FILE in \$(ls -1t \"\$BACKUP_DIR\"/data-*.tgz 2>/dev/null || true); do
COUNT=\$((COUNT + 1))
if [ \"\$COUNT\" -gt \"\$KEEP\" ]; then
rm -f \"\$FILE\"
fi
done
else
echo \"backup_skipped=no_data_dir\"
fi
"
else
echo "Skipping pre-deploy backup (DEPLOY_BACKUP_ENABLE=0)."
fi
# ── Inject version from git tags ──────────────────────────────────────────────
GIT_VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
# Turn v1.0.0-3-gabc1234 into 1.0.0+3
VERSION=$(echo "$GIT_VERSION" | sed -E 's/^v//; s/-([0-9]+)-g[0-9a-f]+$/+\1/')
echo "Version: $VERSION"
sed -i -E "s|VERSION = '[^']*'|VERSION = '${VERSION}'|" i18n.js
sed -i -E "s|(<span class=\"version\">v)[^<]*(</span>)|\1${VERSION}\2|" index.html
# ── Minify & update SRI hashes ────────────────────────────────────────────────
echo "Minifying JS..."
TERSER="${TERSER:-terser}"
if ! command -v "$TERSER" &>/dev/null; then
echo "Error: terser not found. Install with: npm i -g terser" >&2
exit 1
fi
"$TERSER" app.js -c -m -o app.min.js
"$TERSER" i18n.js -c -m -o i18n.min.js
echo "Updating SRI hashes..."
sri_hash() { echo "sha384-$(openssl dgst -sha384 -binary "$1" | openssl base64 -A)"; }
HASH_STYLE=$(sri_hash style.css)
HASH_QRCODE=$(sri_hash lib/qrcode.min.js)
HASH_I18N=$(sri_hash i18n.min.js)
HASH_APP=$(sri_hash app.min.js)
HASH_JSPDF=$(sri_hash lib/jspdf.min.js)
HASH_CRYPTO=$(sri_hash lib/xmr-crypto.bundle.js)
# Update index.html SRI attributes
sed -i -E \
-e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_STYLE}|" \
-e "s|(qrcode\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_QRCODE}|" \
-e "s|(i18n\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_I18N}|" \
-e "s|(app\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_APP}|" \
index.html
# Update privacy.html SRI attributes
sed -i -E \
-e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_STYLE}|" \
privacy.html
# Update dynamic SRI hashes in app.js and re-minify if changed
sed -i -E \
-e "s|(jspdf\.min\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${HASH_JSPDF}|" \
-e "s|(xmr-crypto\.bundle\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${HASH_CRYPTO}|" \
app.js
# Re-minify app.js (dynamic SRI hashes may have changed)
"$TERSER" app.js -c -m -o app.min.js
HASH_APP_FINAL=$(sri_hash app.min.js)
sed -i -E "s|(app\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${HASH_APP_FINAL}|" index.html
echo "SRI hashes updated."
RSYNC_DRY_RUN=""
if [[ "$DRY_RUN" == "1" ]]; then
RSYNC_DRY_RUN="--dry-run"
echo "Running in dry-run mode (DEPLOY_DRY_RUN=1)."
fi
rsync -avz --delete --chmod=D755,F644 \
${RSYNC_DRY_RUN} \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'data/' \
--exclude 'scripts/.deploy.env' \
./ "$HOST:$TARGET"
echo "Deploy complete (data/ preserved)."

115
scripts/restore-data.sh Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
# Restore runtime data/ from remote backup archives created by deploy.sh
#
# Usage:
# ./scripts/restore-data.sh --latest
# ./scripts/restore-data.sh --file data-20260326-120000.tgz
# ./scripts/restore-data.sh --list
#
# Config (env vars or scripts/.deploy.env):
# DEPLOY_HOST
# DEPLOY_TARGET
# DEPLOY_BACKUP_DIR (optional, defaults to ../backups/xmrpay-data)
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:-}"
BACKUP_DIR="${DEPLOY_BACKUP_DIR:-$TARGET/../backups/xmrpay-data}"
if [[ -z "$HOST" || -z "$TARGET" ]]; then
echo "Missing configuration." >&2
echo "Set DEPLOY_HOST and DEPLOY_TARGET (env vars or scripts/.deploy.env)." >&2
exit 1
fi
MODE=""
ARCHIVE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--latest)
MODE="latest"
shift
;;
--file)
MODE="file"
ARCHIVE="${2:-}"
shift 2
;;
--list)
MODE="list"
shift
;;
-h|--help)
MODE="help"
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$MODE" || "$MODE" == "help" ]]; then
sed -n '1,20p' "$0"
exit 0
fi
if [[ "$MODE" == "list" ]]; then
ssh "$HOST" "ls -1t '$BACKUP_DIR'/data-*.tgz 2>/dev/null || true"
exit 0
fi
if [[ "$MODE" == "latest" ]]; then
ARCHIVE="$(ssh "$HOST" "ls -1t '$BACKUP_DIR'/data-*.tgz 2>/dev/null | head -n 1")"
if [[ -z "$ARCHIVE" ]]; then
echo "No backup archives found in $BACKUP_DIR" >&2
exit 1
fi
fi
if [[ "$MODE" == "file" ]]; then
if [[ -z "$ARCHIVE" ]]; then
echo "--file requires an archive name" >&2
exit 1
fi
ARCHIVE="$BACKUP_DIR/$ARCHIVE"
fi
echo "Restoring data from: $ARCHIVE"
ssh "$HOST" "
set -euo pipefail
TARGET='$TARGET'
DATA_DIR=\"\$TARGET/data\"
ARCHIVE='$ARCHIVE'
if [ ! -f \"\$ARCHIVE\" ]; then
echo 'Backup archive not found: '\"\$ARCHIVE\" >&2
exit 1
fi
TS=\$(date +%Y%m%d-%H%M%S)
SAFETY=\"\$TARGET/../backups/xmrpay-data/pre-restore-\$TS.tgz\"
mkdir -p \"\$(dirname \"\$SAFETY\")\"
if [ -d \"\$DATA_DIR\" ]; then
tar -C \"\$TARGET\" -czf \"\$SAFETY\" data
rm -rf \"\$DATA_DIR\"
fi
tar -C \"\$TARGET\" -xzf \"\$ARCHIVE\"
echo \"restore_ok=\$ARCHIVE\"
echo \"safety_backup=\$SAFETY\"
"
echo "Restore complete."

775
style.css Normal file
View File

@@ -0,0 +1,775 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: optional;
src: url('fonts/inter-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('fonts/inter-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: optional;
src: url('fonts/jetbrains-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--bg: #0d0d0d;
--bg-card: #1a1a1a;
--bg-input: #222;
--border: #333;
--text: #e0e0e0;
--text-muted: #888;
--accent: #c74a00;
--accent-hover: #a83f00;
--accent-text: #e87830;
--success: #4caf50;
--error: #f44336;
--radius: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.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;
z-index: 60;
}
.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 {
text-align: center;
padding: 2rem 1rem 1rem;
}
.lang-picker {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 50;
}
.lang-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.35rem 0.6rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
font-family: var(--font);
transition: border-color 0.2s, color 0.2s;
}
.lang-toggle:hover,
.lang-picker.open .lang-toggle {
border-color: var(--accent);
color: var(--text);
}
.lang-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-width: 120px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.lang-picker.open .lang-dropdown {
display: block;
}
.lang-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.lang-option:hover {
background: var(--bg-input);
color: var(--text);
}
.lang-option.active {
color: var(--accent-text);
}
header h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.5px;
}
header h1 a {
color: inherit;
text-decoration: none;
}
header h1 a:hover {
opacity: 0.8;
}
header h1 span {
color: var(--accent-text);
}
header p {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 0.3rem;
}
main {
width: 100%;
max-width: 480px;
padding: 1rem;
flex: 1;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, select, textarea {
width: 100%;
padding: 0.7rem 0.8rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
color-scheme: dark;
}
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
select:-webkit-autofill,
textarea:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--bg-input) inset !important;
-webkit-text-fill-color: var(--text) !important;
transition: background-color 5000s ease-in-out 0s;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 2rem;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
input.invalid {
border-color: var(--error);
}
input.valid {
border-color: var(--success);
}
.field {
margin-bottom: 1rem;
}
.field:last-child {
margin-bottom: 0;
}
.amount-row {
display: flex;
gap: 0.5rem;
}
.amount-row input {
flex: 1;
}
.amount-row select {
width: 90px;
flex-shrink: 0;
}
.fiat-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.3rem;
min-height: 1.1em;
}
.fiat-hint.error {
color: var(--error);
}
.deadline-badges {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.badge {
padding: 0.45rem 0.8rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: #aaa;
font-family: var(--font);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
white-space: nowrap;
}
.badge:hover {
border-color: var(--accent);
color: var(--text);
}
.badge.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.badge-input {
width: 70px;
padding: 0.45rem 0.5rem;
font-size: 0.8rem;
text-align: center;
flex-shrink: 0;
}
textarea {
resize: vertical;
min-height: 60px;
}
.btn {
width: 100%;
padding: 0.8rem;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
background: #3a2215;
color: #a0a0a0;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
font-size: 0.85rem;
padding: 0.6rem;
text-decoration: none;
text-align: center;
}
.btn-secondary:hover {
border-color: var(--accent);
}
#result {
display: none;
}
#result.visible {
display: block;
animation: fadeSlideIn 0.3s ease;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
}
.qr-container canvas,
.qr-container img {
border-radius: var(--radius);
padding: 12px;
background: #fff;
cursor: pointer;
transition: opacity 0.2s;
}
.qr-container canvas:hover,
.qr-container img:hover {
opacity: 0.85;
}
.qr-hint {
text-align: center;
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 0.3rem;
}
.payment-summary {
text-align: center;
padding: 0.5rem 0 0.8rem;
}
.summary-amount {
font-size: 1.6rem;
font-weight: 700;
color: var(--text);
font-family: var(--mono);
}
.payment-summary.paid-confirmed .summary-amount {
color: var(--success);
}
.summary-fiat {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.summary-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-top: 0.4rem;
font-style: italic;
}
.uri-details {
margin-bottom: 0.8rem;
}
.uri-details summary {
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.3rem 0;
}
.uri-details summary:hover {
color: var(--text);
}
.uri-box {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
font-family: var(--mono);
font-size: 0.75rem;
word-break: break-all;
color: var(--text-muted);
margin-top: 0.4rem;
max-height: 80px;
overflow-y: auto;
}
.share-link-box {
margin-bottom: 0.8rem;
}
.share-link-box label {
margin-bottom: 0.3rem;
}
.shortlink-toggle-row {
margin-bottom: 0.45rem;
}
.shortlink-toggle-label {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0;
font-size: 0.78rem;
color: var(--text);
text-transform: none;
letter-spacing: 0;
}
.shortlink-toggle-label input {
width: auto;
accent-color: var(--accent);
}
.shortlink-tradeoff {
margin-top: 0.28rem;
font-size: 0.7rem;
color: #d08a61;
line-height: 1.35;
}
.share-link-row {
display: flex;
gap: 0.4rem;
}
.share-link-row input {
flex: 1;
font-size: 0.75rem;
padding: 0.5rem 0.6rem;
cursor: text;
}
.btn-icon {
width: auto;
padding: 0.5rem 0.6rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.actions .btn-secondary {
flex: 1 1 30%;
min-width: 0;
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- TX Proof Section --- */
.proof-section {
margin-top: 1rem;
border-top: 1px solid var(--border);
padding-top: 0.8rem;
}
.btn-proof {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem;
background: transparent;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
font-family: var(--font);
}
.btn-proof:hover {
border-color: var(--accent);
color: var(--text);
}
.proof-panel {
display: none;
margin-top: 0.8rem;
}
.proof-panel.open {
display: block;
}
.proof-result {
display: none;
text-align: center;
padding: 0.8rem;
border-radius: var(--radius);
font-size: 0.85rem;
margin-top: 0.8rem;
}
.proof-result.active {
display: block;
}
.proof-result.success {
background: rgba(76, 175, 80, 0.15);
color: var(--success);
border: 1px solid var(--success);
}
.proof-result.error {
background: rgba(244, 67, 54, 0.15);
color: var(--error);
border: 1px solid var(--error);
}
.proof-result.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 1px solid #f59e0b;
}
.payment-status {
display: none;
}
.payment-status.paid {
display: block;
}
.payment-status.pending {
display: block;
}
.paid-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-12deg);
border: 4px solid var(--success);
border-radius: 12px;
padding: 0.4rem 1.5rem;
font-size: 1.8rem;
font-weight: 900;
color: var(--success);
background: rgba(0, 0, 0, 0.75);
text-transform: uppercase;
letter-spacing: 3px;
pointer-events: none;
z-index: 2;
}
.qr-container.paid canvas,
.qr-container.paid img {
opacity: 0.3;
}
.pending-stamp {
border-color: #f59e0b;
color: #f59e0b;
}
.qr-container.confirming canvas,
.qr-container.confirming img {
opacity: 0.5;
}
.qr-container {
position: relative;
}
.paid-info {
color: var(--success);
font-size: 0.75rem;
font-family: var(--mono);
}
.pending-info {
color: #f59e0b;
font-size: 0.75rem;
font-family: var(--mono);
}
.btn-new {
margin-top: 0.8rem;
background: transparent;
border: 1px solid var(--accent-text);
color: var(--accent-text);
}
.btn-new:hover {
background: var(--accent);
color: #fff;
}
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--success);
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
footer {
text-align: center;
padding: 2rem 1rem;
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.5em;
}
footer a {
color: var(--accent-text);
text-decoration: underline;
}
footer .version {
opacity: 0.5;
}
.countdown {
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.8rem;
}
.countdown.expired {
color: var(--error);
}
.countdown.active {
color: var(--accent-text);
}
@media (max-width: 500px) {
main {
padding: 0.5rem;
}
.card {
padding: 1rem;
}
header h1 {
font-size: 1.5rem;
}
.qr-container canvas,
.qr-container img {
max-width: 80vw;
height: auto;
}
.summary-amount {
font-size: 1.3rem;
}
}

73
sw.js Normal file
View File

@@ -0,0 +1,73 @@
var CACHE_NAME = 'xmrpay-v4';
var ASSETS = [
'/',
'/index.html',
'/app.min.js?v=20260326-3',
'/i18n.min.js?v=20260326-3',
'/style.css',
'/lib/qrcode.min.js?v=20260326-3',
'/favicon.svg',
'/fonts/inter-400.woff2',
'/fonts/jetbrains-400.woff2'
// xmr-crypto.bundle.js and jspdf.min.js are lazy-loaded and runtime-cached
];
self.addEventListener('install', function (e) {
e.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener('activate', function (e) {
e.waitUntil(
caches.keys().then(function (names) {
return Promise.all(
names.filter(function (n) { return n !== CACHE_NAME; })
.map(function (n) { return caches.delete(n); })
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', function (e) {
var url = new URL(e.request.url);
// API calls — network only, don't cache
if (url.pathname.startsWith('/api/')) {
e.respondWith(fetch(e.request));
return;
}
// Navigation (HTML) — network first, fall back to cached index.html for offline
// Invoice data is in the URL hash, so caching the document would cause stale state
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(function () {
return caches.match('/index.html');
})
);
return;
}
// App assets — cache first, fallback to network
e.respondWith(
caches.match(e.request).then(function (cached) {
var networkFetch = fetch(e.request).then(function (response) {
if (response.ok) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(e.request, clone);
});
}
return response;
}).catch(function () {
return cached;
});
return cached || networkFetch;
})
);
});