feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s

This commit is contained in:
Kroonk
2026-05-21 23:18:23 +02:00
parent fb540813ff
commit d40329ae7c
12 changed files with 1039 additions and 10 deletions

162
server.js
View File

@@ -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());