feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
This commit is contained in:
162
server.js
162
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());
|
||||
|
||||
Reference in New Issue
Block a user