fix: security hardening + code quality improvements (401 retry limit, UUID crypto, debounce this-bug, deduplicate CSS/helpers, optimize SW precache)

This commit is contained in:
2026-02-08 13:53:23 +01:00
parent c66c80adcc
commit 9f48e073b8
11 changed files with 41 additions and 152 deletions

View File

@@ -12,8 +12,8 @@
--color-secondary: #777777; --color-secondary: #777777;
--color-secondary-hover: #5A5A5A; --color-secondary-hover: #5A5A5A;
--color-accent: #047857; --color-accent: #059669;
--color-accent-hover: #065f46; --color-accent-hover: #047857;
--color-accent-text: #fff; --color-accent-text: #fff;
--color-success: #666666; --color-success: #666666;
@@ -160,33 +160,3 @@
--color-overlay: rgba(0, 0, 0, 0.7); --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);
}

View File

@@ -62,7 +62,7 @@
:root { :root {
--color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8; --color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8;
--color-secondary: #777777; --color-secondary-hover: #5A5A5A; --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-success: #666666; --color-warning: #888888; --color-error: #444444;
--color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5; --color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5;
--color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373; --color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373;
@@ -102,16 +102,6 @@
--color-border: #3A3A3A; --color-border-focus: #AAAAAA; --color-border: #3A3A3A; --color-border-focus: #AAAAAA;
--color-shadow: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.7); --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 */ /* base.css */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -18,7 +18,6 @@ async function initApp() {
// Restore auth session before loading components // Restore auth session before loading components
await auth.tryRestoreSession() await auth.tryRestoreSession()
favoritesService.init() favoritesService.init()
notificationsService.init()
auth.subscribe((loggedIn) => { auth.subscribe((loggedIn) => {
if (loggedIn) { if (loggedIn) {
@@ -28,6 +27,10 @@ async function initApp() {
} }
}) })
if (auth.isLoggedIn()) {
notificationsService.init()
}
await import('./components/app-shell.js') await import('./components/app-shell.js')
const appEl = document.getElementById('app') const appEl = document.getElementById('app')

View File

@@ -73,13 +73,13 @@ export function setupGlobalErrorHandler() {
// Don't show UI for script loading errors // Don't show UI for script loading errors
if (event.filename && event.filename.includes('.js')) { if (event.filename && event.filename.includes('.js')) {
showErrorToast(event.message || 'Ein Fehler ist aufgetreten') showErrorToast(event.message || t('common.error'))
} }
}) })
window.addEventListener('unhandledrejection', (event) => { window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason) 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 // Offline/Online detection

View File

@@ -4,8 +4,6 @@ import { getXmrRates, formatPrice as formatCurrencyPrice } from '../services/cur
import { auth } from '../services/auth.js' import { auth } from '../services/auth.js'
import { favoritesService } from '../services/favorites.js' import { favoritesService } from '../services/favorites.js'
let cachedRates = null
class ListingCard extends HTMLElement { class ListingCard extends HTMLElement {
static get observedAttributes() { 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']
@@ -53,10 +51,7 @@ class ListingCard extends HTMLElement {
} }
async loadRates() { async loadRates() {
if (!cachedRates) { this.rates = await getXmrRates()
cachedRates = await getXmrRates()
}
this.rates = cachedRates
} }
attributeChangedCallback() { attributeChangedCallback() {

View File

@@ -27,11 +27,13 @@ class AuthService {
return crypto.randomUUID() return crypto.randomUUID()
} }
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const bytes = new Uint8Array(16)
const r = Math.random() * 16 | 0 crypto.getRandomValues(bytes)
const v = c === 'x' ? r : (r & 0x3 | 0x8) bytes[6] = (bytes[6] & 0x0f) | 0x40
return v.toString(16) bytes[8] = (bytes[8] & 0x3f) | 0x80
}) return [...bytes].map((b, i) =>
([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0')
).join('')
} }
/** /**

View File

@@ -18,14 +18,13 @@ class ConversationsService {
return this.hashPublicKey(publicKey) return this.hashPublicKey(publicKey)
} }
hashPublicKey(publicKey) { async hashPublicKey(publicKey) {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(publicKey) const data = encoder.encode(publicKey)
return crypto.subtle.digest('SHA-256', data).then(hash => { const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)) return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0')) .map(b => b.toString(16).padStart(2, '0'))
.join('') .join('')
})
} }
async getMyConversations() { async getMyConversations() {

View File

@@ -45,6 +45,7 @@ class DirectusService {
this.refreshToken = null this.refreshToken = null
this.tokenExpiry = null this.tokenExpiry = null
this.refreshTimeout = null this.refreshTimeout = null
this._refreshPromise = null
this.loadTokens() this.loadTokens()
this.setupVisibilityRefresh() this.setupVisibilityRefresh()
@@ -153,15 +154,17 @@ class DirectusService {
headers headers
}) })
// Token expired - try refresh (but not for auth endpoints) if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) {
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/')) { if (!this._refreshPromise) {
const refreshed = await this.refreshSession() this._refreshPromise = this.refreshSession().finally(() => {
if (refreshed) { this._refreshPromise = null
return this.request(endpoint, options) })
} else {
this.clearTokens()
return this.request(endpoint, options)
} }
const refreshed = await this._refreshPromise
if (!refreshed) {
this.clearTokens()
}
return this.request(endpoint, options, _retryCount + 1)
} }
if (response.status === 429 && _retryCount < 3) { if (response.status === 429 && _retryCount < 3) {

View File

@@ -21,34 +21,6 @@ export function escapeHTML(str) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
/**
* 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") * Format relative time (e.g., "vor 2 Stunden", "2 hours ago")
* Uses Intl.RelativeTimeFormat for localization * Uses Intl.RelativeTimeFormat for localization
@@ -84,11 +56,11 @@ export function formatRelativeTime(date, locale = 'de') {
* const debouncedSearch = debounce((q) => search(q), 500) * const debouncedSearch = debounce((q) => search(q), 500)
*/ */
export function debounce(fn, delay = 300) { export function debounce(fn, delay = 300) {
let timeoutId; let timeoutId
return (...args) => { return function (...args) {
clearTimeout(timeoutId); clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), delay); timeoutId = setTimeout(() => fn.apply(this, args), delay)
}; }
} }
/** /**

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'dgray-v49'; const CACHE_NAME = 'dgray-v50';
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/index.html', '/index.html',
@@ -45,20 +45,9 @@ const STATIC_ASSETS = [
'/js/components/location-map.js', '/js/components/location-map.js',
'/js/components/pow-captcha.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-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-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 // Vendor
'/js/vendor/cropper.min.js', '/js/vendor/cropper.min.js',

View File

@@ -3,7 +3,7 @@
*/ */
import { test, describe, assertEquals, assertTrue } from './test-runner.js' 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', () => { describe('escapeHTML', () => {
test('escapes < and >', () => { 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', () => { describe('formatRelativeTime', () => {
test('formats seconds ago', () => { test('formats seconds ago', () => {
const date = new Date(Date.now() - 30000) // 30 seconds ago const date = new Date(Date.now() - 30000) // 30 seconds ago