From 53673b4650702232b0ed52a0592f52526a0d6869 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Wed, 11 Feb 2026 08:14:44 +0100 Subject: [PATCH] feat: add verifiable listings (proof of possession) with verification widget, badge on cards/detail, i18n (7 langs), fix edit prefill for location/monero, prevent edit/delete on pending listings --- AGENTS.md | 4 +- README.md | 28 +- docs/DIRECTUS-SCHEMA.md | 22 +- docs/KILLER-FEATURES.md | 2 +- js/components/listing-card.js | 31 ++- js/components/pages/page-create.js | 32 ++- js/components/pages/page-favorites.js | 1 + js/components/pages/page-home.js | 1 + js/components/pages/page-listing.js | 139 ++++++++- js/components/pages/page-my-listings.js | 7 +- js/components/verification-widget.js | 356 ++++++++++++++++++++++++ js/services/directus/listings.js | 11 +- js/services/verification.js | 49 ++++ locales/de.json | 15 + locales/en.json | 15 + locales/es.json | 15 + locales/fr.json | 15 + locales/it.json | 15 + locales/pt.json | 15 + locales/ru.json | 15 + 20 files changed, 754 insertions(+), 34 deletions(-) create mode 100644 js/components/verification-widget.js create mode 100644 js/services/verification.js diff --git a/AGENTS.md b/AGENTS.md index 2399905..22ae164 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,7 +179,7 @@ locales/ 14. Tor Hidden Service (.onion URL) 15. Öffentliche Statistiken (Anzeigen, User, Deals — erst ab Schwellwert anzeigen) 16. ~~Pseudonyme & Avatare~~ ✅ `identity.js` — deterministisch aus User-ID, Identicon + Name (Adjektiv+Tier+Zahl) -17. Verifiable Listings (siehe `docs/KILLER-FEATURES.md`) +17. ~~Verifiable Listings~~ ✅ `verification.js` Service, `verification-widget.js` Component, Badge auf Cards + Listing-Detail 18. Selbstzerstörende Listings (siehe `docs/KILLER-FEATURES.md`) 19. Blind Meeting Points (siehe `docs/KILLER-FEATURES.md`) @@ -195,7 +195,7 @@ locales/ | Collection | Read | Create | Update | Hinweise | |------------|------|--------|--------|----------| -| `listings` | ✓ | ✓ | ✓ | Nur `status=published` lesen, Update nur `views` (Public) | +| `listings` | ✓ | ✓ | ✓ | Nur `status=published` lesen, Update: `views` (Public), `verified`/`verification_*` (User) | | `listings_files` | ✓ | ✓ | - | Junction-Table für Bilder | | `directus_files` | ✓ | ✓ | - | Für Assets/Bilder | | `categories` | ✓ | - | - | Nur `status=published` | diff --git a/README.md b/README.md index 158a28b..2287479 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,13 @@ kashilo.com ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstle | Anonyme Nutzung (UUID + Hash) | Mittel | ✅ Fertig | | PWA | Mittel | ✅ Grundgerüst | | Light/Dark Mode | Niedrig | ✅ Fertig | -| i18n (DE/EN/FR) | Niedrig | ✅ Fertig | +| i18n (7 Sprachen) | Niedrig | ✅ Fertig | | Bildergalerie | Niedrig | ✅ Fertig | | E2E-Chat (NaCl box.before + secretbox) | Hoch | ✅ Fertig | | PoW Captcha (Server-seitig) | Mittel | ✅ Fertig | -| Rating-System | Mittel | 🔲 Offen | +| Rating-System | Mittel | ✅ Fertig | +| Verifiable Listings | Mittel | ✅ Fertig | +| Reputation-System | Mittel | ✅ Fertig | | 2FA | Mittel | 🔲 Offen | ### ⚠️ Kritische Punkte @@ -177,7 +179,10 @@ kashilo/ │ │ ├── pow-captcha.js # PoW Captcha (Server-first, lokaler Fallback) │ │ ├── btcpay.js # BTCPay Server Integration (Invoice, Checkout) │ │ ├── favorites.js # Favoriten (localStorage + Directus Sync) -│ │ └── notifications.js# Benachrichtigungen (Polling, Badge) +│ │ ├── notifications.js# Benachrichtigungen (Polling, Badge) +│ │ ├── reputation.js # Reputation (Deals, Ratings, Level) +│ │ ├── verification.js # Verifiable Listings (Proof of Possession) +│ │ └── identity.js # Pseudonyme & Identicon-Avatare │ ├── vendor/ │ │ ├── nacl-fast.min.js # TweetNaCl (self-hosted) │ │ ├── nacl-util.min.js # TweetNaCl Utils @@ -192,7 +197,11 @@ kashilo/ ├── locales/ │ ├── de.json # Deutsch │ ├── en.json # English -│ └── fr.json # Français +│ ├── fr.json # Français +│ ├── it.json # Italiano +│ ├── es.json # Español +│ ├── pt.json # Português (BR) +│ └── ru.json # Русский ├── tests/ │ ├── index.html # Test-Runner UI │ ├── test-runner.js # Test-Framework @@ -252,7 +261,10 @@ kashilo/ - [x] Expired Listings (Directus Flow, Status-Badges auf Cards) - [x] Token-Refresh bei Tab-Visibility-Change ### Phase 5: Trust & Safety -- [ ] Rating-System +- [x] Rating-System +- [x] Reputation-System (Deals, Levels, Badges) +- [x] Verifiable Listings (Proof of Possession) +- [x] Pseudonyme & Identicon-Avatare - [ ] 2FA - [ ] Reporting/Moderation - [x] AGB, Datenschutz, Impressum (Entwürfe in 7 Sprachen) @@ -267,9 +279,9 @@ kashilo/ - Self-hosted Fonts (SIL Open Font License) ### Farbpalette -- **Monochrome Theme** - reine Graustufen -- **Light Mode**: BG #FAFAFA, Text #1A1A1A, Primary #555555 -- **Dark Mode**: BG #141414, Text #F0F0F0, Primary #AAAAAA +- **Warm Teal Theme** +- **Light Mode**: BG #FAFAF9, Text #1C1917, Accent #0D9488 (Teal) +- **Dark Mode**: BG #171717, Text #F5F5F4, Accent #2DD4BF (Teal light) ### Mobile-First - Responsive Grid (2 Spalten Mobile, 5 Spalten Desktop) diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md index 6cb6145..1f12034 100644 --- a/docs/DIRECTUS-SCHEMA.md +++ b/docs/DIRECTUS-SCHEMA.md @@ -44,6 +44,10 @@ Haupttabelle für alle Anzeigen. | `expires_at` | datetime | Ablaufdatum | | `monero_address` | string | XMR-Adresse für Zahlung | | `contact_public_key` | text | NaCl Public Key für E2E-Chat (pro Listing) | +| `verified` | boolean | `false` default — Verifikation abgeschlossen | +| `verification_code` | string(6) | 6-stelliger Klartext-Code (für Käufer-Vergleich mit Foto) | +| `verification_image` | UUID | FK → directus_files (Verifikationsfoto) | +| `verification_date` | datetime | Zeitpunkt der Verifikation | | `date_created` | datetime | Erstellungsdatum | | `date_updated` | datetime | Änderungsdatum | | `user_created` | UUID | Ersteller (FK → directus_users) | @@ -220,7 +224,7 @@ Meldungen von Anzeigen. | `favorites` | ✓ | ✓ | - | ✓ | Nur eigene | | `reports` | - | ✓ | - | - | Nur erstellen | -### Listings Update-Berechtigungen (Detail) +### Listings Update-Berechtigungen — Public Role (Detail) **Custom Filter:** ```json @@ -236,12 +240,28 @@ Meldungen von Anzeigen. - `contact_public_key` - `images` - `views` (geschützt durch Flow) +- `verified`, `verification_code`, `verification_image`, `verification_date` **Read Filter:** ```json { "status": { "_eq": "published" } } ``` +**Hinweis:** Die Felder `paid_at`, `payment_status` und `btcpay_invoice_id` sind in der Public-Rolle **nicht** lesbar. + +### User Role: Listings (zusätzliche Felder) + +Eingeloggte User haben zusätzlich zu den Public-Feldern Zugriff auf: + +**Read (zusätzlich):** +- `paid_at`, `payment_status`, `btcpay_invoice_id` + +**Update:** +- Gleiche Felder wie Public Update +- Filter: `user_created = $CURRENT_USER` + +**Hinweis:** `paid_at` und `payment_status` dürfen NICHT in der Public-Rolle lesbar sein, da sonst `getListing()` für nicht-eingeloggte Besucher fehlschlägt. + --- ## Directus Flows diff --git a/docs/KILLER-FEATURES.md b/docs/KILLER-FEATURES.md index 8fbde12..5ba9fe9 100644 --- a/docs/KILLER-FEATURES.md +++ b/docs/KILLER-FEATURES.md @@ -3,7 +3,7 @@ Differenzierung gegenüber eBay Kleinanzeigen, Tutti, XMRBazaar. Drei Features, die kein Konkurrent hat. -Status: **Planung** (noch nicht implementiert) +Status: **Verifiable Listings** ✅ implementiert, Rest in Planung --- diff --git a/js/components/listing-card.js b/js/components/listing-card.js index 5149554..b908bb5 100644 --- a/js/components/listing-card.js +++ b/js/components/listing-card.js @@ -6,7 +6,7 @@ import { favoritesService } from '../services/favorites.js' class ListingCard extends HTMLElement { static get observedAttributes() { - return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority'] + return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority', 'verified'] } constructor() { @@ -103,8 +103,9 @@ class ListingCard extends HTMLElement { const paymentStatus = this.getAttribute('payment-status') const status = this.getAttribute('status') const isDeleted = status === 'deleted' + const isPending = status === 'draft' && paymentStatus !== 'paid' - const ownerBadge = (this.isOwner && !isDeleted) ? /* html */` + const ownerBadge = (this.isOwner && !isDeleted && !isPending) ? /* html */` @@ -141,13 +142,16 @@ class ListingCard extends HTMLElement { ${paymentBadge}
-

${escapeHTML(title)}

-
-

${priceDisplay}

- ${secondaryPrice ? `

${secondaryPrice}

` : ''} -
+

${escapeHTML(title)}

+
+

${priceDisplay}

+ ${secondaryPrice ? `

${secondaryPrice}

` : ''} +
+
+ ${this.getAttribute('verified') === 'true' ? `${t('verification.badge')}` : ''}

${escapeHTML(location)}

+
${!isDeleted ? /* html */`
+ + ` + } + + renderActive() { + const remaining = this.getRemainingTime() + const isUploading = this.state === 'uploading' + return ` +
+
${this.currentCode.code}
+
${this.formatTime(remaining)}
+

${t('verification.instructions')}

+ +
+ ` + } + + renderExpired() { + return ` +
+

${t('verification.expired')}

+ +
+ ` + } + + renderVerified() { + const verifiedDate = this.getAttribute('verified-date') + const dateStr = verifiedDate + ? t('verification.verifiedDate', { date: new Date(verifiedDate).toLocaleDateString() }) + : '' + return ` +
+
+ + + + ${t('verification.verified')} +
+ ${dateStr ? `${dateStr}` : ''} +
+ ` + } + + setupEventListeners() { + const toggleBtn = this.querySelector('.verification-toggle') + if (toggleBtn) { + toggleBtn.addEventListener('click', () => this.startVerification()) + } + + const regenerateBtn = this.querySelector('.verification-regenerate') + if (regenerateBtn) { + regenerateBtn.addEventListener('click', () => this.startVerification()) + } + + const fileInput = this.querySelector('input[type="file"]') + if (fileInput) { + fileInput.addEventListener('change', (e) => this.handleUpload(e)) + } + } +} + +customElements.define('verification-widget', VerificationWidget) + +const style = document.createElement('style') +style.textContent = ` + .verification-widget { + padding: var(--space-md); + border-radius: var(--radius-md); + } + + .verification-toggle { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + width: 100%; + padding: var(--space-md) var(--space-lg); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-fast); + } + + .verification-toggle:hover { + border-color: var(--color-primary); + } + + .verification-toggle-label { + font-size: var(--font-size-base); + font-weight: 500; + color: var(--color-text); + } + + .verification-toggle-subtitle { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + .verification-widget--active { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + } + + .verification-code { + font-family: monospace; + font-size: var(--font-size-3xl); + font-weight: 700; + letter-spacing: 0.5em; + color: var(--color-text); + text-align: center; + padding: var(--space-md) var(--space-lg); + background: var(--color-bg); + border-radius: var(--radius-md); + user-select: all; + } + + .verification-timer { + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + } + + .verification-timer--warning { + color: var(--color-warning); + font-weight: 600; + } + + .verification-instructions { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + text-align: center; + margin: 0; + } + + .verification-upload { + position: relative; + cursor: pointer; + } + + .verification-upload--loading { + pointer-events: none; + opacity: 0.7; + } + + .verification-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: var(--radius-full); + animation: verification-spin 0.8s linear infinite; + } + + @keyframes verification-spin { + to { transform: rotate(360deg); } + } + + .verification-widget--expired { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + } + + .verification-expired-text { + font-size: var(--font-size-sm); + color: var(--color-error); + margin: 0; + } + + .verification-widget--verified { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + } + + .verification-success { + display: flex; + align-items: center; + gap: var(--space-sm); + color: var(--color-success); + font-weight: 600; + font-size: var(--font-size-base); + } + + .verification-success-icon { + width: 20px; + height: 20px; + } + + .verification-date { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +` +document.head.appendChild(style) + +export { VerificationWidget } diff --git a/js/services/directus/listings.js b/js/services/directus/listings.js index 181ffb7..22e03ed 100644 --- a/js/services/directus/listings.js +++ b/js/services/directus/listings.js @@ -21,7 +21,8 @@ const DEFAULT_FIELDS = [ 'location.postal_code', 'location.country', 'location.latitude', - 'location.longitude' + 'location.longitude', + 'verified' ] const DETAIL_FIELDS = [ @@ -49,7 +50,13 @@ const DETAIL_FIELDS = [ 'location.name', 'location.postal_code', 'location.country', - 'contact_public_key' + 'monero_address', + 'contact_public_key', + 'views', + 'verified', + 'verification_code', + 'verification_date', + 'verification_image' ] export async function getListings(options = {}) { diff --git a/js/services/verification.js b/js/services/verification.js new file mode 100644 index 0000000..1d6fe0b --- /dev/null +++ b/js/services/verification.js @@ -0,0 +1,49 @@ +import { directus } from './directus.js' + +const CODE_VALIDITY_MS = 10 * 60 * 1000 + +class VerificationService { + + generateCode() { + const array = new Uint32Array(1) + crypto.getRandomValues(array) + const code = String(array[0] % 1000000).padStart(6, '0') + const generatedAt = new Date() + const expiresAt = new Date(generatedAt.getTime() + CODE_VALIDITY_MS) + return { code, generatedAt, expiresAt } + } + + isCodeValid(generatedAt) { + return Date.now() - new Date(generatedAt).getTime() < CODE_VALIDITY_MS + } + + getRemainingTime(generatedAt) { + const elapsed = Date.now() - new Date(generatedAt).getTime() + const remaining = Math.max(0, CODE_VALIDITY_MS - elapsed) + return Math.ceil(remaining / 1000) + } + + async verify(listingId, code, imageFile) { + try { + const uploaded = await directus.uploadFile(imageFile) + + await directus.patch('/items/listings/' + listingId, { + verification_code: code, + verification_image: uploaded.id, + verification_date: new Date().toISOString(), + verified: true + }) + + return true + } catch (e) { + console.error('Verification failed:', e) + return false + } + } + + isVerified(listing) { + return listing.verified === true + } +} + +export const verificationService = new VerificationService() diff --git a/locales/de.json b/locales/de.json index fcb1d59..190e784 100644 --- a/locales/de.json +++ b/locales/de.json @@ -345,5 +345,20 @@ "rate": "Bewertung abgeben", "rated": "Bewertet", "memberSince": "Mitglied seit {{date}}" + }, + "verification": { + "verify": "Besitz verifizieren", + "optional": "Optional — erhöht das Vertrauen", + "code": "Dein Code", + "validFor": "Gültig noch", + "instructions": "Schreibe diesen Code auf einen Zettel und fotografiere deinen Artikel zusammen mit dem Code.", + "upload": "Verifikationsfoto hochladen", + "verified": "Besitz verifiziert", + "verifiedDate": "Verifiziert am {{date}}", + "expired": "Code abgelaufen — neuen generieren", + "badge": "✓ Verifiziert", + "proofHint": "Der Verkäufer hat diesen Artikel mit einem von kashilo generierten Code fotografiert. Vergleiche den Code im Foto mit dem angezeigten Code.", + "proofCode": "Verifizierungscode", + "proofPhoto": "Verifikationsfoto" } } diff --git a/locales/en.json b/locales/en.json index a4c13d1..250f3ac 100644 --- a/locales/en.json +++ b/locales/en.json @@ -345,5 +345,20 @@ "rate": "Leave a rating", "rated": "Rated", "memberSince": "Member since {{date}}" + }, + "verification": { + "verify": "Verify ownership", + "optional": "Optional — increases trust", + "code": "Your code", + "validFor": "Valid for", + "instructions": "Write this code on a piece of paper and photograph your item together with the code.", + "upload": "Upload verification photo", + "verified": "Ownership verified", + "verifiedDate": "Verified on {{date}}", + "expired": "Code expired — generate new one", + "badge": "✓ Verified", + "proofHint": "The seller photographed this item with a code generated by kashilo. Compare the code in the photo with the code shown here.", + "proofCode": "Verification code", + "proofPhoto": "Verification photo" } } diff --git a/locales/es.json b/locales/es.json index 188f4a2..2a6d933 100644 --- a/locales/es.json +++ b/locales/es.json @@ -345,5 +345,20 @@ "rate": "Dejar una valoración", "rated": "Valorado", "memberSince": "Miembro desde {{date}}" + }, + "verification": { + "verify": "Verificar posesión", + "optional": "Opcional — aumenta la confianza", + "code": "Tu código", + "validFor": "Válido aún", + "instructions": "Escribe este código en un papel y fotografía tu artículo junto con el código.", + "upload": "Subir foto de verificación", + "verified": "Posesión verificada", + "verifiedDate": "Verificado el {{date}}", + "expired": "Código expirado — generar uno nuevo", + "badge": "✓ Verificado", + "proofHint": "El vendedor fotografió este artículo con un código generado por kashilo. Compara el código en la foto con el código mostrado aquí.", + "proofCode": "Código de verificación", + "proofPhoto": "Foto de verificación" } } diff --git a/locales/fr.json b/locales/fr.json index 8da6e0f..627437b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -345,5 +345,20 @@ "rate": "Donner une évaluation", "rated": "Évalué", "memberSince": "Membre depuis {{date}}" + }, + "verification": { + "verify": "Vérifier la possession", + "optional": "Optionnel — augmente la confiance", + "code": "Votre code", + "validFor": "Valide encore", + "instructions": "Écrivez ce code sur un papier et photographiez votre article avec le code.", + "upload": "Télécharger la photo de vérification", + "verified": "Possession vérifiée", + "verifiedDate": "Vérifié le {{date}}", + "expired": "Code expiré — générer un nouveau", + "badge": "✓ Vérifié", + "proofHint": "Le vendeur a photographié cet article avec un code généré par kashilo. Comparez le code sur la photo avec le code affiché ici.", + "proofCode": "Code de vérification", + "proofPhoto": "Photo de vérification" } } diff --git a/locales/it.json b/locales/it.json index aa9d982..b0e7281 100644 --- a/locales/it.json +++ b/locales/it.json @@ -345,5 +345,20 @@ "rate": "Lascia una valutazione", "rated": "Valutato", "memberSince": "Membro dal {{date}}" + }, + "verification": { + "verify": "Verificare il possesso", + "optional": "Opzionale — aumenta la fiducia", + "code": "Il tuo codice", + "validFor": "Valido ancora", + "instructions": "Scrivi questo codice su un foglio e fotografa il tuo articolo insieme al codice.", + "upload": "Carica foto di verifica", + "verified": "Possesso verificato", + "verifiedDate": "Verificato il {{date}}", + "expired": "Codice scaduto — generarne uno nuovo", + "badge": "✓ Verificato", + "proofHint": "Il venditore ha fotografato questo articolo con un codice generato da kashilo. Confronta il codice nella foto con il codice mostrato qui.", + "proofCode": "Codice di verifica", + "proofPhoto": "Foto di verifica" } } diff --git a/locales/pt.json b/locales/pt.json index 69b1687..b35fd86 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -345,5 +345,20 @@ "rate": "Deixar uma avaliação", "rated": "Avaliado", "memberSince": "Membro desde {{date}}" + }, + "verification": { + "verify": "Verificar posse", + "optional": "Opcional — aumenta a confiança", + "code": "Seu código", + "validFor": "Válido ainda", + "instructions": "Escreva este código em um papel e fotografe seu artigo junto com o código.", + "upload": "Enviar foto de verificação", + "verified": "Posse verificada", + "verifiedDate": "Verificado em {{date}}", + "expired": "Código expirado — gerar novo", + "badge": "✓ Verificado", + "proofHint": "O vendedor fotografou este artigo com um código gerado pelo kashilo. Compare o código na foto com o código exibido aqui.", + "proofCode": "Código de verificação", + "proofPhoto": "Foto de verificação" } } diff --git a/locales/ru.json b/locales/ru.json index 4e09f84..1c9fa8f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -345,5 +345,20 @@ "rate": "Оставить оценку", "rated": "Оценено", "memberSince": "Участник с {{date}}" + }, + "verification": { + "verify": "Подтвердить владение", + "optional": "Необязательно — повышает доверие", + "code": "Ваш код", + "validFor": "Действителен ещё", + "instructions": "Напишите этот код на листке бумаги и сфотографируйте ваш товар вместе с кодом.", + "upload": "Загрузить фото подтверждения", + "verified": "Владение подтверждено", + "verifiedDate": "Подтверждено {{date}}", + "expired": "Код истёк — сгенерировать новый", + "badge": "✓ Подтверждено", + "proofHint": "Продавец сфотографировал этот товар с кодом, сгенерированным kashilo. Сравните код на фото с кодом, показанным здесь.", + "proofCode": "Код подтверждения", + "proofPhoto": "Фото подтверждения" } }