From 227791e8f9133f46207640b583d043e2c042b8c9 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Wed, 11 Feb 2026 11:21:39 +0100 Subject: [PATCH] fix: namespace crypto storage per account, add chat polling, fix notification flow and dark theme issues --- css/variables.css | 3 + docs/pow-server/btcpay-webhook.php | 60 +++++++++----- index.html | 6 +- js/components/chat-widget.js | 22 +++++- js/components/listing-card.js | 4 +- js/components/pages/page-listing.js | 2 + js/services/conversations.js | 8 +- js/services/crypto.js | 109 ++++++++++++++++++++++---- js/services/directus/conversations.js | 9 ++- js/services/notifications.js | 6 +- js/services/pow-captcha.js | 10 ++- 11 files changed, 188 insertions(+), 51 deletions(-) diff --git a/css/variables.css b/css/variables.css index fb84687..0ce2ed6 100644 --- a/css/variables.css +++ b/css/variables.css @@ -17,6 +17,7 @@ --color-accent-text: #fff; --color-success: #16A34A; + --color-success-text: #fff; --color-warning: #D97706; --color-error: #DC2626; @@ -109,6 +110,7 @@ --color-accent-text: #042F2E; --color-success: #4ADE80; + --color-success-text: #042F2E; --color-warning: #FBBF24; --color-error: #F87171; @@ -142,6 +144,7 @@ --color-accent-text: #042F2E; --color-success: #4ADE80; + --color-success-text: #042F2E; --color-warning: #FBBF24; --color-error: #F87171; diff --git a/docs/pow-server/btcpay-webhook.php b/docs/pow-server/btcpay-webhook.php index c9c63a7..6160f35 100644 --- a/docs/pow-server/btcpay-webhook.php +++ b/docs/pow-server/btcpay-webhook.php @@ -43,8 +43,8 @@ if (!$type) { exit; } -// Only handle settled invoices -if ($type !== 'InvoiceSettled' && $type !== 'InvoicePaymentSettled') { +// Only handle fully settled invoices (not partial payment events) +if ($type !== 'InvoiceSettled') { echo json_encode(['ok' => true, 'action' => 'ignored', 'type' => $type]); exit; } @@ -125,29 +125,49 @@ curl_close($gch); $listingData = json_decode($getResponse, true); $userCreated = $listingData['data']['user_created'] ?? null; -// Create notification for listing owner +// Create notification for listing owner (skip if already exists) if ($userCreated) { - $notifPayload = json_encode([ - 'user_hash' => $userCreated, - 'type' => 'listing_published', - 'reference_id' => $listingId, - 'read' => false, + $checkUrl = DIRECTUS_URL . '/items/notifications?' . http_build_query([ + 'filter' => json_encode([ + 'user_hash' => ['_eq' => $userCreated], + 'type' => ['_eq' => 'listing_published'], + 'reference_id' => ['_eq' => $listingId], + ]), + 'limit' => 1, ]); - - $notifUrl = DIRECTUS_URL . '/items/notifications'; - $nch = curl_init($notifUrl); - curl_setopt_array($nch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $notifPayload, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . DIRECTUS_TOKEN, - ], + $ech = curl_init($checkUrl); + curl_setopt_array($ech, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . DIRECTUS_TOKEN], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, ]); - curl_exec($nch); - curl_close($nch); + $existingResp = curl_exec($ech); + curl_close($ech); + $existingData = json_decode($existingResp, true); + + if (empty($existingData['data'])) { + $notifPayload = json_encode([ + 'user_hash' => $userCreated, + 'type' => 'listing_published', + 'reference_id' => $listingId, + 'read' => false, + ]); + + $notifUrl = DIRECTUS_URL . '/items/notifications'; + $nch = curl_init($notifUrl); + curl_setopt_array($nch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $notifPayload, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . DIRECTUS_TOKEN, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + ]); + curl_exec($nch); + curl_close($nch); + } } echo json_encode(['ok' => true, 'listingId' => $listingId, 'action' => 'published']); diff --git a/index.html b/index.html index 1405cf7..7d3121a 100644 --- a/index.html +++ b/index.html @@ -63,7 +63,7 @@ --color-primary: #525252; --color-primary-hover: #3F3F3F; --color-primary-light: #E7E5E4; --color-secondary: #737373; --color-secondary-hover: #525252; --color-accent: #0D9488; --color-accent-hover: #0F766E; --color-accent-text: #fff; - --color-success: #16A34A; --color-warning: #D97706; --color-error: #DC2626; + --color-success: #16A34A; --color-success-text: #fff; --color-warning: #D97706; --color-error: #DC2626; --color-bg: #FAFAF9; --color-bg-secondary: #F5F5F4; --color-bg-tertiary: #E7E5E4; --color-text: #1C1917; --color-text-secondary: #44403C; --color-text-muted: #78716C; --color-border: #D6D3D1; --color-border-focus: #0D9488; @@ -85,7 +85,7 @@ --color-primary: #A8A29E; --color-primary-hover: #D6D3D1; --color-primary-light: #292524; --color-secondary: #A8A29E; --color-secondary-hover: #D6D3D1; --color-accent: #2DD4BF; --color-accent-hover: #5EEAD4; --color-accent-text: #042F2E; - --color-success: #4ADE80; --color-warning: #FBBF24; --color-error: #F87171; + --color-success: #4ADE80; --color-success-text: #042F2E; --color-warning: #FBBF24; --color-error: #F87171; --color-bg: #171717; --color-bg-secondary: #1C1917; --color-bg-tertiary: #292524; --color-text: #F5F5F4; --color-text-secondary: #D6D3D1; --color-text-muted: #A8A29E; --color-border: #3D3836; --color-border-focus: #2DD4BF; @@ -96,7 +96,7 @@ --color-primary: #A8A29E; --color-primary-hover: #D6D3D1; --color-primary-light: #292524; --color-secondary: #A8A29E; --color-secondary-hover: #D6D3D1; --color-accent: #2DD4BF; --color-accent-hover: #5EEAD4; --color-accent-text: #042F2E; - --color-success: #4ADE80; --color-warning: #FBBF24; --color-error: #F87171; + --color-success: #4ADE80; --color-success-text: #042F2E; --color-warning: #FBBF24; --color-error: #F87171; --color-bg: #171717; --color-bg-secondary: #1C1917; --color-bg-tertiary: #292524; --color-text: #F5F5F4; --color-text-secondary: #D6D3D1; --color-text-muted: #A8A29E; --color-border: #3D3836; --color-border-focus: #2DD4BF; diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js index dcf76cb..96ca0f5 100644 --- a/js/components/chat-widget.js +++ b/js/components/chat-widget.js @@ -40,6 +40,19 @@ class ChatWidget extends HTMLElement { disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() if (this.i18nUnsubscribe) this.i18nUnsubscribe() + this._stopPolling() + } + + _startPolling() { + this._stopPolling() + this._pollTimer = setInterval(() => this.refreshMessages(), 5000) + } + + _stopPolling() { + if (this._pollTimer) { + clearInterval(this._pollTimer) + this._pollTimer = null + } } async activate() { @@ -66,6 +79,8 @@ class ChatWidget extends HTMLElement { this.unsubscribe = conversationsService.subscribe(() => this.refreshMessages()) this.i18nUnsubscribe = i18n.subscribe(() => this.render()) + + if (this.conversation) this._startPolling() } async initConversation() { @@ -130,9 +145,12 @@ class ChatWidget extends HTMLElement { } async refreshMessages() { + const prevCount = this.messages.length await this.loadMessages() - this.renderMessages() - this.scrollToBottom() + if (this.messages.length !== prevCount) { + this.renderMessages() + this.scrollToBottom() + } } async loadDealState() { diff --git a/js/components/listing-card.js b/js/components/listing-card.js index b908bb5..f032f63 100644 --- a/js/components/listing-card.js +++ b/js/components/listing-card.js @@ -255,8 +255,8 @@ style.textContent = /* css */` } listing-card .payment-published { - background: var(--color-accent); - color: var(--color-accent-text, #fff); + background: var(--color-success); + color: var(--color-success-text, #fff); } listing-card .payment-expired { diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index 05b6938..8be2bfb 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -1299,11 +1299,13 @@ style.textContent = /* css */` font-size: var(--font-size-xs); word-break: break-all; line-height: 1.4; + color: var(--color-text); } page-listing .btn-copy { padding: var(--space-sm); flex-shrink: 0; + color: var(--color-text); } page-listing .btn-copy.copied { diff --git a/js/services/conversations.js b/js/services/conversations.js index 09510cd..ada891a 100644 --- a/js/services/conversations.js +++ b/js/services/conversations.js @@ -5,6 +5,7 @@ import { directus } from './directus.js' import { cryptoService } from './crypto.js' +import { auth } from './auth.js' class ConversationsService { constructor() { @@ -140,7 +141,8 @@ class ConversationsService { let text = '[Encrypted]' try { - text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey) + const decrypted = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey) + text = decrypted || '[Decryption failed]' } catch (e) { text = '[Decryption failed]' } @@ -203,12 +205,14 @@ class ConversationsService { return this.getConversation(existing.id) } + const user = await auth.getUser() const newConv = await directus.startConversation( listingId, myHash, sellerHash, myPublicKey, - sellerPublicKey + sellerPublicKey, + user?.id ) return this.getConversation(newConv.id) diff --git a/js/services/crypto.js b/js/services/crypto.js index 55863b4..853773b 100644 --- a/js/services/crypto.js +++ b/js/services/crypto.js @@ -4,11 +4,12 @@ * * Secret keys are encrypted at rest using AES-GCM with a key * derived from the user's UUID via PBKDF2. + * + * Storage keys are namespaced per account (UUID hash prefix) + * to support multiple accounts in the same browser. */ -const STORAGE_KEY = 'kashilo_keypair' -const SALT_KEY = 'kashilo_keypair_salt' -const LISTING_KEYS_STORAGE = 'kashilo_listing_keys' +const STORAGE_PREFIX = 'kashilo_' class CryptoService { constructor() { @@ -16,6 +17,7 @@ class CryptoService { this.naclUtil = null this.keyPair = null this.wrappingKey = null + this._storageKeys = null this.ready = this.init() } @@ -43,13 +45,33 @@ class CryptoService { }) } + async _uuidPrefix(uuid) { + const data = new TextEncoder().encode(uuid) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)).slice(0, 4) + .map(b => b.toString(16).padStart(2, '0')).join('') + } + + async _getStorageKeys(uuid) { + if (this._storageKeys) return this._storageKeys + const prefix = await this._uuidPrefix(uuid) + this._storageKeys = { + keypair: `${STORAGE_PREFIX}kp_${prefix}`, + salt: `${STORAGE_PREFIX}salt_${prefix}`, + listingKeys: `${STORAGE_PREFIX}lk_${prefix}` + } + return this._storageKeys + } + async deriveKey(uuid) { const enc = new TextEncoder() - let salt = localStorage.getItem(SALT_KEY) + const keys = await this._getStorageKeys(uuid) + + let salt = localStorage.getItem(keys.salt) if (!salt) { const saltBytes = crypto.getRandomValues(new Uint8Array(16)) salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('') - localStorage.setItem(SALT_KEY, salt) + localStorage.setItem(keys.salt, salt) } const saltBytes = new Uint8Array(salt.match(/.{2}/g).map(h => parseInt(h, 16))) @@ -92,10 +114,11 @@ class CryptoService { async unlock(uuid) { await this.ready + const keys = await this._getStorageKeys(uuid) const wrappingKey = await this.deriveKey(uuid) this.wrappingKey = wrappingKey - const stored = localStorage.getItem(STORAGE_KEY) + const stored = localStorage.getItem(keys.keypair) if (stored) { try { const parsed = JSON.parse(stored) @@ -119,10 +142,61 @@ class CryptoService { } } catch (e) { console.warn('Failed to load keypair, generating new one') + localStorage.removeItem(keys.listingKeys) } } - await this.generateKeyPair(wrappingKey) + const migrated = await this._migrateOldStorage(uuid, wrappingKey, keys) + if (!migrated) await this.generateKeyPair(wrappingKey) + } + + async _migrateOldStorage(uuid, wrappingKey, keys) { + const oldKeypair = localStorage.getItem('kashilo_keypair') + const oldSalt = localStorage.getItem('kashilo_keypair_salt') + if (!oldKeypair || !oldSalt) return false + + try { + const oldSaltBytes = new Uint8Array(oldSalt.match(/.{2}/g).map(h => parseInt(h, 16))) + const enc = new TextEncoder() + const baseKey = await crypto.subtle.importKey( + 'raw', enc.encode(uuid), 'PBKDF2', false, ['deriveKey'] + ) + const oldWrappingKey = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: oldSaltBytes, iterations: 100000, hash: 'SHA-256' }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) + + const parsed = JSON.parse(oldKeypair) + if (parsed.ct && parsed.iv) { + const data = await this.decryptFromStorage(parsed, oldWrappingKey) + this.keyPair = { + publicKey: this.naclUtil.decodeBase64(data.publicKey), + secretKey: this.naclUtil.decodeBase64(data.secretKey) + } + await this.saveKeyPair(wrappingKey) + + const oldListing = localStorage.getItem('kashilo_listing_keys') + if (oldListing) { + try { + const listingData = await this.decryptFromStorage(JSON.parse(oldListing), oldWrappingKey) + const encrypted = await this.encryptForStorage(listingData, wrappingKey) + localStorage.setItem(keys.listingKeys, JSON.stringify(encrypted)) + localStorage.removeItem('kashilo_listing_keys') + } catch {} + } + + localStorage.removeItem('kashilo_keypair') + localStorage.removeItem('kashilo_keypair_salt') + console.debug('[crypto] migrated old storage to namespaced keys') + return true + } + } catch (e) { + console.debug('[crypto] old storage migration failed (different account)') + } + return false } async generateKeyPair(wrappingKey) { @@ -136,20 +210,27 @@ class CryptoService { secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey) } const encrypted = await this.encryptForStorage(data, wrappingKey) - localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)) + localStorage.setItem(this._storageKeys.keypair, JSON.stringify(encrypted)) } lock() { this.keyPair = null this.wrappingKey = null + this._storageKeys = null } destroyKeyPair() { this.keyPair = null this.wrappingKey = null - localStorage.removeItem(STORAGE_KEY) - localStorage.removeItem(SALT_KEY) - localStorage.removeItem(LISTING_KEYS_STORAGE) + if (this._storageKeys) { + localStorage.removeItem(this._storageKeys.keypair) + localStorage.removeItem(this._storageKeys.salt) + localStorage.removeItem(this._storageKeys.listingKeys) + } + localStorage.removeItem('kashilo_keypair') + localStorage.removeItem('kashilo_keypair_salt') + localStorage.removeItem('kashilo_listing_keys') + this._storageKeys = null } getPublicKey() { @@ -158,8 +239,8 @@ class CryptoService { } async getListingKeysStore() { - if (!this.wrappingKey) return {} - const stored = localStorage.getItem(LISTING_KEYS_STORAGE) + if (!this.wrappingKey || !this._storageKeys) return {} + const stored = localStorage.getItem(this._storageKeys.listingKeys) if (!stored) return {} try { const parsed = JSON.parse(stored) @@ -173,7 +254,7 @@ class CryptoService { async saveListingKeysStore(store) { if (!this.wrappingKey) throw new Error('Not unlocked') const encrypted = await this.encryptForStorage(store, this.wrappingKey) - localStorage.setItem(LISTING_KEYS_STORAGE, JSON.stringify(encrypted)) + localStorage.setItem(this._storageKeys.listingKeys, JSON.stringify(encrypted)) } async generateListingKeyPair(listingId) { diff --git a/js/services/directus/conversations.js b/js/services/directus/conversations.js index 39b7443..3bb7519 100644 --- a/js/services/directus/conversations.js +++ b/js/services/directus/conversations.js @@ -54,16 +54,17 @@ export async function sendMessage(conversationId, senderHash, encryptedContent, return response.data } -// Directus sets user_created automatically for authenticated requests -export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) { - const response = await client.post('/items/conversations', { +export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2, buyerUserId) { + const payload = { listing_id: listingId, participant_hash_1: participantHash1, participant_hash_2: participantHash2, public_key_1: publicKey1, public_key_2: publicKey2, status: 'active' - }) + } + if (buyerUserId) payload.buyer_user = buyerUserId + const response = await client.post('/items/conversations', payload) return response.data } diff --git a/js/services/notifications.js b/js/services/notifications.js index ae7d5f7..d7c56f6 100644 --- a/js/services/notifications.js +++ b/js/services/notifications.js @@ -3,6 +3,7 @@ */ import { directus } from './directus.js' +import { client } from './directus/client.js' import { auth } from './auth.js' class NotificationsService { @@ -68,8 +69,11 @@ class NotificationsService { document.removeEventListener('visibilitychange', this._onVisibility) } - _onVisibility = () => { + _onVisibility = async () => { if (document.visibilityState === 'visible') { + if (client._refreshPromise) { + await client._refreshPromise + } this.refresh() } } diff --git a/js/services/pow-captcha.js b/js/services/pow-captcha.js index ee2de2c..aef1478 100644 --- a/js/services/pow-captcha.js +++ b/js/services/pow-captcha.js @@ -69,13 +69,17 @@ export async function verifySolution(solution) { }) clearTimeout(timeout) - if (!response.ok) throw new Error(`HTTP ${response.status}`) + if (!response.ok) { + const errData = await response.json().catch(() => ({})) + throw new Error(errData.error || `HTTP ${response.status}`) + } const data = await response.json() return { ok: data.ok === true, token: data.captcha_token || null } - } catch { + } catch (e) { clearTimeout(timeout) - return { ok: false, token: null } + console.warn('[pow] verify failed:', e.message) + return { ok: false, token: null, error: e.message } } }