add pow-captcha

This commit is contained in:
2026-02-03 14:44:36 +01:00
parent cb2576f847
commit 3669321139
7 changed files with 300 additions and 15 deletions

View File

@@ -2,7 +2,7 @@
## Preismodell
**1 Anzeige = 1 Monat = 1 EUR/CHF/USD**
**1 Anzeige = 1 Monat = 1 EUR/USD/CHF/GBP (oder 200 JPY)**
- Einfach, transparent, fair
- Power-User (mit Reputation): 2 Monate Laufzeit
@@ -14,13 +14,17 @@
- Anzeigen von Accounts mit erfolgreichen Verkäufen werden höher gerankt
- Neue Accounts erscheinen weiter unten in Suchergebnissen
### Trust-Badge
- "Verifizierter Verkäufer" nach X abgeschlossenen Deals
- Käufer bestätigt erfolgreichen Abschluss
### Reputation-Stufen
### Power-User Vorteile
- 2 Monate Laufzeit statt 1 Monat (bei gleichem Preis)
- Kriterien: z.B. 5+ erfolgreiche Verkäufe
| Stufe | Deals | Vorteile |
|-------|-------|----------|
| Neu | 0 | Standard (1 Monat Laufzeit) |
| Verifizierter Verkäufer | 5+ | Badge, bessere Sichtbarkeit |
| Power-User | 15+ | 2 Monate Laufzeit, höheres Ranking |
| Top Seller | 50+ | Badge, Priority-Ranking, maximale Sichtbarkeit |
- Käufer bestätigt erfolgreichen Abschluss
- Deals werden erst nach Käufer-Bestätigung gezählt
### Proof of Wallet (optional)
- Einmalige Monero-Mikrozahlung als Verifikation
@@ -33,20 +37,31 @@
- Max. 3 neue Anzeigen pro Tag für neue Accounts
- Limit erhöht sich mit Reputation
### Captcha
### Captcha (Eigenes PoW)
- Proof-of-Work basiert, kein Tracking
- Keine externe Abhängigkeit, keine Lizenzkosten
- Client löst SHA256-Challenge (Difficulty 4, ~1-3 Sek)
- Bei Account-Erstellung
- Bei verdächtigen Aktivitäten
- Bei Anzeigen-Erstellung
- Implementierung: `js/services/pow-captcha.js`, `js/components/pow-captcha.js`
## Payment-Integration
- **Provider**: BTCpay Server (self-hosted)
- **Währung**: Monero (XMR)
- **Preisumrechnung**: Live XMR/EUR-Kurs zum Zeitpunkt der Zahlung
- **URL**: https://pay.xmr.rocks/
- **Primär**: Monero (XMR)
- **Alternativ**: Andere Kryptos via Trocador-Plugin (automatischer Swap zu XMR)
- **Preisumrechnung**: Live XMR-Kurs via Kraken API
- EUR: `https://api.kraken.com/0/public/Ticker?pair=XMREUR`
- USD: `https://api.kraken.com/0/public/Ticker?pair=XMRUSD`
- CHF: `https://api.kraken.com/0/public/Ticker?pair=XMRCHF`
- GBP: `https://api.kraken.com/0/public/Ticker?pair=XMRGBP`
- JPY: `https://api.kraken.com/0/public/Ticker?pair=XMRJPY`
- **Bestätigung**: Nach 1-2 Blockchain-Confirmations
## Offene Fragen
- [ ] BTCpay Server Setup & Hosting
- [ ] XMR-Kurs API für Umrechnung
- [ ] Anzahl Deals für Power-User Status
- [ ] Captcha-Lösung (privacy-freundlich, z.B. hCaptcha)
- [x] ~~BTCpay Server Setup & Hosting~~ → https://pay.xmr.rocks/
- [x] ~~XMR-Kurs API für Umrechnung~~ → Kraken API
- [x] ~~Anzahl Deals für Power-User Status~~ → 5/15/50 Stufen
- [x] ~~Captcha-Lösung~~ → Eigenes PoW-Captcha (keine Lizenzkosten)

View File

@@ -4,6 +4,7 @@ import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js'
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
import '../location-picker.js'
import '../pow-captcha.js'
const STORAGE_KEY = 'dgray_create_draft'
@@ -247,6 +248,10 @@ class PageCreate extends HTMLElement {
>
<p class="field-hint">${t('create.moneroHint')}</p>
</div>
<div class="form-group">
<pow-captcha id="pow-captcha"></pow-captcha>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
@@ -380,6 +385,13 @@ class PageCreate extends HTMLElement {
e.preventDefault()
if (this.submitting) return
// Validate PoW Captcha
const captcha = this.querySelector('#pow-captcha')
if (!captcha?.isSolved()) {
this.showError(t('captcha.error'))
return
}
// Validate Monero address
if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) {

View File

@@ -0,0 +1,156 @@
// PoW Captcha Web Component
import { generateChallenge, solveChallenge } from '../services/pow-captcha.js'
import { t, i18n } from '../i18n.js'
export class PowCaptcha extends HTMLElement {
constructor() {
super()
this.solved = false
this.solution = null
this.unsubscribe = null
}
connectedCallback() {
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
}
render() {
this.innerHTML = `
<div class="pow-captcha">
<div class="pow-captcha-status">
${this.solved
? `<span class="pow-captcha-success">✓ ${t('captcha.verified')}</span>`
: `<button class="pow-captcha-btn" type="button">${t('captcha.verify')}</button>`
}
</div>
<div class="pow-captcha-progress" style="display: none;">
<div class="pow-captcha-progress-bar"></div>
<span class="pow-captcha-progress-text"></span>
</div>
</div>
`
if (!this.solved) {
this.querySelector('.pow-captcha-btn').addEventListener('click', () => this.solve())
}
}
async solve() {
const btn = this.querySelector('.pow-captcha-btn')
const progress = this.querySelector('.pow-captcha-progress')
const progressBar = this.querySelector('.pow-captcha-progress-bar')
const progressText = this.querySelector('.pow-captcha-progress-text')
btn.disabled = true
btn.textContent = t('captcha.solving')
progress.style.display = 'flex'
try {
// Generate challenge (in production, fetch from server)
const { challenge, difficulty, timestamp, signature } = generateChallenge()
// Solve with progress updates
const result = await solveChallenge(challenge, difficulty, ({ attempts, elapsed }) => {
const estimatedTotal = Math.pow(16, difficulty) / 2
const percent = Math.min((attempts / estimatedTotal) * 100, 95)
progressBar.style.width = `${percent}%`
progressText.textContent = `${attempts.toLocaleString()} ${t('captcha.attempts')}`
})
// Store solution for form submission
this.solution = {
challenge,
nonce: result.nonce,
signature,
timestamp
}
this.solved = true
this.dispatchEvent(new CustomEvent('solved', { detail: this.solution }))
this.render()
} catch (error) {
btn.disabled = false
btn.textContent = t('captcha.error')
console.error('PoW Captcha error:', error)
}
}
// Get solution for form submission
getSolution() {
return this.solution
}
// Check if solved
isSolved() {
return this.solved
}
// Reset captcha
reset() {
this.solved = false
this.solution = null
this.render()
}
}
customElements.define('pow-captcha', PowCaptcha)
// Add styles
const style = document.createElement('style')
style.textContent = `
.pow-captcha {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.pow-captcha-btn {
padding: var(--space-sm) var(--space-md);
background: var(--color-primary);
color: var(--color-bg);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
}
.pow-captcha-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.pow-captcha-success {
color: var(--color-success, #22c55e);
font-weight: 500;
}
.pow-captcha-progress {
margin-top: var(--space-sm);
align-items: center;
gap: var(--space-sm);
}
.pow-captcha-progress-bar {
height: 4px;
background: var(--color-primary);
border-radius: 2px;
width: 0%;
transition: width 0.1s;
flex: 1;
}
.pow-captcha-progress-text {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
min-width: 100px;
text-align: right;
}
`
document.head.appendChild(style)

View File

@@ -0,0 +1,81 @@
// Proof-of-Work Captcha Service
// Client must find nonce where SHA256(challenge + nonce) has N leading zeros
const DIFFICULTY = 4 // Number of leading zeros required (4 = ~65k attempts avg)
const CHALLENGE_EXPIRY = 5 * 60 * 1000 // 5 minutes
// Generate a challenge (call this from your API/backend)
export function generateChallenge() {
const challenge = crypto.randomUUID()
const timestamp = Date.now()
return {
challenge,
difficulty: DIFFICULTY,
timestamp,
// Sign to prevent tampering (simple HMAC alternative)
signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`)
}
}
// Verify solution (call this from your API/backend)
export async function verifySolution(challenge, nonce, signature, timestamp) {
// Check expiry
if (Date.now() - timestamp > CHALLENGE_EXPIRY) {
return { valid: false, error: 'Challenge expired' }
}
// Verify signature
const expectedSig = btoa(`${challenge}:${timestamp}:${DIFFICULTY}`)
if (signature !== expectedSig) {
return { valid: false, error: 'Invalid signature' }
}
// Verify PoW
const hash = await sha256(`${challenge}${nonce}`)
const prefix = '0'.repeat(DIFFICULTY)
if (hash.startsWith(prefix)) {
return { valid: true }
}
return { valid: false, error: 'Invalid proof of work' }
}
// Solve challenge (runs in browser)
export async function solveChallenge(challenge, difficulty, onProgress) {
let nonce = 0
const prefix = '0'.repeat(difficulty)
const startTime = Date.now()
while (true) {
const hash = await sha256(`${challenge}${nonce}`)
if (hash.startsWith(prefix)) {
return {
nonce,
hash,
duration: Date.now() - startTime
}
}
nonce++
// Report progress every 1000 attempts
if (onProgress && nonce % 1000 === 0) {
onProgress({ attempts: nonce, elapsed: Date.now() - startTime })
}
// Yield to main thread every 100 attempts
if (nonce % 100 === 0) {
await new Promise(r => setTimeout(r, 0))
}
}
}
// SHA256 helper
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

View File

@@ -173,6 +173,13 @@
"message": "Die gesuchte Seite existiert leider nicht.",
"backHome": "Zur Startseite"
},
"captcha": {
"verify": "Ich bin kein Roboter",
"verified": "Verifiziert",
"solving": "Verifiziere...",
"attempts": "Versuche",
"error": "Fehler - erneut versuchen"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",

View File

@@ -173,6 +173,13 @@
"message": "The page you are looking for does not exist.",
"backHome": "Back to Home"
},
"captcha": {
"verify": "I'm not a robot",
"verified": "Verified",
"solving": "Verifying...",
"attempts": "attempts",
"error": "Error - try again"
},
"auth": {
"login": "Login",
"logout": "Logout",

View File

@@ -173,6 +173,13 @@
"message": "La page que vous recherchez n'existe pas.",
"backHome": "Retour à l'accueil"
},
"captcha": {
"verify": "Je ne suis pas un robot",
"verified": "Vérifié",
"solving": "Vérification...",
"attempts": "tentatives",
"error": "Erreur - réessayer"
},
"auth": {
"login": "Connexion",
"logout": "Déconnexion",