diff --git a/Dockerfile b/Dockerfile index e725fb6..eb57cdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,13 @@ WORKDIR /app COPY server.js /app/server.js COPY index.html /app/public/ +COPY favorites.html /app/public/ COPY admin.html /app/public/ COPY offline.html /app/public/ COPY style.css /app/public/ COPY app.js /app/public/ +COPY auth.js /app/public/ +COPY favorites.js /app/public/ COPY admin.js /app/public/ COPY services.json /app/public/ COPY manifest.webmanifest /app/public/ @@ -17,6 +20,9 @@ COPY icons/ /app/public/icons/ ENV NODE_ENV=production ENV PORT=80 +ENV FAVORITES_FILE=/app/data/favorites.json + +RUN mkdir -p /app/data EXPOSE 80 diff --git a/admin.html b/admin.html index c905f16..ac7a19d 100644 --- a/admin.html +++ b/admin.html @@ -12,7 +12,7 @@ - +
diff --git a/app.js b/app.js index 97719bb..0ab5ca2 100644 --- a/app.js +++ b/app.js @@ -16,6 +16,7 @@ const icons = { let cards = []; let sections = []; let config = null; +let favorites = []; let activeFilter = 'all'; let categoryById = new Map(); @@ -26,11 +27,25 @@ const onlineCount = document.querySelector('#onlineCount'); const serviceCount = document.querySelector('#serviceCount'); const lastChecked = document.querySelector('#lastChecked'); const dashboardSections = document.querySelector('#dashboardSections'); +const favoritesSections = document.querySelector('#favoritesSections'); +const loginButton = document.querySelector('#dashboardLoginButton'); +const logoutButton = document.querySelector('#dashboardLogoutButton'); +const favoritesLink = document.querySelector('#favoritesLink'); +const userBadge = document.querySelector('#dashboardUserBadge'); function normalize(value) { return String(value || '').toLowerCase().trim(); } +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function getLocalConfig() { try { const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY); @@ -181,6 +196,135 @@ function createServiceCard(service) { return card; } +function createFavoriteCard(favorite) { + const card = document.createElement('a'); + card.href = favorite.url; + card.target = '_blank'; + card.rel = 'noopener'; + card.className = 'card favorite-card'; + card.style.setProperty('--accent', favorite.color || '#67e8f9'); + + const safeLogo = favorite.logoUrl ? escapeHtml(favorite.logoUrl) : ''; + const safeName = escapeHtml(favorite.name); + const safeDescription = escapeHtml(favorite.description || favorite.group || 'Favorit'); + const iconSvg = icons[favorite.icon] || icons.folder; + + card.innerHTML = ` +
+ ${safeLogo + ? `` + : ``} +
+
+ ${escapeHtml(favorite.group || 'Favorit')} +

${safeName}

+

${safeDescription}

+
+ ${escapeHtml(new URL(favorite.url).hostname)} + `; + + return card; +} + +function renderFavorites() { + if (!favoritesSections) return; + + if (!MischLabsAuth.isAuthenticated()) { + favoritesSections.innerHTML = ` +
+
+
+

Persoenlich

+

Deine Favoriten

+
+
+
+

Melde dich mit SSO an, um eigene Kacheln und Weblinks zu speichern.

+ +
+
+ `; + document.querySelector('#favoritesLoginCallout')?.addEventListener('click', () => MischLabsAuth.login()); + return; + } + + if (!favorites.length) { + favoritesSections.innerHTML = ` +
+
+
+

Persoenlich

+

Deine Favoriten

+
+ Favoriten erstellen +
+
+

Noch keine Favoriten. Lege eigene Kacheln fuer YouTube, Notion, Tools oder beliebige Links an.

+
+
+ `; + return; + } + + const groups = new Map(); + favorites.forEach((favorite) => { + const group = favorite.group || 'Favoriten'; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(favorite); + }); + + favoritesSections.innerHTML = ` +
+
+
+

Persoenlich

+

Deine Favoriten

+
+ Bearbeiten +
+
+
+ `; + + const container = favoritesSections.querySelector('.favorite-groups'); + [...groups.entries()].forEach(([group, items]) => { + const section = document.createElement('section'); + section.className = 'favorite-group'; + section.innerHTML = ` +
+

${escapeHtml(group)}

+ ${items.length} ${items.length === 1 ? 'Link' : 'Links'} +
+
+ `; + const grid = section.querySelector('.grid'); + items.forEach((favorite) => grid.appendChild(createFavoriteCard(favorite))); + container.appendChild(section); + }); +} + +async function loadFavorites() { + if (!MischLabsAuth.isAuthenticated()) { + favorites = []; + renderFavorites(); + return; + } + + try { + const response = await fetch('/api/favorites', { + cache: 'no-store', + headers: MischLabsAuth.authHeader() + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + favorites = Array.isArray(payload.favorites) ? payload.favorites : []; + } catch { + favorites = []; + } + + renderFavorites(); +} + function renderDashboard(nextConfig) { dashboardSections.innerHTML = ''; serviceCount.textContent = String(nextConfig.services.length); @@ -313,17 +457,37 @@ segments.forEach((segment) => { searchInput.addEventListener('input', updateVisibility); +function updateAuthNav() { + const authenticated = MischLabsAuth.isAuthenticated(); + if (loginButton) loginButton.hidden = authenticated; + if (logoutButton) logoutButton.hidden = !authenticated; + if (favoritesLink) favoritesLink.hidden = !authenticated; + if (userBadge) { + userBadge.hidden = !authenticated; + userBadge.textContent = authenticated ? MischLabsAuth.displayName() : ''; + } +} + +loginButton?.addEventListener('click', () => MischLabsAuth.login()); +logoutButton?.addEventListener('click', () => MischLabsAuth.logout()); + if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}); }); } -loadConfig() +MischLabsAuth.handleCallback() + .catch((error) => { + lastChecked.textContent = error.message; + }) + .then(() => loadConfig()) .then((nextConfig) => { config = nextConfig; + updateAuthNav(); renderDashboard(config); updateVisibility(); + loadFavorites(); scheduleStatusRefresh(); }) .catch(() => { diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..6e1a349 --- /dev/null +++ b/auth.js @@ -0,0 +1,200 @@ +const MischLabsAuth = (() => { + const TOKEN_KEY = 'mischlabs.dashboard.tokens'; + const PKCE_KEY = 'mischlabs.dashboard.pkce'; + const CONFIG_KEY = 'mischlabs.dashboard.authConfig'; + const SCOPES = 'openid profile email'; + + let configPromise = null; + let cachedConfig = null; + + function base64UrlEncode(buffer) { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + + function randomString(length = 64) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return [...bytes].map((byte) => chars[byte % chars.length]).join(''); + } + + async function createChallenge(verifier) { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(digest); + } + + async function getConfig() { + if (cachedConfig) return cachedConfig; + if (!configPromise) { + configPromise = fetch('/api/auth/config', { cache: 'no-store' }) + .then((response) => { + if (!response.ok) throw new Error(`Auth config returned ${response.status}`); + return response.json(); + }) + .then((config) => { + cachedConfig = config; + sessionStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + return config; + }) + .catch((error) => { + const fallback = JSON.parse(sessionStorage.getItem(CONFIG_KEY) || 'null'); + if (fallback) return fallback; + throw error; + }); + } + return configPromise; + } + + function redirectUri() { + const path = window.location.pathname === '/index.html' ? '/' : window.location.pathname; + return `${window.location.origin}${path}`; + } + + function decodeJwt(token) { + const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = decodeURIComponent(atob(payload).split('').map((char) => ( + `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}` + )).join('')); + return JSON.parse(json); + } + + function tokens() { + try { + const raw = sessionStorage.getItem(TOKEN_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } + } + + function storeTokens(nextTokens) { + sessionStorage.setItem(TOKEN_KEY, JSON.stringify(nextTokens)); + } + + function clearTokens() { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(PKCE_KEY); + } + + function isAuthenticated() { + const current = tokens(); + if (!current?.id_token) return false; + try { + return Date.now() < decodeJwt(current.id_token).exp * 1000; + } catch { + return false; + } + } + + function claims() { + const current = tokens(); + if (!current?.id_token) return null; + try { + return decodeJwt(current.id_token); + } catch { + return null; + } + } + + function authHeader() { + const current = tokens(); + return current?.id_token ? { Authorization: `Bearer ${current.id_token}` } : {}; + } + + async function login() { + const config = await getConfig(); + const verifier = randomString(); + const challenge = await createChallenge(verifier); + const state = randomString(32); + const redirect = redirectUri(); + sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, redirect })); + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirect, + response_type: 'code', + scope: config.scopes || SCOPES, + state, + code_challenge: challenge, + code_challenge_method: 'S256' + }); + + window.location.href = `${config.authority}/protocol/openid-connect/auth?${params}`; + } + + async function handleCallback() { + const url = new URL(window.location.href); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + window.history.replaceState({}, document.title, redirectUri()); + throw new Error(`SSO Fehler: ${error}`); + } + + if (!code) return false; + + const config = await getConfig(); + const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}'); + if (!stored.verifier || stored.state !== state) { + throw new Error('SSO Antwort konnte nicht verifiziert werden.'); + } + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: config.clientId, + redirect_uri: stored.redirect || redirectUri(), + code, + code_verifier: stored.verifier + }); + + const response = await fetch(`${config.authority}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + + if (!response.ok) { + throw new Error(`Token endpoint returned ${response.status}`); + } + + storeTokens(await response.json()); + sessionStorage.removeItem(PKCE_KEY); + window.history.replaceState({}, document.title, redirectUri()); + return true; + } + + async function logout() { + const config = await getConfig(); + const idToken = tokens()?.id_token; + clearTokens(); + const params = new URLSearchParams({ + client_id: config.clientId, + post_logout_redirect_uri: redirectUri() + }); + if (idToken) params.set('id_token_hint', idToken); + window.location.href = `${config.authority}/protocol/openid-connect/logout?${params}`; + } + + function displayName() { + const currentClaims = claims(); + return currentClaims?.name || currentClaims?.preferred_username || currentClaims?.email || 'Angemeldet'; + } + + return { + authHeader, + claims, + clearTokens, + displayName, + getConfig, + handleCallback, + isAuthenticated, + login, + logout + }; +})(); diff --git a/docker-compose.yml b/docker-compose.yml index 8451222..f3f37fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - /volume1:/host/volume1:ro - /volume2:/host/volume2:ro + - mischlabs_data:/app/data + +volumes: + mischlabs_data: diff --git a/favorites.html b/favorites.html new file mode 100644 index 0000000..b0b3e22 --- /dev/null +++ b/favorites.html @@ -0,0 +1,73 @@ + + + + + + MischLabs - Favoriten + + + + + + + + + + + +
+
+ +
+

Persoenlich

+

Favoriten

+

Eigene Weblinks als Kacheln auf deinem MischLabs Dashboard.

+
+
+ +
+ Dashboard + + +
+
+ +
+
+

SSO

+

Anmeldung erforderlich

+

Deine Favoriten werden pro Keycloak-Benutzer gespeichert.

+
Noch nicht angemeldet.
+
+ + +
+ + + + + diff --git a/favorites.js b/favorites.js new file mode 100644 index 0000000..6ac841b --- /dev/null +++ b/favorites.js @@ -0,0 +1,239 @@ +const favoriteIcons = { + star: '', + link: '', + play: '', + note: '', + book: '', + code: '', + mail: '', + calendar: '', + image: '', + tool: '' +}; + +const iconLabels = { + star: 'Stern', + link: 'Link', + play: 'Video', + note: 'Notiz', + book: 'Buch', + code: 'Code', + mail: 'Mail', + calendar: 'Kalender', + image: 'Bild', + tool: 'Tool' +}; + +const loginButton = document.querySelector('#favoritesLoginButton'); +const logoutButton = document.querySelector('#favoritesLogoutButton'); +const loginPanel = document.querySelector('#favoritesLoginPanel'); +const panel = document.querySelector('#favoritesPanel'); +const message = document.querySelector('#favoritesMessage'); +const userBox = document.querySelector('#favoritesUser'); +const editor = document.querySelector('#favoriteEditor'); +const preview = document.querySelector('#favoritePreview'); +const addButton = document.querySelector('#addFavoriteButton'); +const saveButton = document.querySelector('#saveFavoritesButton'); +const reloadButton = document.querySelector('#reloadFavoritesButton'); + +let favorites = []; + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function createDefaultFavorite() { + return { + id: crypto.randomUUID(), + name: 'Neuer Link', + url: 'https://', + group: 'Favoriten', + description: '', + color: '#67e8f9', + icon: 'star', + logoUrl: '', + order: favorites.length + }; +} + +function iconOptions(selected) { + return Object.keys(favoriteIcons).map((icon) => ( + `` + )).join(''); +} + +function syncFromInputs() { + editor.querySelectorAll('.favorite-edit-card').forEach((row) => { + const favorite = favorites[Number(row.dataset.index)]; + if (!favorite) return; + row.querySelectorAll('[data-field]').forEach((input) => { + favorite[input.dataset.field] = input.value; + }); + }); +} + +function moveFavorite(index, direction) { + const next = index + direction; + if (next < 0 || next >= favorites.length) return; + syncFromInputs(); + [favorites[index], favorites[next]] = [favorites[next], favorites[index]]; + favorites = favorites.map((favorite, order) => ({ ...favorite, order })); + render(); +} + +function removeFavorite(index) { + syncFromInputs(); + favorites.splice(index, 1); + favorites = favorites.map((favorite, order) => ({ ...favorite, order })); + render(); +} + +function renderPreviewCard(favorite) { + const color = /^#[0-9a-fA-F]{6}$/.test(favorite.color) ? favorite.color : '#67e8f9'; + const iconSvg = favoriteIcons[favorite.icon] || favoriteIcons.star; + let host = ''; + try { + host = new URL(favorite.url).hostname; + } catch { + host = 'ungueltige-url'; + } + + return ` +
+
+ ${favorite.logoUrl + ? `` + : ``} +
+
+ ${escapeHtml(favorite.group || 'Favorit')} +

${escapeHtml(favorite.name)}

+

${escapeHtml(favorite.description || 'Eigener Weblink')}

+
+ ${escapeHtml(host)} +
+ `; +} + +function renderPreview() { + preview.innerHTML = favorites.map(renderPreviewCard).join('') || '
Die Vorschau erscheint hier.
'; +} + +function render() { + editor.innerHTML = favorites.map((favorite, index) => ` +
+
+ ${escapeHtml(favorite.name || 'Neuer Link')} +
+ + + +
+
+
+ + + + + + + +
+
+ `).join('') || '
Noch keine Favoriten angelegt.
'; + + renderPreview(); + + editor.querySelectorAll('[data-field]').forEach((input) => { + input.addEventListener('input', () => { + syncFromInputs(); + renderPreview(); + }); + }); + + editor.querySelectorAll('[data-action]').forEach((button) => { + button.addEventListener('click', () => { + const index = Number(button.closest('.favorite-edit-card').dataset.index); + if (button.dataset.action === 'up') moveFavorite(index, -1); + if (button.dataset.action === 'down') moveFavorite(index, 1); + if (button.dataset.action === 'remove') removeFavorite(index); + }); + }); +} + +async function loadFavorites() { + const response = await fetch('/api/favorites', { + cache: 'no-store', + headers: MischLabsAuth.authHeader() + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + favorites = Array.isArray(payload.favorites) ? payload.favorites : []; + userBox.textContent = `Angemeldet als ${payload.user?.name || MischLabsAuth.displayName()}`; + render(); +} + +async function saveFavorites() { + syncFromInputs(); + const response = await fetch('/api/favorites', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...MischLabsAuth.authHeader() + }, + body: JSON.stringify({ favorites }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + favorites = payload.favorites || []; + render(); + userBox.textContent = 'Favoriten gespeichert.'; +} + +function updateAuthUi() { + const authenticated = MischLabsAuth.isAuthenticated(); + loginButton.hidden = authenticated; + logoutButton.hidden = !authenticated; + loginPanel.hidden = authenticated; + panel.hidden = !authenticated; +} + +async function boot() { + try { + await MischLabsAuth.handleCallback(); + } catch (error) { + message.textContent = error.message; + } + + updateAuthUi(); + if (MischLabsAuth.isAuthenticated()) { + await loadFavorites(); + } +} + +loginButton.addEventListener('click', () => MischLabsAuth.login()); +logoutButton.addEventListener('click', () => MischLabsAuth.logout()); +addButton.addEventListener('click', () => { + syncFromInputs(); + favorites.push(createDefaultFavorite()); + render(); +}); +saveButton.addEventListener('click', () => { + saveFavorites().catch((error) => { + userBox.textContent = `Speichern fehlgeschlagen: ${error.message}`; + }); +}); +reloadButton.addEventListener('click', () => { + loadFavorites().catch((error) => { + userBox.textContent = `Laden fehlgeschlagen: ${error.message}`; + }); +}); + +boot().catch((error) => { + message.textContent = `Favoritenseite konnte nicht starten: ${error.message}`; +}); diff --git a/index.html b/index.html index 1fdd833..ea067aa 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ - +
@@ -42,6 +42,13 @@ Installierbar + +
@@ -66,6 +73,7 @@
+
@@ -119,6 +127,7 @@ - + + diff --git a/offline.html b/offline.html index a6c6323..6c2dbbb 100644 --- a/offline.html +++ b/offline.html @@ -11,7 +11,7 @@ - +
diff --git a/server.js b/server.js index 5dec6c4..86f2934 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,8 @@ const WATCHTOWER_CONFIG_PATH = process.env.WATCHTOWER_CONFIG_PATH || '/volume2/d const WATCHTOWER_LOG_TAIL = Number(process.env.WATCHTOWER_LOG_TAIL || 1200); const AUTHORITY = process.env.AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs'; const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'mischlabs-admin'; +const DASHBOARD_CLIENT_ID = process.env.DASHBOARD_CLIENT_ID || ADMIN_CLIENT_ID; +const FAVORITES_FILE = process.env.FAVORITES_FILE || path.join(__dirname, 'data', 'favorites.json'); const ADMIN_USERS = (process.env.ADMIN_USERS || 'mrdiderot').split(',').map((user) => user.trim().toLowerCase()).filter(Boolean); const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'mail.misch@pm.me').split(',').map((email) => email.trim().toLowerCase()).filter(Boolean); const RESTART_BLOCKLIST = new Set((process.env.RESTART_BLOCKLIST || [ @@ -156,6 +158,14 @@ function authError(message) { } async function verifyAdminRequest(req) { + return verifyOidcRequest(req, { clientId: ADMIN_CLIENT_ID, requireAdmin: true }); +} + +async function verifyUserRequest(req) { + return verifyOidcRequest(req, { clientId: DASHBOARD_CLIENT_ID, requireAdmin: false }); +} + +async function verifyOidcRequest(req, { clientId, requireAdmin }) { const token = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]; if (!token) throw authError('missing_token'); @@ -176,11 +186,11 @@ async function verifyAdminRequest(req) { if (claims.exp * 1000 < Date.now()) throw authError('expired_token'); const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; - if (!audience.includes(ADMIN_CLIENT_ID) && claims.azp !== ADMIN_CLIENT_ID) throw authError('invalid_audience'); + if (!audience.includes(clientId) && claims.azp !== clientId) throw authError('invalid_audience'); const username = String(claims.preferred_username || '').toLowerCase(); const email = String(claims.email || '').toLowerCase(); - if (!ADMIN_USERS.includes(username) && !ADMIN_EMAILS.includes(email)) throw authError('not_allowed'); + if (requireAdmin && !ADMIN_USERS.includes(username) && !ADMIN_EMAILS.includes(email)) throw authError('not_allowed'); const jwks = await getJwks(); const jwk = jwks.keys?.find((key) => key.kid === header.kid); @@ -196,6 +206,109 @@ async function verifyAdminRequest(req) { return claims; } +function readRequestBody(req, maxBytes = 128 * 1024) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + + req.on('data', (chunk) => { + total += chunk.length; + if (total > maxBytes) { + reject(Object.assign(new Error('payload_too_large'), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function userKeyFromClaims(claims) { + return String(claims.sub || claims.preferred_username || claims.email || '').replace(/[^a-zA-Z0-9_.@-]/g, '_'); +} + +function publicUserFromClaims(claims) { + return { + id: claims.sub, + username: claims.preferred_username || null, + email: claims.email || null, + name: claims.name || claims.preferred_username || claims.email || 'Nutzer' + }; +} + +async function readFavoritesStore() { + try { + const raw = await fsp.readFile(FAVORITES_FILE, 'utf8'); + return JSON.parse(raw); + } catch (error) { + if (error.code === 'ENOENT') return {}; + throw error; + } +} + +async function writeFavoritesStore(store) { + await fsp.mkdir(path.dirname(FAVORITES_FILE), { recursive: true }); + const tmp = `${FAVORITES_FILE}.${process.pid}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(store, null, 2), 'utf8'); + await fsp.rename(tmp, FAVORITES_FILE); +} + +function cleanText(value, max = 80) { + return String(value || '').trim().replace(/\s+/g, ' ').slice(0, max); +} + +function cleanColor(value) { + const color = String(value || '').trim(); + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#67e8f9'; +} + +function cleanUrl(value) { + const url = new URL(String(value || '').trim()); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Ungueltige URL.'); + return url.href; +} + +function sanitizeFavorites(input) { + if (!Array.isArray(input)) throw new Error('Favoriten muessen ein Array sein.'); + return input.slice(0, 80).map((item, index) => { + const name = cleanText(item.name, 64); + if (!name) throw new Error('Jeder Favorit braucht eine Beschriftung.'); + + return { + id: cleanText(item.id, 80) || crypto.randomUUID(), + name, + url: cleanUrl(item.url), + group: cleanText(item.group || 'Favoriten', 40) || 'Favoriten', + description: cleanText(item.description, 120), + color: cleanColor(item.color), + icon: cleanText(item.icon || 'star', 32), + logoUrl: item.logoUrl ? cleanUrl(item.logoUrl) : '', + order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + }; + }).sort((a, b) => a.order - b.order).map((item, index) => ({ ...item, order: index })); +} + +async function getUserFavorites(claims) { + const store = await readFavoritesStore(); + const key = userKeyFromClaims(claims); + return Array.isArray(store[key]?.favorites) ? store[key].favorites : []; +} + +async function saveUserFavorites(claims, favorites) { + const store = await readFavoritesStore(); + const key = userKeyFromClaims(claims); + store[key] = { + user: publicUserFromClaims(claims), + updatedAt: new Date().toISOString(), + favorites: sanitizeFavorites(favorites) + }; + await writeFavoritesStore(store); + return store[key].favorites; +} + function normalizeContainerName(name) { return String(name || '').replace(/^\//, '').trim(); } @@ -946,6 +1059,51 @@ async function serveStatic(req, res) { } const server = http.createServer(async (req, res) => { + if (req.url?.startsWith('/api/auth/config')) { + if (req.method !== 'GET') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + json(res, 200, { + authority: AUTHORITY, + clientId: DASHBOARD_CLIENT_ID, + scopes: 'openid profile email' + }); + return; + } + + if (req.url?.startsWith('/api/favorites')) { + try { + const claims = await verifyUserRequest(req); + + if (req.method === 'GET') { + json(res, 200, { + user: publicUserFromClaims(claims), + favorites: await getUserFavorites(claims) + }); + return; + } + + if (req.method === 'PUT') { + const body = await readRequestBody(req); + const payload = JSON.parse(body || '{}'); + const favorites = await saveUserFavorites(claims, payload.favorites); + json(res, 200, { + user: publicUserFromClaims(claims), + favorites + }); + return; + } + + json(res, 405, { error: 'Method not allowed' }); + } catch (error) { + const status = error.statusCode || (['missing_token', 'invalid_token', 'expired_token', 'not_allowed', 'invalid_audience'].includes(error.message) ? 401 : 500); + json(res, status, { error: error.message }); + } + return; + } + if (req.url?.startsWith('/api/status')) { try { json(res, 200, await statusPayload()); diff --git a/style.css b/style.css index a06a07d..b05ba40 100644 --- a/style.css +++ b/style.css @@ -121,6 +121,27 @@ h1 { gap: 10px; } +.dashboard-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-top: -10px; +} + +.user-badge { + min-height: 34px; + display: inline-flex; + align-items: center; + padding: 0 12px; + color: #c4f1ff; + background: rgba(17, 22, 36, 0.72); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.82rem; +} + .metric { min-width: 88px; padding: 13px 14px; @@ -328,6 +349,13 @@ main { height: 23px; } +.favorite-icon img { + width: 28px; + height: 28px; + border-radius: 6px; + object-fit: cover; +} + .card-content { min-width: 0; } @@ -413,6 +441,134 @@ main { font-size: 0.8rem; } +.favorites-section { + margin-top: 28px; + padding-top: 6px; +} + +.favorites-empty { + padding: 16px; + color: var(--muted); + background: rgba(17, 22, 36, 0.56); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.favorites-empty p { + margin-bottom: 12px; + line-height: 1.45; +} + +.favorite-groups { + display: grid; + gap: 18px; +} + +.favorite-group { + display: grid; + gap: 10px; +} + +.favorite-group-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.favorite-group-heading h3 { + color: #fff; + font-size: 0.98rem; +} + +.favorite-group-heading span { + color: var(--dim); + font-size: 0.78rem; +} + +.favorite-editor-layout { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 14px; + align-items: start; + margin-top: 14px; +} + +.favorite-editor-list { + display: grid; + gap: 12px; +} + +.favorite-edit-card { + padding: 14px; + background: rgba(7, 9, 20, 0.42); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.favorite-edit-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.favorite-order-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.favorite-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.favorite-form-grid label { + display: grid; + gap: 6px; + color: var(--dim); + font-size: 0.76rem; +} + +.favorite-form-grid input, +.favorite-form-grid select { + width: 100%; + min-height: 38px; + padding: 0 10px; + color: var(--text); + background: rgba(17, 22, 36, 0.92); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; +} + +.favorite-form-grid input[type="color"] { + padding: 3px; +} + +.favorite-form-wide { + grid-column: 1 / -1; +} + +.favorite-preview-panel { + position: sticky; + top: 72px; + display: grid; + gap: 12px; + padding: 14px; + background: rgba(7, 9, 20, 0.42); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.favorite-preview-grid { + grid-template-columns: 1fr; +} + .admin-hero { align-items: center; } @@ -752,10 +908,23 @@ main { grid-template-columns: 1fr; } + .favorite-editor-layout { + grid-template-columns: 1fr; + } + + .favorite-preview-panel { + position: static; + } + .hero-meta { width: 100%; } + .dashboard-actions { + justify-content: flex-start; + margin-top: 0; + } + .control-bar { grid-template-columns: 1fr; } @@ -811,6 +980,10 @@ main { padding: 14px; } + .favorite-form-grid { + grid-template-columns: 1fr; + } + .section-heading { align-items: start; } diff --git a/sw.js b/sw.js index 04619eb..6052050 100644 --- a/sw.js +++ b/sw.js @@ -1,10 +1,13 @@ -const CACHE_NAME = 'mischlabs-pwa-v14'; +const CACHE_NAME = 'mischlabs-pwa-v15'; const APP_SHELL = [ '/', '/index.html', + '/favorites.html', '/offline.html', - '/style.css?v=27', - '/app.js?v=31', + '/style.css?v=28', + '/auth.js?v=1', + '/app.js?v=32', + '/favorites.js?v=1', '/services.json?v=1', '/manifest.webmanifest?v=4', '/favicon.ico?v=4',