add pow-captcha
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
156
js/components/pow-captcha.js
Normal file
156
js/components/pow-captcha.js
Normal 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)
|
||||
81
js/services/pow-captcha.js
Normal file
81
js/services/pow-captcha.js
Normal 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('')
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user