diff --git a/AGENTS.md b/AGENTS.md index 46045d6..87e838e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,8 @@ js/ │ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt) │ ├── crypto.js # NaCl Encryption │ ├── currency.js # XMR/Fiat Umrechnung -│ └── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify) +│ ├── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify) +│ └── btcpay.js # BTCPay Server Integration (Invoice, Checkout, Polling) └── components/ ├── app-shell.js # Layout, registriert Routes ├── app-header.js # Header (Theme-Toggle, Lang-Dropdown, Profil-Dropdown) @@ -140,7 +141,7 @@ locales/ 4. ~~PoW-Captcha server-seitig~~ ✅ PHP-Server auf `pow.dgray.io` 5. ~~TweetNaCl self-hosted~~ ✅ In `js/vendor/` 6. ~~Chat-Crypto fix~~ ✅ `box.before` + `secretbox` -7. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/) +7. ~~Payment-Integration mit BTCpay Server~~ ✅ Proxy auf `pow.dgray.io`, Frontend-Service `btcpay.js` 8. Reputation-System (5/15/50 Deals Stufen) 9. Push-Benachrichtigungen für neue Nachrichten diff --git a/docs/pow-server/README.md b/docs/pow-server/README.md index 8814466..4e481e6 100644 --- a/docs/pow-server/README.md +++ b/docs/pow-server/README.md @@ -1,30 +1,38 @@ -# PoW Captcha Server +# PoW Captcha & Payment Server -PHP-basierter Proof-of-Work Captcha Server für dgray.io. +PHP-basierter Server für dgray.io mit Proof-of-Work Captcha und BTCPay Payment-Proxy. ## Setup 1. Subdomain `pow.dgray.io` auf den Server zeigen 2. Dateien in das Web-Root kopieren -3. Secret setzen: +3. Secrets setzen: ```bash # In .env oder Apache/Nginx config: SetEnv POW_SECRET $(openssl rand -hex 32) + SetEnv BTCPAY_API_KEY your_btcpay_api_key + SetEnv BTCPAY_STORE_ID your_btcpay_store_id ``` - Oder direkt in `config.php` den Wert von `POW_SECRET` ändern. + Oder direkt in `config.php` die Werte ändern. 4. Testen: ```bash + # PoW Challenge curl https://pow.dgray.io/challenge + + # BTCPay Invoice erstellen + curl -X POST https://pow.dgray.io/btcpay/invoice \ + -H "Content-Type: application/json" \ + -d '{"listingId": "test-123", "currency": "EUR"}' ``` ## Endpoints ### GET /challenge -Gibt eine signierte Challenge zurück. +Gibt eine signierte PoW-Challenge zurück. ### POST /verify -Prüft die Lösung. Body (JSON): +Prüft die PoW-Lösung. Body (JSON): ```json { "challenge": "...", @@ -35,9 +43,51 @@ Prüft die Lösung. Body (JSON): } ``` +### POST /btcpay/invoice +Erstellt eine BTCPay Server Invoice für eine Listing-Gebühr. +Body (JSON): +```json +{ + "listingId": "uuid-string", + "currency": "EUR" +} +``` +Response: +```json +{ + "invoiceId": "...", + "checkoutLink": "https://pay.xmr.rocks/i/...", + "status": "New", + "expirationTime": 1700000000 +} +``` + +### GET /btcpay/status?id={invoiceId} +Prüft den Zahlungsstatus einer Invoice. +Response: +```json +{ + "invoiceId": "...", + "status": "New|Processing|Settled|Expired|Invalid", + "additionalStatus": "None|PaidLate|PaidPartial|..." +} +``` + +## Gebühren + +| Währung | Betrag | +|---------|--------| +| EUR | 1 | +| USD | 1 | +| CHF | 1 | +| GBP | 1 | +| JPY | 200 | + ## Sicherheit - HMAC-SHA256 signierte Challenges (nicht fälschbar) - TTL: 2 Minuten - CORS: nur `https://dgray.io` - `hash_equals()` gegen Timing-Attacks +- BTCPay API-Key bleibt serverseitig (nie im Frontend) +- Gebühren serverseitig erzwungen (nicht manipulierbar) diff --git a/docs/pow-server/btcpay-invoice.php b/docs/pow-server/btcpay-invoice.php new file mode 100644 index 0000000..f15d43d --- /dev/null +++ b/docs/pow-server/btcpay-invoice.php @@ -0,0 +1,77 @@ + 'Method not allowed']); + exit; +} + +$input = json_decode(file_get_contents('php://input'), true); + +$listingId = $input['listingId'] ?? null; +$currency = $input['currency'] ?? 'EUR'; + +if (!$listingId) { + http_response_code(400); + echo json_encode(['error' => 'Missing listingId']); + exit; +} + +$fees = LISTING_FEE; +if (!isset($fees[$currency])) { + http_response_code(400); + echo json_encode(['error' => 'Unsupported currency', 'supported' => array_keys($fees)]); + exit; +} + +$amount = $fees[$currency]; + +$payload = json_encode([ + 'amount' => $amount, + 'currency' => $currency, + 'metadata' => [ + 'listingId' => $listingId, + 'orderId' => 'listing-' . $listingId, + ], +]); + +$url = BTCPAY_BASE_URL . '/api/v1/stores/' . BTCPAY_STORE_ID . '/invoices'; + +$context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nAuthorization: token " . BTCPAY_API_KEY . "\r\n", + 'content' => $payload, + 'ignore_errors' => true, + ], +]); + +$response = file_get_contents($url, false, $context); + +if ($response === false) { + http_response_code(502); + echo json_encode(['error' => 'Failed to connect to payment server']); + exit; +} + +// Extract HTTP status from response headers +$statusCode = 500; +if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $matches)) { + $statusCode = (int)$matches[0]; +} + +$data = json_decode($response, true); + +if ($statusCode >= 400) { + http_response_code($statusCode); + echo json_encode(['error' => $data['message'] ?? 'Invoice creation failed']); + exit; +} + +echo json_encode([ + 'invoiceId' => $data['id'] ?? null, + 'checkoutLink' => $data['checkoutLink'] ?? null, + 'status' => $data['status'] ?? null, + 'expirationTime' => $data['expirationTime'] ?? null, +]); diff --git a/docs/pow-server/btcpay-status.php b/docs/pow-server/btcpay-status.php new file mode 100644 index 0000000..9a9320b --- /dev/null +++ b/docs/pow-server/btcpay-status.php @@ -0,0 +1,54 @@ + 'Method not allowed']); + exit; +} + +$invoiceId = $_GET['id'] ?? null; + +if (!$invoiceId) { + http_response_code(400); + echo json_encode(['error' => 'Missing id parameter']); + exit; +} + +$url = BTCPAY_BASE_URL . '/api/v1/stores/' . BTCPAY_STORE_ID . '/invoices/' . urlencode($invoiceId); + +$context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Authorization: token " . BTCPAY_API_KEY . "\r\n", + 'ignore_errors' => true, + ], +]); + +$response = file_get_contents($url, false, $context); + +if ($response === false) { + http_response_code(502); + echo json_encode(['error' => 'Failed to connect to payment server']); + exit; +} + +// Extract HTTP status from response headers +$statusCode = 500; +if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $matches)) { + $statusCode = (int)$matches[0]; +} + +$data = json_decode($response, true); + +if ($statusCode >= 400) { + http_response_code($statusCode); + echo json_encode(['error' => $data['message'] ?? 'Failed to fetch invoice status']); + exit; +} + +echo json_encode([ + 'invoiceId' => $data['id'] ?? null, + 'status' => $data['status'] ?? null, + 'additionalStatus' => $data['additionalStatus'] ?? null, +]); diff --git a/docs/pow-server/config.php b/docs/pow-server/config.php index 41623cd..f52c7c4 100644 --- a/docs/pow-server/config.php +++ b/docs/pow-server/config.php @@ -2,3 +2,9 @@ define('POW_SECRET', getenv('POW_SECRET') ?: 'CHANGE_ME_TO_A_RANDOM_64_CHAR_HEX_STRING'); define('POW_DIFFICULTY', 4); define('POW_TTL_SECONDS', 120); + +define('BTCPAY_BASE_URL', getenv('BTCPAY_BASE_URL') ?: 'https://pay.xmr.rocks'); +define('BTCPAY_API_KEY', getenv('BTCPAY_API_KEY') ?: 'CHANGE_ME'); +define('BTCPAY_STORE_ID', getenv('BTCPAY_STORE_ID') ?: 'CHANGE_ME'); +define('BTCPAY_WEBHOOK_SECRET', getenv('BTCPAY_WEBHOOK_SECRET') ?: ''); +define('LISTING_FEE', ['EUR' => 1, 'USD' => 1, 'CHF' => 1, 'GBP' => 1, 'JPY' => 200]); diff --git a/docs/pow-server/index.php b/docs/pow-server/index.php index 295958b..46ac641 100644 --- a/docs/pow-server/index.php +++ b/docs/pow-server/index.php @@ -19,6 +19,12 @@ switch ($uri) { case '/verify': require __DIR__ . '/verify.php'; break; + case '/btcpay/invoice': + require __DIR__ . '/btcpay-invoice.php'; + break; + case '/btcpay/status': + require __DIR__ . '/btcpay-status.php'; + break; default: http_response_code(404); echo json_encode(['error' => 'Not found']); diff --git a/js/components/pages/page-create.js b/js/components/pages/page-create.js index 5d13105..b55cfb0 100644 --- a/js/components/pages/page-create.js +++ b/js/components/pages/page-create.js @@ -4,6 +4,7 @@ import { auth } from '../../services/auth.js' import { directus } from '../../services/directus.js' import { categoriesService } from '../../services/categories.js' import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js' +import { createInvoice, openCheckout, pollUntilDone, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js' import { escapeHTML } from '../../utils/helpers.js' import '../location-picker.js' import '../pow-captcha.js' @@ -527,7 +528,6 @@ class PageCreate extends HTMLElement { const form = e.target - // Read current form values directly (more reliable than event listeners) const formElements = { title: form.querySelector('#title')?.value || '', description: form.querySelector('#description')?.value || '', @@ -541,7 +541,6 @@ class PageCreate extends HTMLElement { moneroAddress: form.querySelector('#moneroAddress')?.value || '' } - // Validate PoW Captcha (only for new accounts and new listings) if (!this.editMode && this.isNewAccount) { const captcha = this.querySelector('#pow-captcha') if (!captcha?.isSolved()) { @@ -550,7 +549,6 @@ class PageCreate extends HTMLElement { } } - // Validate Monero address if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) { this.showError(t('create.invalidMoneroAddress')) return @@ -564,14 +562,12 @@ class PageCreate extends HTMLElement { submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing') try { - // Upload new images first let newImageIds = [] if (this.imageFiles.length > 0) { const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles) newImageIds = uploadedFiles.map(f => f.id) } - // Build listing data from form values const listingData = { title: formElements.title, slug: this.generateSlug(formElements.title), @@ -580,12 +576,6 @@ class PageCreate extends HTMLElement { currency: formElements.currency } - // Only set status on create, not on edit - if (!this.editMode) { - listingData.status = 'published' - } - - // Add optional fields only if set if (formElements.price_mode) listingData.price_mode = formElements.price_mode if (formElements.category) listingData.category = formElements.category if (formElements.condition) listingData.condition = formElements.condition @@ -594,16 +584,7 @@ class PageCreate extends HTMLElement { listingData.shipping_cost = parseFloat(formElements.shipping_cost) } if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress - - // Calculate expires_at (only on create) - 30 days for regular users, 60 for power users - if (!this.editMode) { - const days = 30 // TODO: 60 for power users - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + days) - listingData.expires_at = expiresAt.toISOString() - } - // Handle location - find or create in locations collection if (this.formData.locationData) { const locationId = await this.findOrCreateLocation(this.formData.locationData) if (locationId) { @@ -612,8 +593,6 @@ class PageCreate extends HTMLElement { } if (this.editMode) { - // Update existing listing - // Add new images if any if (newImageIds.length > 0) { listingData.images = { create: newImageIds.map((id, index) => ({ @@ -626,7 +605,10 @@ class PageCreate extends HTMLElement { await directus.updateListing(this.editId, listingData) router.navigate(`/listing/${this.editId}`) } else { - // Create new listing + // Save as draft first, then trigger payment + listingData.status = 'draft' + listingData.payment_status = 'unpaid' + if (newImageIds.length > 0) { listingData.images = { create: newImageIds.map((id, index) => ({ @@ -638,9 +620,9 @@ class PageCreate extends HTMLElement { const listing = await directus.createListing(listingData) this.clearDraft() - + if (listing?.id) { - router.navigate(`/listing/${listing.id}`) + await this.startPayment(listing.id, formElements.currency) } else { router.navigate('/') } @@ -652,12 +634,100 @@ class PageCreate extends HTMLElement { submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish') this.submitting = false - // Extract detailed error message const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed') this.showError(errorMsg) } } + async startPayment(listingId, currency = 'EUR') { + const submitBtn = this.querySelector('[type="submit"]') + + try { + // Check for existing pending invoice + const pending = getPendingInvoice(listingId) + let invoiceId = null + + if (pending?.invoiceId) { + const status = await getInvoiceStatus(pending.invoiceId) + if (status.status === 'New') { + invoiceId = pending.invoiceId + } else if (status.status === 'Settled') { + await this.onPaymentSuccess(listingId) + return + } else { + clearPendingInvoice(listingId) + } + } + + if (!invoiceId) { + if (submitBtn) submitBtn.textContent = t('payment.paying') + const invoice = await createInvoice(listingId, currency) + invoiceId = invoice.invoiceId + savePendingInvoice(listingId, invoiceId) + + await directus.updateListing(listingId, { + payment_status: 'pending', + btcpay_invoice_id: invoiceId + }) + } + + const modalStatus = await openCheckout(invoiceId) + + if (modalStatus === 'complete' || modalStatus === 'paid') { + await this.onPaymentSuccess(listingId) + return + } + + // Poll for final status after modal close + const result = await pollUntilDone(invoiceId, { + interval: 4000, + timeout: 60000, + onUpdate: (update) => { + if (update.status === 'Processing' && submitBtn) { + submitBtn.textContent = t('payment.processing') + } + } + }) + + if (result.status === 'Settled') { + await this.onPaymentSuccess(listingId) + } else { + await directus.updateListing(listingId, { payment_status: 'expired' }) + clearPendingInvoice(listingId) + this.showError(t('payment.expired')) + this.submitting = false + if (submitBtn) { + submitBtn.disabled = false + submitBtn.textContent = t('create.publish') + } + } + } catch (error) { + console.error('Payment failed:', error) + this.showError(t('payment.failed')) + this.submitting = false + if (submitBtn) { + submitBtn.disabled = false + submitBtn.textContent = t('create.publish') + } + } + } + + async onPaymentSuccess(listingId) { + const days = 30 + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + days) + + await directus.updateListing(listingId, { + status: 'published', + payment_status: 'paid', + paid_at: new Date().toISOString(), + expires_at: expiresAt.toISOString() + }) + + clearPendingInvoice(listingId) + router.navigate(`/listing/${listingId}`) + } + showError(message) { let errorDiv = this.querySelector('.form-error') if (!errorDiv) { diff --git a/js/services/btcpay.js b/js/services/btcpay.js new file mode 100644 index 0000000..61fb3aa --- /dev/null +++ b/js/services/btcpay.js @@ -0,0 +1,170 @@ +const POW_SERVER = 'https://pow.dgray.io' + +let modalScriptLoaded = false +let modalScriptLoading = null + +/** + * Load BTCPay modal checkout script once + */ +async function ensureModalLoaded() { + if (modalScriptLoaded) return + if (modalScriptLoading) return modalScriptLoading + + modalScriptLoading = new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = 'https://pay.xmr.rocks/modal/btcpay.js' + script.onload = () => { + modalScriptLoaded = true + resolve() + } + script.onerror = () => reject(new Error('Failed to load BTCPay checkout')) + document.head.appendChild(script) + }) + + return modalScriptLoading +} + +/** + * Create a payment invoice for a listing + * @param {string} listingId - The listing UUID + * @param {string} [currency='EUR'] - Payment currency + * @returns {Promise} { invoiceId, checkoutLink, status, expirationTime } + */ +export async function createInvoice(listingId, currency = 'EUR') { + const response = await fetch(`${POW_SERVER}/btcpay/invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ listingId, currency }) + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to create invoice') + } + + return response.json() +} + +/** + * Check invoice payment status + * @param {string} invoiceId + * @returns {Promise} { invoiceId, status, additionalStatus } + */ +export async function getInvoiceStatus(invoiceId) { + const response = await fetch(`${POW_SERVER}/btcpay/status?id=${encodeURIComponent(invoiceId)}`) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to check invoice status') + } + + return response.json() +} + +/** + * Open the BTCPay modal checkout + * @param {string} invoiceId + * @returns {Promise} Final status when modal closes + */ +export async function openCheckout(invoiceId) { + await ensureModalLoaded() + + return new Promise((resolve) => { + let lastStatus = null + + window.btcpay.onModalReceiveMessage((msg) => { + if (msg?.status) lastStatus = msg.status + }) + + window.btcpay.onModalWillLeave(() => { + resolve(lastStatus || 'unknown') + }) + + window.btcpay.showInvoice(invoiceId) + }) +} + +/** + * Poll invoice status until settled, expired, or timeout + * @param {string} invoiceId + * @param {Object} [options] + * @param {number} [options.interval=4000] - Poll interval in ms + * @param {number} [options.timeout=900000] - Timeout in ms (default 15min) + * @param {Function} [options.onUpdate] - Callback on status change + * @returns {Promise} Final status object + */ +export async function pollUntilDone(invoiceId, options = {}) { + const { + interval = 4000, + timeout = 900000, + onUpdate = null + } = options + + const startTime = Date.now() + let lastStatus = null + + while (Date.now() - startTime < timeout) { + const result = await getInvoiceStatus(invoiceId) + + if (result.status !== lastStatus) { + lastStatus = result.status + if (onUpdate) onUpdate(result) + } + + if (['Settled', 'Expired', 'Invalid'].includes(result.status)) { + return result + } + + await new Promise(r => setTimeout(r, interval)) + } + + throw new Error('Payment check timed out') +} + +/** + * Check if a listing has a pending invoice stored locally + * @param {string} listingId + * @returns {Object|null} { invoiceId, createdAt } + */ +export function getPendingInvoice(listingId) { + try { + const data = localStorage.getItem(`dgray_invoice_${listingId}`) + return data ? JSON.parse(data) : null + } catch (e) { + return null + } +} + +/** + * Store a pending invoice reference locally + * @param {string} listingId + * @param {string} invoiceId + */ +export function savePendingInvoice(listingId, invoiceId) { + try { + localStorage.setItem(`dgray_invoice_${listingId}`, JSON.stringify({ + invoiceId, + createdAt: Date.now() + })) + } catch (e) { + // Storage unavailable + } +} + +/** + * Remove pending invoice reference + * @param {string} listingId + */ +export function clearPendingInvoice(listingId) { + localStorage.removeItem(`dgray_invoice_${listingId}`) +} + +export default { + createInvoice, + getInvoiceStatus, + openCheckout, + pollUntilDone, + getPendingInvoice, + savePendingInvoice, + clearPendingInvoice +} diff --git a/locales/de.json b/locales/de.json index 15b397e..9c868fd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -308,5 +308,20 @@ "about": "Über", "currency": "Währung", "currencyChanged": "Währung geändert" + }, + "payment": { + "title": "Zahlung", + "listingFee": "Anzeigengebühr", + "feeInfo": "1 Anzeige = 1 Monat = {{amount}} {{currency}}", + "payNow": "Jetzt bezahlen", + "paying": "Zahlung wird verarbeitet...", + "processing": "Zahlung eingegangen, warte auf Bestätigung...", + "success": "Zahlung erfolgreich! Deine Anzeige ist jetzt online.", + "expired": "Zahlung abgelaufen. Bitte versuche es erneut.", + "failed": "Zahlung fehlgeschlagen. Bitte versuche es erneut.", + "resume": "Zahlung fortsetzen", + "pending": "Zahlung ausstehend", + "required": "Zum Veröffentlichen ist eine Gebühr von {{amount}} {{currency}} erforderlich.", + "paidViaXmr": "Bezahlt via Monero (XMR)" } } diff --git a/locales/en.json b/locales/en.json index c71cbae..bc972af 100644 --- a/locales/en.json +++ b/locales/en.json @@ -308,5 +308,20 @@ "about": "About", "currency": "Currency", "currencyChanged": "Currency changed" + }, + "payment": { + "title": "Payment", + "listingFee": "Listing Fee", + "feeInfo": "1 listing = 1 month = {{amount}} {{currency}}", + "payNow": "Pay Now", + "paying": "Processing payment...", + "processing": "Payment received, waiting for confirmation...", + "success": "Payment successful! Your listing is now live.", + "expired": "Payment expired. Please try again.", + "failed": "Payment failed. Please try again.", + "resume": "Resume payment", + "pending": "Payment pending", + "required": "A fee of {{amount}} {{currency}} is required to publish.", + "paidViaXmr": "Paid via Monero (XMR)" } } diff --git a/locales/fr.json b/locales/fr.json index bd2b12b..0ae2c0a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -308,5 +308,20 @@ "about": "À propos", "currency": "Devise", "currencyChanged": "Devise modifiée" + }, + "payment": { + "title": "Paiement", + "listingFee": "Frais d'annonce", + "feeInfo": "1 annonce = 1 mois = {{amount}} {{currency}}", + "payNow": "Payer maintenant", + "paying": "Traitement du paiement...", + "processing": "Paiement reçu, en attente de confirmation...", + "success": "Paiement réussi ! Votre annonce est maintenant en ligne.", + "expired": "Paiement expiré. Veuillez réessayer.", + "failed": "Paiement échoué. Veuillez réessayer.", + "resume": "Reprendre le paiement", + "pending": "Paiement en attente", + "required": "Des frais de {{amount}} {{currency}} sont requis pour publier.", + "paidViaXmr": "Payé via Monero (XMR)" } } diff --git a/service-worker.js b/service-worker.js index 95a1d6b..c8e2c1b 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'dgray-v41'; +const CACHE_NAME = 'dgray-v42'; const STATIC_ASSETS = [ '/', '/index.html', @@ -29,6 +29,7 @@ const STATIC_ASSETS = [ '/js/services/crypto.js', '/js/services/currency.js', '/js/services/pow-captcha.js', + '/js/services/btcpay.js', // Components '/js/components/app-shell.js',