From 9f48e073b863164f1e7ce757f9825abb223d45b6 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sun, 8 Feb 2026 13:53:23 +0100 Subject: [PATCH] fix: security hardening + code quality improvements (401 retry limit, UUID crypto, debounce this-bug, deduplicate CSS/helpers, optimize SW precache) --- css/variables.css | 34 ++--------------------------- index.html | 12 +---------- js/app.js | 5 ++++- js/components/error-boundary.js | 4 ++-- js/components/listing-card.js | 7 +----- js/services/auth.js | 12 ++++++----- js/services/conversations.js | 11 +++++----- js/services/directus.js | 19 ++++++++++------- js/utils/helpers.js | 38 +++++---------------------------- service-worker.js | 15 ++----------- tests/helpers.test.js | 36 +------------------------------ 11 files changed, 41 insertions(+), 152 deletions(-) diff --git a/css/variables.css b/css/variables.css index c90f360..1571d15 100644 --- a/css/variables.css +++ b/css/variables.css @@ -12,8 +12,8 @@ --color-secondary: #777777; --color-secondary-hover: #5A5A5A; - --color-accent: #047857; - --color-accent-hover: #065f46; + --color-accent: #059669; + --color-accent-hover: #047857; --color-accent-text: #fff; --color-success: #666666; @@ -160,33 +160,3 @@ --color-overlay: rgba(0, 0, 0, 0.7); } -/* Light Mode - Manual Override */ -[data-theme="light"] { - --color-primary: #555555; - --color-primary-hover: #404040; - --color-primary-light: #E8E8E8; - - --color-secondary: #777777; - --color-secondary-hover: #5A5A5A; - - --color-accent: #059669; - --color-accent-hover: #047857; - - --color-success: #666666; - --color-warning: #888888; - --color-error: #444444; - - --color-bg: #FAFAFA; - --color-bg-secondary: #F0F0F0; - --color-bg-tertiary: #E5E5E5; - - --color-text: #1A1A1A; - --color-text-secondary: #4A4A4A; - --color-text-muted: #737373; - - --color-border: #D0D0D0; - --color-border-focus: #555555; - - --color-shadow: rgba(0, 0, 0, 0.1); - --color-overlay: rgba(0, 0, 0, 0.5); -} diff --git a/index.html b/index.html index 9318846..d923fa6 100644 --- a/index.html +++ b/index.html @@ -62,7 +62,7 @@ :root { --color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8; --color-secondary: #777777; --color-secondary-hover: #5A5A5A; - --color-accent: #047857; --color-accent-hover: #065f46; --color-accent-text: #fff; + --color-accent: #059669; --color-accent-hover: #047857; --color-accent-text: #fff; --color-success: #666666; --color-warning: #888888; --color-error: #444444; --color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5; --color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373; @@ -102,16 +102,6 @@ --color-border: #3A3A3A; --color-border-focus: #AAAAAA; --color-shadow: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.7); } - [data-theme="light"] { - --color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8; - --color-secondary: #777777; --color-secondary-hover: #5A5A5A; - --color-accent: #059669; --color-accent-hover: #047857; - --color-success: #666666; --color-warning: #888888; --color-error: #444444; - --color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5; - --color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373; - --color-border: #D0D0D0; --color-border-focus: #555555; - --color-shadow: rgba(0, 0, 0, 0.1); --color-overlay: rgba(0, 0, 0, 0.5); - } /* base.css */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } diff --git a/js/app.js b/js/app.js index c0c5b47..ba7d400 100644 --- a/js/app.js +++ b/js/app.js @@ -18,7 +18,6 @@ async function initApp() { // Restore auth session before loading components await auth.tryRestoreSession() favoritesService.init() - notificationsService.init() auth.subscribe((loggedIn) => { if (loggedIn) { @@ -28,6 +27,10 @@ async function initApp() { } }) + if (auth.isLoggedIn()) { + notificationsService.init() + } + await import('./components/app-shell.js') const appEl = document.getElementById('app') diff --git a/js/components/error-boundary.js b/js/components/error-boundary.js index 5b33c13..675bb5d 100644 --- a/js/components/error-boundary.js +++ b/js/components/error-boundary.js @@ -73,13 +73,13 @@ export function setupGlobalErrorHandler() { // Don't show UI for script loading errors if (event.filename && event.filename.includes('.js')) { - showErrorToast(event.message || 'Ein Fehler ist aufgetreten') + showErrorToast(event.message || t('common.error')) } }) window.addEventListener('unhandledrejection', (event) => { console.error('Unhandled promise rejection:', event.reason) - showErrorToast(event.reason?.message || 'Ein Fehler ist aufgetreten') + showErrorToast(event.reason?.message || t('common.error')) }) // Offline/Online detection diff --git a/js/components/listing-card.js b/js/components/listing-card.js index 5a3596d..d14cc0b 100644 --- a/js/components/listing-card.js +++ b/js/components/listing-card.js @@ -4,8 +4,6 @@ import { getXmrRates, formatPrice as formatCurrencyPrice } from '../services/cur import { auth } from '../services/auth.js' import { favoritesService } from '../services/favorites.js' -let cachedRates = null - class ListingCard extends HTMLElement { static get observedAttributes() { return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority'] @@ -53,10 +51,7 @@ class ListingCard extends HTMLElement { } async loadRates() { - if (!cachedRates) { - cachedRates = await getXmrRates() - } - this.rates = cachedRates + this.rates = await getXmrRates() } attributeChangedCallback() { diff --git a/js/services/auth.js b/js/services/auth.js index 206434d..4132dcd 100644 --- a/js/services/auth.js +++ b/js/services/auth.js @@ -27,11 +27,13 @@ class AuthService { return crypto.randomUUID() } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0 - const v = c === 'x' ? r : (r & 0x3 | 0x8) - return v.toString(16) - }) + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return [...bytes].map((b, i) => + ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0') + ).join('') } /** diff --git a/js/services/conversations.js b/js/services/conversations.js index 131970b..cdaa068 100644 --- a/js/services/conversations.js +++ b/js/services/conversations.js @@ -18,14 +18,13 @@ class ConversationsService { return this.hashPublicKey(publicKey) } - hashPublicKey(publicKey) { + async hashPublicKey(publicKey) { const encoder = new TextEncoder() const data = encoder.encode(publicKey) - return crypto.subtle.digest('SHA-256', data).then(hash => { - return Array.from(new Uint8Array(hash)) - .map(b => b.toString(16).padStart(2, '0')) - .join('') - }) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') } async getMyConversations() { diff --git a/js/services/directus.js b/js/services/directus.js index d785f07..12479e5 100644 --- a/js/services/directus.js +++ b/js/services/directus.js @@ -45,6 +45,7 @@ class DirectusService { this.refreshToken = null this.tokenExpiry = null this.refreshTimeout = null + this._refreshPromise = null this.loadTokens() this.setupVisibilityRefresh() @@ -153,15 +154,17 @@ class DirectusService { headers }) - // Token expired - try refresh (but not for auth endpoints) - if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/')) { - const refreshed = await this.refreshSession() - if (refreshed) { - return this.request(endpoint, options) - } else { - this.clearTokens() - return this.request(endpoint, options) + if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) { + if (!this._refreshPromise) { + this._refreshPromise = this.refreshSession().finally(() => { + this._refreshPromise = null + }) } + const refreshed = await this._refreshPromise + if (!refreshed) { + this.clearTokens() + } + return this.request(endpoint, options, _retryCount + 1) } if (response.status === 429 && _retryCount < 3) { diff --git a/js/utils/helpers.js b/js/utils/helpers.js index 9e361dc..67b59bf 100644 --- a/js/utils/helpers.js +++ b/js/utils/helpers.js @@ -21,34 +21,6 @@ export function escapeHTML(str) { .replace(/'/g, '''); } -/** - * Format price with currency symbol - * @param {number} price - Price value - * @param {string} [currency='EUR'] - Currency code (EUR, USD, CHF, XMR) - * @returns {string} Formatted price string - * @example - * formatPrice(99.5, 'EUR') // '€ 99.50' - * formatPrice(0.5, 'XMR') // '0.5000 ɱ' - */ -export function formatPrice(price, currency = 'EUR') { - if (price === null || price === undefined) return '–'; - - const symbols = { - EUR: '€', - USD: '$', - CHF: 'CHF', - XMR: 'ɱ' - }; - - const symbol = symbols[currency] || currency; - - if (currency === 'XMR') { - return `${price.toFixed(4)} ${symbol}`; - } - - return `${symbol} ${price.toFixed(2)}`; -} - /** * Format relative time (e.g., "vor 2 Stunden", "2 hours ago") * Uses Intl.RelativeTimeFormat for localization @@ -84,11 +56,11 @@ export function formatRelativeTime(date, locale = 'de') { * const debouncedSearch = debounce((q) => search(q), 500) */ export function debounce(fn, delay = 300) { - let timeoutId; - return (...args) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => fn.apply(this, args), delay); - }; + let timeoutId + return function (...args) { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => fn.apply(this, args), delay) + } } /** diff --git a/service-worker.js b/service-worker.js index 87d7058..8df8448 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'dgray-v49'; +const CACHE_NAME = 'dgray-v50'; const STATIC_ASSETS = [ '/', '/index.html', @@ -45,20 +45,9 @@ const STATIC_ASSETS = [ '/js/components/location-map.js', '/js/components/pow-captcha.js', - // Pages + // Pages (critical only — lazy-loaded pages are runtime-cached via stale-while-revalidate) '/js/components/pages/page-home.js', - '/js/components/pages/page-listing.js', - '/js/components/pages/page-create.js', - '/js/components/pages/page-favorites.js', - '/js/components/pages/page-my-listings.js', - '/js/components/pages/page-messages.js', - '/js/components/pages/page-settings.js', '/js/components/pages/page-not-found.js', - '/js/components/pages/page-notifications.js', - '/js/components/pages/page-privacy.js', - '/js/components/pages/page-terms.js', - '/js/components/pages/page-about.js', - '/js/components/pages/page-contact.js', // Vendor '/js/vendor/cropper.min.js', diff --git a/tests/helpers.test.js b/tests/helpers.test.js index 0bb1b91..83a3393 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -3,7 +3,7 @@ */ import { test, describe, assertEquals, assertTrue } from './test-runner.js' -import { escapeHTML, formatPrice, formatRelativeTime, debounce, truncate } from '../js/utils/helpers.js' +import { escapeHTML, formatRelativeTime, debounce, truncate } from '../js/utils/helpers.js' describe('escapeHTML', () => { test('escapes < and >', () => { @@ -40,40 +40,6 @@ describe('escapeHTML', () => { }) }) -describe('formatPrice', () => { - test('formats EUR correctly', () => { - assertEquals(formatPrice(99.5, 'EUR'), '€ 99.50') - }) - - test('formats USD correctly', () => { - assertEquals(formatPrice(99.5, 'USD'), '$ 99.50') - }) - - test('formats CHF correctly', () => { - assertEquals(formatPrice(99.5, 'CHF'), 'CHF 99.50') - }) - - test('formats XMR with 4 decimals', () => { - assertEquals(formatPrice(0.5, 'XMR'), '0.5000 ɱ') - }) - - test('handles null price', () => { - assertEquals(formatPrice(null, 'EUR'), '–') - }) - - test('handles undefined price', () => { - assertEquals(formatPrice(undefined, 'EUR'), '–') - }) - - test('defaults to EUR', () => { - assertEquals(formatPrice(10), '€ 10.00') - }) - - test('handles unknown currency', () => { - assertEquals(formatPrice(10, 'GBP'), 'GBP 10.00') - }) -}) - describe('formatRelativeTime', () => { test('formats seconds ago', () => { const date = new Date(Date.now() - 30000) // 30 seconds ago