Compare commits
47 Commits
v1.1.0
...
ec99e097c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec99e097c2 | ||
|
|
7e389d6a1c | ||
|
|
3cd8d03d9b | ||
|
|
e52955f106 | ||
|
|
eae15de873 | ||
|
|
71383431f2 | ||
|
|
09616adc81 | ||
|
|
090256ae4f | ||
|
|
9c466d3814 | ||
|
|
523bdae81c | ||
|
|
c206a51f0b | ||
|
|
9faec16b31 | ||
|
|
fa2f7a4ab1 | ||
|
|
a5de8752dd | ||
|
|
4549a05b6d | ||
|
|
c8df4df881 | ||
|
|
9999c00d59 | ||
|
|
458ee78362 | ||
|
|
ded24ce575 | ||
|
|
600154493e | ||
|
|
fa9f2243ae | ||
|
|
cffdee2cb6 | ||
|
|
2154d5996d | ||
|
|
27cb9e0fec | ||
|
|
69c66aea38 | ||
|
|
1bbf309029 | ||
|
|
14f73875de | ||
|
|
38f23d6627 | ||
|
|
96dd4bfc72 | ||
|
|
1edf8bb324 | ||
|
|
6b45a6346c | ||
|
|
9ef0988627 | ||
|
|
2b84a16055 | ||
|
|
af7824b080 | ||
|
|
b30f3051e4 | ||
|
|
8af78dbb53 | ||
|
|
dbe6e45ef3 | ||
|
|
9b6cca5d4b | ||
|
|
6bad55113b | ||
|
|
c2abb49a42 | ||
|
|
68a871d00c | ||
|
|
2aff54a765 | ||
|
|
a0236a7c43 | ||
|
|
025e3a06c0 | ||
|
|
59125ecea0 | ||
|
|
2ffc3cec1f | ||
|
|
e9b66fda24 |
@@ -1,12 +0,0 @@
|
|||||||
.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
98
.github/workflows/docker.yml
vendored
@@ -1,98 +0,0 @@
|
|||||||
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
1
.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
scripts/.deploy.env
|
|
||||||
24
Caddyfile
24
Caddyfile
@@ -1,24 +0,0 @@
|
|||||||
{$DOMAIN:localhost} {
|
|
||||||
root * /srv
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
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'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Short URL rewrite: /s/CODE -> s.php?c=CODE
|
|
||||||
@shorturl path_regexp short ^/s/([a-zA-Z0-9]+)$
|
|
||||||
rewrite @shorturl /s.php?c={re.short.1}
|
|
||||||
|
|
||||||
# PHP via FPM
|
|
||||||
php_fastcgi 127.0.0.1:9000
|
|
||||||
|
|
||||||
# Static files
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
38
Dockerfile
38
Dockerfile
@@ -1,38 +0,0 @@
|
|||||||
FROM php:8.3-fpm-alpine AS base
|
|
||||||
|
|
||||||
# Install PHP curl extension (needed for API proxies)
|
|
||||||
RUN apk add --no-cache caddy 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
|
|
||||||
|
|
||||||
VOLUME ["/srv/data", "/data/caddy"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
227
README.md
227
README.md
@@ -1,111 +1,80 @@
|
|||||||
# xmrpay — Monero Invoice Generator
|
# xmrpay.link — Monero Invoice Generator
|
||||||
|
|
||||||
> Create Monero payment requests in seconds. No accounts. No tracking. No KYC.
|
> Private. Self-hosted. No accounts. No tracking. No bullshit.
|
||||||
|
|
||||||
**[Demo: xmrpay.link](https://xmrpay.link)** — for real payments, self-host your own instance.
|
**[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Self-Host in 60 Seconds
|
## What is this?
|
||||||
|
|
||||||
You need a VPS with a domain pointing to it. Then:
|
**xmrpay.link** is a client-side web app that lets anyone create a professional Monero payment request in under 30 seconds — no account registration, no KYC, no custodial services.
|
||||||
|
|
||||||
```bash
|
Enter your address, the amount, an optional description — and get a QR code, a shareable short link, and a PDF invoice. Done.
|
||||||
curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Done. HTTPS is automatic (via Caddy + Let's Encrypt).
|
### Architecture & Transparency
|
||||||
|
|
||||||
### Requirements
|
xmrpay.link uses a **minimal backend** for the following specific purposes:
|
||||||
|
|
||||||
| | Minimum | Recommended |
|
| Component | Where it runs | What the server sees |
|
||||||
|---|---|---|
|
|-----------|--------------|---------------------|
|
||||||
| **CPU** | 1 vCPU | 2 vCPU |
|
| QR code generation | Browser only | Nothing |
|
||||||
| **RAM** | 1 GB | 2 GB |
|
| PDF invoice | Browser only | Nothing |
|
||||||
| **Disk** | 10 GB | 20 GB |
|
| Payment (TX) verification | Browser only | Nothing |
|
||||||
| **OS** | Any Linux with Docker | Ubuntu 22+, Debian 12+ |
|
| Fiat exchange rates | Server (CoinGecko proxy) | Your IP address |
|
||||||
| **Domain** | A-Record pointing to server IP | |
|
| Short URL storage | Server | Invoice hash (address + amount + description), HMAC-signed |
|
||||||
| **Cost** | ~3 EUR/month (Hetzner, Contabo, etc.) | |
|
| Payment proof storage | Server | TX hash + amount — **not** your XMR address |
|
||||||
|
|
||||||
### Updates
|
**Self-hosting** eliminates any trust in the public instance.
|
||||||
|
**No short links** (use the long `/#...` URL or QR code) = zero server involvement.
|
||||||
|
|
||||||
Watchtower runs alongside xmrpay and automatically pulls new images every 6 hours. No action needed.
|
### Security Model
|
||||||
|
|
||||||
Manual update:
|
- **HMAC-signed short URLs:** Hashes are signed with a server-side secret. Clients verify the signature on load to detect tampering.
|
||||||
|
- **Address never stored:** Payment verification is cryptographic and runs client-side. The server never learns your XMR address.
|
||||||
|
- **Rate-limited APIs:** All write endpoints are rate-limited per IP.
|
||||||
|
- **Origin-restricted:** API endpoints reject cross-origin requests.
|
||||||
|
|
||||||
```bash
|
---
|
||||||
cd /opt/xmrpay && docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
## Why?
|
||||||
|
|
||||||
After install, the config is at `/opt/xmrpay/.env`:
|
| Solution | Problem |
|
||||||
|
|
||||||
```bash
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
XMRPAY_IMAGE=schmidt1024/xmrpay:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Images
|
|
||||||
|
|
||||||
| Registry | Pull command |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Docker Hub | `docker pull schmidt1024/xmrpay:latest` |
|
| **BTCPay Server** | Requires own server, complex setup |
|
||||||
| GitHub (GHCR) | `docker pull ghcr.io/schmidt1024/xmrpay:latest` |
|
| **NOWPayments, Globee** | Custodial, KYC, fees, third-party dependency |
|
||||||
|
| **Cake Wallet Invoice** | Mobile-only, no sharing without app |
|
||||||
|
| **MoneroPay** | Backend daemon required, developer-only |
|
||||||
|
| **Wallet QR** | No amount, no description, no confirmation |
|
||||||
|
|
||||||
### Manual Setup (without install script)
|
**The gap:** There's no simple, privacy-respecting tool for freelancers, small merchants, and creators that works without setup and still allows payment confirmation.
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/xmrpay && cd /opt/xmrpay
|
|
||||||
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/schmidt1024/xmrpay/master/docker-compose.yml -o docker-compose.yml
|
|
||||||
|
|
||||||
cat > .env <<EOF
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
XMRPAY_IMAGE=schmidt1024/xmrpay:latest
|
|
||||||
EOF
|
|
||||||
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uninstall
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/xmrpay && docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Self-Host?
|
|
||||||
|
|
||||||
**Any server you don't control can steal your funds.** The JavaScript loaded from a third-party instance can swap the address in the QR code or exfiltrate payment data. No HMAC, no SRI hash, no URL fragment can fully prevent this — because the server controls the code your browser runs.
|
|
||||||
|
|
||||||
Self-hosting eliminates this risk. You control the server, you control the code.
|
|
||||||
|
|
||||||
The public instance at [xmrpay.link](https://xmrpay.link) exists as a demo and for testing only.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Invoice generation** — XMR address, amount (XMR or fiat), description, payment deadline
|
### Invoice Generation
|
||||||
- **Wallet-native URI** — `monero:` URI with QR code, works with any Monero wallet
|
- XMR address input with validation (standard, subaddress, integrated)
|
||||||
- **PDF invoice** — downloadable with QR, amount, fiat equivalent, deadline
|
- Amount in XMR or fiat (EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected)
|
||||||
- **Payment verification** — sender provides TX Hash + TX Key, cryptographic verification in browser
|
- Description and payment deadline (7/14/30 days or custom)
|
||||||
- **Fiat conversion** — EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected from locale
|
- QR code with `monero:` URI
|
||||||
- **Short URLs** — optional, with explicit trust trade-off warning
|
- Shareable short URLs (`/s/abc123`) with HMAC signatures for integrity
|
||||||
- **i18n** — English, German, French, Italian, Spanish, Portuguese, Russian
|
- PDF invoice download (with QR, amount, fiat equivalent, deadline)
|
||||||
- **Offline-capable** — Service Worker for offline use
|
- i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection
|
||||||
- **Privacy** — zero cookies, no analytics, no external scripts, self-hosted fonts
|
|
||||||
|
|
||||||
### Security
|
### Payment Verification (TX Proof)
|
||||||
|
- Sender provides TX Hash + TX Key from their wallet
|
||||||
|
- Cryptographic verification in the browser (no private keys needed)
|
||||||
|
- Payment status stored with the invoice (server stores proof, but not your address)
|
||||||
|
- Invoice link shows "Paid" badge after verification
|
||||||
|
- Standard and subaddress support
|
||||||
|
|
||||||
- **CSP** — Content Security Policy blocks exfiltration to foreign domains
|
### Performance & Privacy
|
||||||
- **SRI** — Subresource Integrity on all scripts, verified on every load
|
- 100% Lighthouse score (Performance, Accessibility, Best Practices, SEO)
|
||||||
- **HMAC-signed short URLs** — detect server-side tampering
|
- Offline-capable via Service Worker
|
||||||
- **Rate-limited APIs** — all write endpoints rate-limited per IP
|
- Self-hosted fonts (no Google Fonts dependency)
|
||||||
- **No private keys** — TX proof uses the sender's TX key, not the receiver's view key
|
- Zero external tracking, no cookies
|
||||||
- **Client-side crypto** — Ed25519 + Keccak-256 verification runs in browser
|
- Dark mode, responsive design
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,24 +85,98 @@ Frontend: HTML + Vanilla JS (no frameworks, no build step)
|
|||||||
Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle)
|
Crypto: @noble/curves Ed25519 + Keccak-256 (30KB bundle)
|
||||||
QR: QRCode.js (client-side)
|
QR: QRCode.js (client-side)
|
||||||
PDF: jsPDF (client-side, lazy-loaded)
|
PDF: jsPDF (client-side, lazy-loaded)
|
||||||
|
Hosting: Static site + minimal PHP for short URLs and RPC proxy
|
||||||
Backend: Minimal PHP (URL shortener, rates proxy, proof storage)
|
Backend: Minimal PHP (URL shortener, rates proxy, proof storage)
|
||||||
Data: JSON files (no database)
|
Data: JSON files (no database), LocalStorage (client-side)
|
||||||
Hosting: Caddy (auto-HTTPS) + PHP-FPM in Alpine Docker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
xmrpay.link/
|
||||||
|
├── index.html # Single-page app
|
||||||
|
├── app.js / app.min.js # Main logic (URI builder, QR, fiat rates, TX proof)
|
||||||
|
├── i18n.js / i18n.min.js # Internationalization (DE, EN)
|
||||||
|
├── style.css # Dark theme, responsive, WCAG AA
|
||||||
|
├── sw.js # Service Worker (offline support)
|
||||||
|
├── favicon.svg # Monero coin logo
|
||||||
|
├── s.php # Short URL redirect
|
||||||
|
├── api/
|
||||||
|
│ ├── shorten.php # Short URL creation
|
||||||
|
│ ├── rates.php # CoinGecko proxy with server-side cache
|
||||||
|
│ ├── node.php # Monero RPC proxy (4-node failover)
|
||||||
|
│ └── verify.php # TX proof storage/retrieval
|
||||||
|
├── data/ # JSON storage (auto-generated)
|
||||||
|
├── fonts/ # Self-hosted Inter + JetBrains Mono
|
||||||
|
├── lib/
|
||||||
|
│ ├── qrcode.min.js # QR code generator
|
||||||
|
│ ├── jspdf.min.js # PDF generation (lazy-loaded)
|
||||||
|
│ └── xmr-crypto.bundle.js # Ed25519 + Keccak-256 (lazy-loaded)
|
||||||
|
├── README.md
|
||||||
|
└── LICENSE # MIT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invoice Lifecycle
|
||||||
|
|
||||||
|
**Optional Deadline:** When creating an invoice, you can set an expiration deadline (7/14/30 days or custom).
|
||||||
|
|
||||||
|
**Lazy Cleanup:** When a deadline is enabled, the short URL and payment proof are automatically deleted if accessed after expiration:
|
||||||
|
- Accessing an expired short URL returns **HTTP 410 Gone** and removes the entry
|
||||||
|
- Retrieving a proof for an expired invoice returns `verified: false` and cleans up the entry
|
||||||
|
- No background jobs or cron tasks required
|
||||||
|
|
||||||
|
**No deadline?** Invoices without a deadline persist indefinitely (no auto-cleanup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Hosting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/schmidt1024/xmrpay.git
|
git clone https://gitea.schmidt.eco/schmidt1024/xmrpay.link.git
|
||||||
cd xmrpay
|
cd xmrpay.link
|
||||||
|
# Serve with any web server that supports PHP
|
||||||
# Local Docker build
|
# No build tools, no npm, no database required
|
||||||
docker build -t xmrpay:dev .
|
python3 -m http.server 8080 # For development (no PHP features)
|
||||||
docker run -p 8080:80 -e DOMAIN=localhost xmrpay:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requirements for full functionality:
|
||||||
|
- PHP 8.x with `curl` extension
|
||||||
|
- Nginx or Apache (for `/s/` short URL rewrites)
|
||||||
|
- Writable `data/` directory
|
||||||
|
|
||||||
|
### Production Deploy (Safe)
|
||||||
|
|
||||||
|
Use the provided deploy script to avoid deleting runtime files in `data/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script deploys with `rsync --delete` but explicitly excludes `data/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No private keys** — TX proof uses the sender's TX key, not the receiver's view key
|
||||||
|
- **Client-side crypto** — Ed25519 verification runs in the browser
|
||||||
|
- **No tracking** — zero cookies, no analytics, no external scripts
|
||||||
|
- **RPC proxy** — allowlisted methods only, rate-limited
|
||||||
|
- **Self-hostable** — run your own instance for full control
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Embeddable `<iframe>` payment widget
|
||||||
|
- [ ] Invoice history (LocalStorage, CSV export)
|
||||||
|
- [ ] "Pay Button" generator (HTML snippet)
|
||||||
|
- [x] Auto-cleanup: Lazy-delete invoices after deadline expires
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -14,19 +14,13 @@ function send_security_headers(): void {
|
|||||||
|
|
||||||
// ── Origin verification ───────────────────────────────────────────────────────
|
// ── Origin verification ───────────────────────────────────────────────────────
|
||||||
function verify_origin(): void {
|
function verify_origin(): void {
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
||||||
// Allow same-origin (no Origin header from direct same-origin requests)
|
|
||||||
if ($origin === '') return;
|
|
||||||
|
|
||||||
// Dynamically allow the host this instance runs on
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$self_origin = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? '');
|
|
||||||
|
|
||||||
$allowed = [
|
$allowed = [
|
||||||
$self_origin,
|
|
||||||
'https://xmrpay.link',
|
'https://xmrpay.link',
|
||||||
'http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion',
|
'http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion',
|
||||||
];
|
];
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
// Allow same-origin (no Origin header from direct same-origin requests)
|
||||||
|
if ($origin === '') return;
|
||||||
if (!in_array($origin, $allowed, true)) {
|
if (!in_array($origin, $allowed, true)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Origin not allowed']);
|
echo json_encode(['error' => 'Origin not allowed']);
|
||||||
|
|||||||
61
app.js
61
app.js
@@ -35,17 +35,14 @@
|
|||||||
const qrContainer = $('#qr');
|
const qrContainer = $('#qr');
|
||||||
const uriBox = $('#uri');
|
const uriBox = $('#uri');
|
||||||
const openWalletBtn = $('#openWallet');
|
const openWalletBtn = $('#openWallet');
|
||||||
const copyUriBtn = $('#copyUri');
|
|
||||||
const copyAddrBtn = $('#copyAddr');
|
const copyAddrBtn = $('#copyAddr');
|
||||||
const countdownEl = $('#countdown');
|
const countdownEl = $('#countdown');
|
||||||
const fiatHint = $('#fiatHint');
|
const fiatHint = $('#fiatHint');
|
||||||
const toast = $('#toast');
|
const toast = $('#toast');
|
||||||
const shareLinkInput = $('#shareLink');
|
const shareLinkInput = $('#shareLink');
|
||||||
const copyShareLinkBtn = $('#copyShareLink');
|
const copyShareLinkBtn = $('#copyShareLink');
|
||||||
const useShortLinkCheckbox = $('#useShortLink');
|
|
||||||
const newRequestBtn = $('#newRequest');
|
const newRequestBtn = $('#newRequest');
|
||||||
const homeLink = $('#homeLink');
|
const homeLink = $('#homeLink');
|
||||||
let currentInvoiceHash = null;
|
|
||||||
|
|
||||||
// TX Proof DOM
|
// TX Proof DOM
|
||||||
const proofToggle = $('#proofToggle');
|
const proofToggle = $('#proofToggle');
|
||||||
@@ -61,21 +58,6 @@
|
|||||||
let pdfLoaded = false;
|
let pdfLoaded = false;
|
||||||
let lastPaidData = null;
|
let lastPaidData = null;
|
||||||
|
|
||||||
// --- Self-host Banner ---
|
|
||||||
(function () {
|
|
||||||
var PUBLIC_HOSTS = ['xmrpay.link', 'mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion'];
|
|
||||||
var banner = $('#selfHostBanner');
|
|
||||||
var dismiss = $('#dismissBanner');
|
|
||||||
if (!banner) return;
|
|
||||||
if (PUBLIC_HOSTS.indexOf(location.hostname) === -1) return;
|
|
||||||
if (sessionStorage.getItem('banner_dismissed')) return;
|
|
||||||
banner.hidden = false;
|
|
||||||
dismiss.addEventListener('click', function () {
|
|
||||||
banner.hidden = true;
|
|
||||||
sessionStorage.setItem('banner_dismissed', '1');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Currency Detection ---
|
// --- Currency Detection ---
|
||||||
function detectCurrency() {
|
function detectCurrency() {
|
||||||
var localeToCurrency = {
|
var localeToCurrency = {
|
||||||
@@ -137,12 +119,8 @@
|
|||||||
amountInput.addEventListener('input', updateFiatHint);
|
amountInput.addEventListener('input', updateFiatHint);
|
||||||
currencySelect.addEventListener('change', updateFiatHint);
|
currencySelect.addEventListener('change', updateFiatHint);
|
||||||
generateBtn.addEventListener('click', generate);
|
generateBtn.addEventListener('click', generate);
|
||||||
copyUriBtn.addEventListener('click', () => copyToClipboard(uriBox.textContent));
|
|
||||||
copyAddrBtn.addEventListener('click', () => copyToClipboard(addrInput.value.trim()));
|
copyAddrBtn.addEventListener('click', () => copyToClipboard(addrInput.value.trim()));
|
||||||
copyShareLinkBtn.addEventListener('click', () => copyToClipboard(shareLinkInput.value));
|
copyShareLinkBtn.addEventListener('click', () => copyToClipboard(shareLinkInput.value));
|
||||||
useShortLinkCheckbox.addEventListener('change', function () {
|
|
||||||
if (currentInvoiceHash) updateShareLink(currentInvoiceHash);
|
|
||||||
});
|
|
||||||
qrContainer.addEventListener('click', downloadQR);
|
qrContainer.addEventListener('click', downloadQR);
|
||||||
newRequestBtn.addEventListener('click', resetForm);
|
newRequestBtn.addEventListener('click', resetForm);
|
||||||
homeLink.addEventListener('click', function (e) { e.preventDefault(); resetForm(); });
|
homeLink.addEventListener('click', function (e) { e.preventDefault(); resetForm(); });
|
||||||
@@ -201,8 +179,6 @@
|
|||||||
qrContainer.classList.remove('paid', 'confirming');
|
qrContainer.classList.remove('paid', 'confirming');
|
||||||
uriBox.textContent = '';
|
uriBox.textContent = '';
|
||||||
shareLinkInput.value = '';
|
shareLinkInput.value = '';
|
||||||
useShortLinkCheckbox.checked = false;
|
|
||||||
currentInvoiceHash = null;
|
|
||||||
// Reset proof
|
// Reset proof
|
||||||
invoiceCode = null;
|
invoiceCode = null;
|
||||||
stopConfirmationPolling();
|
stopConfirmationPolling();
|
||||||
@@ -350,7 +326,7 @@
|
|||||||
buildSummary(xmrAmount, desc, timer);
|
buildSummary(xmrAmount, desc, timer);
|
||||||
updatePageTitle(xmrAmount, desc);
|
updatePageTitle(xmrAmount, desc);
|
||||||
|
|
||||||
// Share link
|
// Share link — keep existing short URL if present; otherwise shorten new hash
|
||||||
var deadlineTs = null;
|
var deadlineTs = null;
|
||||||
if (timer && timer > 0) {
|
if (timer && timer > 0) {
|
||||||
if (!deadlineEndMs) {
|
if (!deadlineEndMs) {
|
||||||
@@ -359,8 +335,14 @@
|
|||||||
deadlineTs = Math.floor(deadlineEndMs / 1000);
|
deadlineTs = Math.floor(deadlineEndMs / 1000);
|
||||||
}
|
}
|
||||||
const hash = buildHash(addr, xmrAmount, desc, timer, deadlineTs);
|
const hash = buildHash(addr, xmrAmount, desc, timer, deadlineTs);
|
||||||
currentInvoiceHash = hash;
|
if (invoiceCode) {
|
||||||
updateShareLink(hash);
|
shareLinkInput.value = location.origin + '/s/' + invoiceCode;
|
||||||
|
} else {
|
||||||
|
shareLinkInput.value = location.origin + '/#' + hash;
|
||||||
|
shortenUrl(hash).then(function (shortUrl) {
|
||||||
|
if (shortUrl) shareLinkInput.value = shortUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// QR
|
// QR
|
||||||
qrContainer.innerHTML = '';
|
qrContainer.innerHTML = '';
|
||||||
@@ -428,7 +410,6 @@
|
|||||||
const code = params.get('c');
|
const code = params.get('c');
|
||||||
if (code) {
|
if (code) {
|
||||||
invoiceCode = code;
|
invoiceCode = code;
|
||||||
useShortLinkCheckbox.checked = true;
|
|
||||||
// Verify short URL integrity (detect tampering)
|
// Verify short URL integrity (detect tampering)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
verifyShortUrlIntegrity(code, hash);
|
verifyShortUrlIntegrity(code, hash);
|
||||||
@@ -441,26 +422,6 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateShareLink(hash) {
|
|
||||||
var longUrl = location.origin + '/#' + hash;
|
|
||||||
shareLinkInput.value = longUrl;
|
|
||||||
|
|
||||||
if (!useShortLinkCheckbox.checked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invoiceCode) {
|
|
||||||
shareLinkInput.value = location.origin + '/s/' + invoiceCode;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shortenUrl(hash).then(function (shortUrl) {
|
|
||||||
if (shortUrl && useShortLinkCheckbox.checked && currentInvoiceHash === hash) {
|
|
||||||
shareLinkInput.value = shortUrl;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the redirected hash still matches the stored short URL mapping.
|
// Verify that the redirected hash still matches the stored short URL mapping.
|
||||||
function verifyShortUrlIntegrity(code, currentHash) {
|
function verifyShortUrlIntegrity(code, currentHash) {
|
||||||
fetch('/api/check-short.php?code=' + encodeURIComponent(code))
|
fetch('/api/check-short.php?code=' + encodeURIComponent(code))
|
||||||
@@ -629,8 +590,6 @@
|
|||||||
if (window.jspdf) { resolve(); return; }
|
if (window.jspdf) { resolve(); return; }
|
||||||
var script = document.createElement('script');
|
var script = document.createElement('script');
|
||||||
script.src = 'lib/jspdf.min.js';
|
script.src = 'lib/jspdf.min.js';
|
||||||
script.integrity = 'sha384-GwHhSt8QjC7J+v0zZ0Flfho/T76YHEcCL9w4rvjTIUHauh6gWJeBSIi3vWXxNhtA';
|
|
||||||
script.crossOrigin = 'anonymous';
|
|
||||||
script.onload = function () { pdfLoaded = true; resolve(); };
|
script.onload = function () { pdfLoaded = true; resolve(); };
|
||||||
script.onerror = function () { reject(new Error('Failed to load jsPDF')); };
|
script.onerror = function () { reject(new Error('Failed to load jsPDF')); };
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
@@ -837,8 +796,6 @@
|
|||||||
if (window.XmrCrypto) { resolve(); return; }
|
if (window.XmrCrypto) { resolve(); return; }
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = 'lib/xmr-crypto.bundle.js';
|
script.src = 'lib/xmr-crypto.bundle.js';
|
||||||
script.integrity = 'sha384-ta9IpDZOod8WcA7TprKyb/TxmOSNfkG0fCjhWssiSmpft9MLXAtSO8L8YmnH3DCY';
|
|
||||||
script.crossOrigin = 'anonymous';
|
|
||||||
script.onload = resolve;
|
script.onload = resolve;
|
||||||
script.onerror = function () { reject(new Error('Failed to load crypto module')); };
|
script.onerror = function () { reject(new Error('Failed to load crypto module')); };
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|||||||
2
app.min.js
vendored
2
app.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
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:
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Start PHP-FPM in background
|
|
||||||
php-fpm &
|
|
||||||
|
|
||||||
# Run Caddy in foreground
|
|
||||||
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
|
||||||
32
i18n.js
32
i18n.js
@@ -11,9 +11,7 @@ var I18n = (function () {
|
|||||||
ru: { name: 'Русский' }
|
ru: { name: 'Русский' }
|
||||||
};
|
};
|
||||||
|
|
||||||
var VERSION = '1.0.0';
|
var footer = 'Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a>';
|
||||||
|
|
||||||
var footer = 'Open Source · No Tracking · No KYC<br /><a href="https://github.com/schmidt1024/xmrpay" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a><br /><span class="version">v' + VERSION + '</span>';
|
|
||||||
|
|
||||||
var translations = {
|
var translations = {
|
||||||
en: {
|
en: {
|
||||||
@@ -28,7 +26,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Days',
|
placeholder_timer_custom: 'Days',
|
||||||
btn_generate: 'Create payment request',
|
btn_generate: 'Create payment request',
|
||||||
btn_open_wallet: 'Open in wallet',
|
btn_open_wallet: 'Open in wallet',
|
||||||
btn_copy_uri: 'Copy payment URI',
|
|
||||||
btn_copy_addr: 'Copy address',
|
btn_copy_addr: 'Copy address',
|
||||||
btn_download_pdf: 'PDF Invoice',
|
btn_download_pdf: 'PDF Invoice',
|
||||||
pdf_title: 'Payment Request',
|
pdf_title: 'Payment Request',
|
||||||
@@ -41,12 +38,9 @@ var I18n = (function () {
|
|||||||
pdf_scan_qr: 'Scan QR code to pay',
|
pdf_scan_qr: 'Scan QR code to pay',
|
||||||
pdf_footer: 'Created with xmrpay.link',
|
pdf_footer: 'Created with xmrpay.link',
|
||||||
qr_hint: 'Click QR to save',
|
qr_hint: 'Click QR to save',
|
||||||
self_host_banner: 'This is a public demo. For real payments, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">host your own instance</a> — it takes 60 seconds.',
|
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Currency',
|
aria_currency: 'Currency',
|
||||||
label_share_link: 'Shareable link',
|
label_share_link: 'Shareable link',
|
||||||
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',
|
btn_new_request: 'New payment request',
|
||||||
toast_copied: 'Copied!',
|
toast_copied: 'Copied!',
|
||||||
countdown_expired: 'Payment deadline expired',
|
countdown_expired: 'Payment deadline expired',
|
||||||
@@ -81,7 +75,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Tage',
|
placeholder_timer_custom: 'Tage',
|
||||||
btn_generate: 'Zahlungsanforderung erstellen',
|
btn_generate: 'Zahlungsanforderung erstellen',
|
||||||
btn_open_wallet: 'In Wallet öffnen',
|
btn_open_wallet: 'In Wallet öffnen',
|
||||||
btn_copy_uri: 'Zahlungs-URI kopieren',
|
|
||||||
btn_copy_addr: 'Adresse kopieren',
|
btn_copy_addr: 'Adresse kopieren',
|
||||||
btn_download_pdf: 'PDF Rechnung',
|
btn_download_pdf: 'PDF Rechnung',
|
||||||
pdf_title: 'Zahlungsanforderung',
|
pdf_title: 'Zahlungsanforderung',
|
||||||
@@ -93,13 +86,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Datum',
|
pdf_date: 'Datum',
|
||||||
pdf_scan_qr: 'QR-Code scannen zum Bezahlen',
|
pdf_scan_qr: 'QR-Code scannen zum Bezahlen',
|
||||||
pdf_footer: 'Erstellt mit xmrpay.link',
|
pdf_footer: 'Erstellt mit xmrpay.link',
|
||||||
self_host_banner: 'Dies ist eine öffentliche Demo. Für echte Zahlungen <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">eigene Instanz hosten</a> — dauert 60 Sekunden.',
|
|
||||||
qr_hint: 'Klick auf QR zum Speichern',
|
qr_hint: 'Klick auf QR zum Speichern',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Währung',
|
aria_currency: 'Währung',
|
||||||
label_share_link: 'Teilbarer Link',
|
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',
|
btn_new_request: 'Neue Zahlungsanforderung',
|
||||||
toast_copied: 'Kopiert!',
|
toast_copied: 'Kopiert!',
|
||||||
countdown_expired: 'Zahlungsfrist abgelaufen',
|
countdown_expired: 'Zahlungsfrist abgelaufen',
|
||||||
@@ -134,7 +124,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Jours',
|
placeholder_timer_custom: 'Jours',
|
||||||
btn_generate: 'Créer une demande de paiement',
|
btn_generate: 'Créer une demande de paiement',
|
||||||
btn_open_wallet: 'Ouvrir dans le wallet',
|
btn_open_wallet: 'Ouvrir dans le wallet',
|
||||||
btn_copy_uri: 'Copier l\'URI de paiement',
|
|
||||||
btn_copy_addr: 'Copier l\'adresse',
|
btn_copy_addr: 'Copier l\'adresse',
|
||||||
btn_download_pdf: 'Facture PDF',
|
btn_download_pdf: 'Facture PDF',
|
||||||
pdf_title: 'Demande de paiement',
|
pdf_title: 'Demande de paiement',
|
||||||
@@ -146,13 +135,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Date',
|
pdf_date: 'Date',
|
||||||
pdf_scan_qr: 'Scanner le QR code pour payer',
|
pdf_scan_qr: 'Scanner le QR code pour payer',
|
||||||
pdf_footer: 'Créé avec xmrpay.link',
|
pdf_footer: 'Créé avec xmrpay.link',
|
||||||
self_host_banner: 'Ceci est une démo publique. Pour de vrais paiements, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">hébergez votre propre instance</a> — ça prend 60 secondes.',
|
|
||||||
qr_hint: 'Cliquez sur le QR pour enregistrer',
|
qr_hint: 'Cliquez sur le QR pour enregistrer',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Devise',
|
aria_currency: 'Devise',
|
||||||
label_share_link: 'Lien partageable',
|
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',
|
btn_new_request: 'Nouvelle demande de paiement',
|
||||||
toast_copied: 'Copié !',
|
toast_copied: 'Copié !',
|
||||||
countdown_expired: 'Délai de paiement expiré',
|
countdown_expired: 'Délai de paiement expiré',
|
||||||
@@ -187,7 +173,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Giorni',
|
placeholder_timer_custom: 'Giorni',
|
||||||
btn_generate: 'Crea richiesta di pagamento',
|
btn_generate: 'Crea richiesta di pagamento',
|
||||||
btn_open_wallet: 'Apri nel wallet',
|
btn_open_wallet: 'Apri nel wallet',
|
||||||
btn_copy_uri: 'Copia URI pagamento',
|
|
||||||
btn_copy_addr: 'Copia indirizzo',
|
btn_copy_addr: 'Copia indirizzo',
|
||||||
btn_download_pdf: 'Fattura PDF',
|
btn_download_pdf: 'Fattura PDF',
|
||||||
pdf_title: 'Richiesta di pagamento',
|
pdf_title: 'Richiesta di pagamento',
|
||||||
@@ -199,13 +184,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Data',
|
pdf_date: 'Data',
|
||||||
pdf_scan_qr: 'Scansiona il QR per pagare',
|
pdf_scan_qr: 'Scansiona il QR per pagare',
|
||||||
pdf_footer: 'Creato con xmrpay.link',
|
pdf_footer: 'Creato con xmrpay.link',
|
||||||
self_host_banner: 'Questa è una demo pubblica. Per pagamenti reali, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">ospita la tua istanza</a> — ci vogliono 60 secondi.',
|
|
||||||
qr_hint: 'Clicca sul QR per salvare',
|
qr_hint: 'Clicca sul QR per salvare',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Valuta',
|
aria_currency: 'Valuta',
|
||||||
label_share_link: 'Link condivisibile',
|
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',
|
btn_new_request: 'Nuova richiesta di pagamento',
|
||||||
toast_copied: 'Copiato!',
|
toast_copied: 'Copiato!',
|
||||||
countdown_expired: 'Scadenza pagamento superata',
|
countdown_expired: 'Scadenza pagamento superata',
|
||||||
@@ -240,7 +222,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Días',
|
placeholder_timer_custom: 'Días',
|
||||||
btn_generate: 'Crear solicitud de pago',
|
btn_generate: 'Crear solicitud de pago',
|
||||||
btn_open_wallet: 'Abrir en wallet',
|
btn_open_wallet: 'Abrir en wallet',
|
||||||
btn_copy_uri: 'Copiar URI de pago',
|
|
||||||
btn_copy_addr: 'Copiar dirección',
|
btn_copy_addr: 'Copiar dirección',
|
||||||
btn_download_pdf: 'Factura PDF',
|
btn_download_pdf: 'Factura PDF',
|
||||||
pdf_title: 'Solicitud de pago',
|
pdf_title: 'Solicitud de pago',
|
||||||
@@ -252,13 +233,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Fecha',
|
pdf_date: 'Fecha',
|
||||||
pdf_scan_qr: 'Escanear QR para pagar',
|
pdf_scan_qr: 'Escanear QR para pagar',
|
||||||
pdf_footer: 'Creado con xmrpay.link',
|
pdf_footer: 'Creado con xmrpay.link',
|
||||||
self_host_banner: 'Esta es una demo pública. Para pagos reales, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">aloja tu propia instancia</a> — toma 60 segundos.',
|
|
||||||
qr_hint: 'Clic en QR para guardar',
|
qr_hint: 'Clic en QR para guardar',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Moneda',
|
aria_currency: 'Moneda',
|
||||||
label_share_link: 'Enlace compartible',
|
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',
|
btn_new_request: 'Nueva solicitud de pago',
|
||||||
toast_copied: '¡Copiado!',
|
toast_copied: '¡Copiado!',
|
||||||
countdown_expired: 'Plazo de pago vencido',
|
countdown_expired: 'Plazo de pago vencido',
|
||||||
@@ -293,7 +271,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Dias',
|
placeholder_timer_custom: 'Dias',
|
||||||
btn_generate: 'Criar pedido de pagamento',
|
btn_generate: 'Criar pedido de pagamento',
|
||||||
btn_open_wallet: 'Abrir na wallet',
|
btn_open_wallet: 'Abrir na wallet',
|
||||||
btn_copy_uri: 'Copiar URI de pagamento',
|
|
||||||
btn_copy_addr: 'Copiar endereço',
|
btn_copy_addr: 'Copiar endereço',
|
||||||
btn_download_pdf: 'Fatura PDF',
|
btn_download_pdf: 'Fatura PDF',
|
||||||
pdf_title: 'Pedido de pagamento',
|
pdf_title: 'Pedido de pagamento',
|
||||||
@@ -305,13 +282,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Data',
|
pdf_date: 'Data',
|
||||||
pdf_scan_qr: 'Digitalizar QR para pagar',
|
pdf_scan_qr: 'Digitalizar QR para pagar',
|
||||||
pdf_footer: 'Criado com xmrpay.link',
|
pdf_footer: 'Criado com xmrpay.link',
|
||||||
self_host_banner: 'Esta é uma demo pública. Para pagamentos reais, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">hospede sua própria instância</a> — leva 60 segundos.',
|
|
||||||
qr_hint: 'Clique no QR para guardar',
|
qr_hint: 'Clique no QR para guardar',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Moeda',
|
aria_currency: 'Moeda',
|
||||||
label_share_link: 'Link partilhável',
|
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',
|
btn_new_request: 'Novo pedido de pagamento',
|
||||||
toast_copied: 'Copiado!',
|
toast_copied: 'Copiado!',
|
||||||
countdown_expired: 'Prazo de pagamento expirado',
|
countdown_expired: 'Prazo de pagamento expirado',
|
||||||
@@ -346,7 +320,6 @@ var I18n = (function () {
|
|||||||
placeholder_timer_custom: 'Дней',
|
placeholder_timer_custom: 'Дней',
|
||||||
btn_generate: 'Создать запрос на оплату',
|
btn_generate: 'Создать запрос на оплату',
|
||||||
btn_open_wallet: 'Открыть в кошельке',
|
btn_open_wallet: 'Открыть в кошельке',
|
||||||
btn_copy_uri: 'Копировать платежный URI',
|
|
||||||
btn_copy_addr: 'Копировать адрес',
|
btn_copy_addr: 'Копировать адрес',
|
||||||
btn_download_pdf: 'PDF счёт',
|
btn_download_pdf: 'PDF счёт',
|
||||||
pdf_title: 'Запрос на оплату',
|
pdf_title: 'Запрос на оплату',
|
||||||
@@ -358,13 +331,10 @@ var I18n = (function () {
|
|||||||
pdf_date: 'Дата',
|
pdf_date: 'Дата',
|
||||||
pdf_scan_qr: 'Сканируйте QR для оплаты',
|
pdf_scan_qr: 'Сканируйте QR для оплаты',
|
||||||
pdf_footer: 'Создано с помощью xmrpay.link',
|
pdf_footer: 'Создано с помощью xmrpay.link',
|
||||||
self_host_banner: 'Это публичная демо-версия. Для реальных платежей <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">разверните свой экземпляр</a> — это займёт 60 секунд.',
|
|
||||||
qr_hint: 'Нажмите на QR для сохранения',
|
qr_hint: 'Нажмите на QR для сохранения',
|
||||||
footer: footer,
|
footer: footer,
|
||||||
aria_currency: 'Валюта',
|
aria_currency: 'Валюта',
|
||||||
label_share_link: 'Ссылка для отправки',
|
label_share_link: 'Ссылка для отправки',
|
||||||
shortlink_toggle_label: 'Использовать короткую ссылку (нужно доверять серверу)',
|
|
||||||
shortlink_toggle_hint: 'Компромисс: короткие ссылки удобны, но скомпрометированный сервер может подменить данные счета при первом открытии.',
|
|
||||||
btn_new_request: 'Новый запрос на оплату',
|
btn_new_request: 'Новый запрос на оплату',
|
||||||
toast_copied: 'Скопировано!',
|
toast_copied: 'Скопировано!',
|
||||||
countdown_expired: 'Срок оплаты истёк',
|
countdown_expired: 'Срок оплаты истёк',
|
||||||
|
|||||||
2
i18n.min.js
vendored
2
i18n.min.js
vendored
File diff suppressed because one or more lines are too long
24
index.html
24
index.html
@@ -5,18 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>xmrpay.link — Monero Invoice Generator</title>
|
<title>xmrpay.link — Monero Invoice Generator</title>
|
||||||
<meta name="description" content="Create Monero payment requests in seconds. No account registration, no KYC. Minimal backend for short URLs only.">
|
<meta name="description" content="Create Monero payment requests in seconds. No account registration, no KYC. Minimal backend for short URLs only.">
|
||||||
<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="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="preload" href="fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="style.css?v=20260326-3" integrity="sha384-TLao5+UFp5VS0Vn+LamdOYwxjGy1ZB0dNemTi7Za0HpPsnA+koCWOmVM0Szwaf3n" crossorigin="anonymous">
|
<link rel="stylesheet" href="style.css?v=20260326-3">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="self-host-banner" id="selfHostBanner" hidden>
|
|
||||||
<span data-i18n-html="self_host_banner">This is a public demo. For real payments, <a href="https://github.com/schmidt1024/xmrpay#self-host-in-60-seconds">host your own instance</a> — it takes 60 seconds.</span>
|
|
||||||
<button id="dismissBanner" aria-label="Dismiss">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
|
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
|
||||||
<p data-i18n="subtitle">Monero payment request in seconds</p>
|
<p data-i18n="subtitle">Monero payment request in seconds</p>
|
||||||
@@ -73,13 +67,6 @@
|
|||||||
<div class="uri-box" id="uri" style="display:none"></div>
|
<div class="uri-box" id="uri" style="display:none"></div>
|
||||||
<div class="share-link-box" id="shareLinkBox">
|
<div class="share-link-box" id="shareLinkBox">
|
||||||
<label data-i18n="label_share_link">Shareable link</label>
|
<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">
|
<div class="share-link-row">
|
||||||
<input type="text" id="shareLink" readonly data-i18n-aria="label_share_link">
|
<input type="text" id="shareLink" readonly data-i18n-aria="label_share_link">
|
||||||
<button class="btn btn-secondary btn-icon" id="copyShareLink" title="Copy">
|
<button class="btn btn-secondary btn-icon" id="copyShareLink" title="Copy">
|
||||||
@@ -89,7 +76,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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="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="copyAddr" data-i18n="btn_copy_addr">Copy address</button>
|
||||||
<button class="btn btn-secondary" id="downloadPdf" data-i18n="btn_download_pdf">PDF Invoice</button>
|
<button class="btn btn-secondary" id="downloadPdf" data-i18n="btn_download_pdf">PDF Invoice</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +106,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p data-i18n-html="footer">Open Source · No Tracking · No KYC<br /><a href="https://github.com/schmidt1024/xmrpay" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a><br /><span class="version">v1.0.0</span></p>
|
<p data-i18n-html="footer">Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="lang-picker" id="langPicker">
|
<div class="lang-picker" id="langPicker">
|
||||||
@@ -136,8 +122,8 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="lib/qrcode.min.js?v=20260326-3" integrity="sha384-3zSEDfvllQohrq0PHL1fOXJuC/jSOO34H46t6UQfobFOmxE5BpjjaIJY5F2/bMnU" crossorigin="anonymous" defer></script>
|
<script src="lib/qrcode.min.js?v=20260326-3" defer></script>
|
||||||
<script src="i18n.min.js?v=20260326-3" integrity="sha384-mKqQalrpTWCzSD1ErLtp+GqHpzJDm7D1jzHDMuhCW7ql2v9YkEzSfE4PNuTj4dqU" crossorigin="anonymous" defer></script>
|
<script src="i18n.min.js?v=20260326-3" defer></script>
|
||||||
<script src="app.min.js?v=20260326-3" integrity="sha384-tdgiaUZYJ6E+/EqlbzOvxRvySKQZNdxjNktRV3K75fitMLdkR5DXuVU9XTppNku5" crossorigin="anonymous" defer></script>
|
<script src="app.min.js?v=20260326-3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
60
install.sh
60
install.sh
@@ -1,60 +0,0 @@
|
|||||||
#!/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 " https://$DOMAIN"
|
|
||||||
echo ""
|
|
||||||
echo " Watchtower checks for updates every 6 hours."
|
|
||||||
echo " Data stored in Docker volume: xmrpay-data"
|
|
||||||
echo " Config: $INSTALL_DIR/.env"
|
|
||||||
echo ""
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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;
|
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>xmrpay.link — Privacy & Terms</title>
|
<title>xmrpay.link — Privacy & Terms</title>
|
||||||
<meta name="description" content="Privacy policy and terms of use for xmrpay.link.">
|
<meta name="description" content="Privacy policy and terms of use for xmrpay.link.">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; 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="icon" href="favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="style.css?v=20260326-3" integrity="sha384-TLao5+UFp5VS0Vn+LamdOYwxjGy1ZB0dNemTi7Za0HpPsnA+koCWOmVM0Szwaf3n" crossorigin="anonymous">
|
<link rel="stylesheet" href="style.css?v=20260326-3">
|
||||||
<style>
|
<style>
|
||||||
main.legal-main {
|
main.legal-main {
|
||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
@@ -198,7 +197,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p data-i18n-html="footer">Open Source · No Tracking · No KYC · <a href="https://github.com/schmidt1024/xmrpay" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a></p>
|
<p data-i18n-html="footer">Open Source · No Tracking · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank" rel="noopener noreferrer">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a> · <a href="/privacy.html">Privacy & Terms</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="lang-picker" id="langPicker">
|
<div class="lang-picker" id="langPicker">
|
||||||
|
|||||||
2
s.php
2
s.php
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
$pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) && $_SERVER['PATH_INFO'] !== '' ? $_SERVER['PATH_INFO'] : null;
|
$pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : null;
|
||||||
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
|
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
|
||||||
$code = trim($pathInfo ?? $queryCode, '/');
|
$code = trim($pathInfo ?? $queryCode, '/');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -7,12 +7,6 @@ set -euo pipefail
|
|||||||
# DEPLOY_HOST e.g. root@example.com or deploy@example.com
|
# DEPLOY_HOST e.g. root@example.com or deploy@example.com
|
||||||
# DEPLOY_TARGET e.g. /home/user/web/xmrpay.link/public_html
|
# 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):
|
# Optional local config file (not committed):
|
||||||
# scripts/.deploy.env
|
# scripts/.deploy.env
|
||||||
|
|
||||||
@@ -26,10 +20,6 @@ fi
|
|||||||
|
|
||||||
HOST="${DEPLOY_HOST:-}"
|
HOST="${DEPLOY_HOST:-}"
|
||||||
TARGET="${DEPLOY_TARGET:-}"
|
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
|
if [[ -z "$HOST" || -z "$TARGET" ]]; then
|
||||||
echo "Missing deploy configuration." >&2
|
echo "Missing deploy configuration." >&2
|
||||||
@@ -37,104 +27,10 @@ if [[ -z "$HOST" || -z "$TARGET" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$BACKUP_ENABLE" == "1" ]]; then
|
rsync -avz --delete \
|
||||||
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 '.git' \
|
||||||
--exclude 'node_modules' \
|
--exclude 'node_modules' \
|
||||||
--exclude 'data/' \
|
--exclude 'data/' \
|
||||||
--exclude 'scripts/.deploy.env' \
|
|
||||||
./ "$HOST:$TARGET"
|
./ "$HOST:$TARGET"
|
||||||
|
|
||||||
echo "Deploy complete (data/ preserved)."
|
echo "Deploy complete (data/ preserved)."
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
66
style.css
66
style.css
@@ -58,40 +58,6 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-host-banner {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.6rem 2.5rem 0.6rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-host-banner a {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-host-banner button {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-host-banner button:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 1rem 1rem;
|
padding: 2rem 1rem 1rem;
|
||||||
@@ -493,33 +459,6 @@ textarea {
|
|||||||
margin-bottom: 0.3rem;
|
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 {
|
.share-link-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -726,7 +665,6 @@ footer {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
footer a {
|
||||||
@@ -734,10 +672,6 @@ footer a {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .version {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user