Handy-Insel Portal: Startseite, Kopfleiste und Authentik-Anbindung
- Portal mit Kacheln aus Authentik (pro Benutzer gefiltert) und "Aktuelles" - Web-Component <hi-topbar> als zentraler App-Umschalter für alle Tools - OIDC-Login über Authentik, zentrales Abmelden - Setup-Skript für Authentik (Gruppen-Bindungen, Portal-App, Service-Account) - Deployment auf den Portal-CT per systemd Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6606420b4a
43 files changed
+5577
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from './config.js';
|
||||
import type { PortalUser } from './types.js';
|
||||
|
||||
export interface PortalApp {
|
||||
slug: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
section: string;
|
||||
iconUrl: string;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
interface AkApplication {
|
||||
slug: string;
|
||||
name: string;
|
||||
launch_url: string | null;
|
||||
meta_launch_url: string;
|
||||
meta_description: string;
|
||||
meta_icon: string | null;
|
||||
meta_icon_url?: string | null;
|
||||
meta_hide?: boolean;
|
||||
group: string;
|
||||
open_in_new_tab: boolean;
|
||||
}
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
|
||||
// Mitgelieferte Icons (Build: dist/public/icons, Entwicklung: public/icons).
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const iconDir = [path.resolve(here, '../public/icons'), path.resolve(here, '../../public/icons')].find((d) => fs.existsSync(d));
|
||||
const bundledIcons = new Set(iconDir ? fs.readdirSync(iconDir).map((f) => f.replace(/\.svg$/, '')) : []);
|
||||
const cache = new Map<number, { at: number; apps: PortalApp[] }>();
|
||||
|
||||
async function ak<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${config.authentik.url}/api/v3${path}`, {
|
||||
headers: { Authorization: `Bearer ${config.authentik.token}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentik ${path}: HTTP ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function resolvePk(user: PortalUser): Promise<number | undefined> {
|
||||
if (user.authentikPk) return user.authentikPk;
|
||||
const res = await ak<{ results: { pk: number; username: string }[] }>(
|
||||
`/core/users/?username=${encodeURIComponent(user.username)}`,
|
||||
);
|
||||
const match = res.results.find((u) => u.username === user.username);
|
||||
if (match) user.authentikPk = match.pk;
|
||||
return match?.pk;
|
||||
}
|
||||
|
||||
function iconFor(app: AkApplication): string {
|
||||
// Eigenes Icon in Authentik hat Vorrang, sonst das mitgelieferte Portal-Icon.
|
||||
const custom = app.meta_icon_url || app.meta_icon;
|
||||
if (custom) return custom.startsWith('http') ? custom : `${config.authentik.url}${custom}`;
|
||||
return `${config.publicUrl}/icons/${bundledIcons.has(app.slug) ? app.slug : 'default'}.svg`;
|
||||
}
|
||||
|
||||
function sortApps(apps: PortalApp[]): PortalApp[] {
|
||||
const rank = (slug: string) => {
|
||||
const i = config.appOrder.indexOf(slug);
|
||||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||
};
|
||||
return apps.sort((a, b) => rank(a.slug) - rank(b.slug) || a.name.localeCompare(b.name, 'de'));
|
||||
}
|
||||
|
||||
/** Liefert die Apps, auf die der Benutzer laut Authentik-Richtlinien Zugriff hat. */
|
||||
export async function appsForUser(user: PortalUser): Promise<PortalApp[]> {
|
||||
if (!config.authentik.token) return [];
|
||||
const pk = await resolvePk(user);
|
||||
if (!pk) return [];
|
||||
const hit = cache.get(pk);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.apps;
|
||||
|
||||
const res = await ak<{ results: AkApplication[] }>(`/core/applications/?for_user=${pk}&page_size=200`);
|
||||
const apps = sortApps(
|
||||
res.results
|
||||
.filter((a) => !a.meta_hide && !config.hiddenApps.includes(a.slug))
|
||||
.map((a) => ({ app: a, url: a.launch_url || a.meta_launch_url }))
|
||||
.filter(({ url }) => !!url && /^https?:\/\//.test(url))
|
||||
.map(({ app, url }) => ({
|
||||
slug: app.slug,
|
||||
name: app.name,
|
||||
url,
|
||||
description: app.meta_description || '',
|
||||
section: app.group || 'Anwendungen',
|
||||
iconUrl: iconFor(app),
|
||||
newTab: app.open_in_new_tab,
|
||||
})),
|
||||
);
|
||||
cache.set(pk, { at: Date.now(), apps });
|
||||
return apps;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import path from 'node:path';
|
||||
|
||||
function env(name: string, fallback = ''): string {
|
||||
return (process.env[name] ?? fallback).trim();
|
||||
}
|
||||
|
||||
function list(name: string, fallback: string): string[] {
|
||||
return env(name, fallback).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const isProduction = env('NODE_ENV') === 'production';
|
||||
const publicUrl = env('PUBLIC_URL', 'http://localhost:3010').replace(/\/$/, '');
|
||||
|
||||
export const config = {
|
||||
isProduction,
|
||||
port: Number(env('PORT', '3010')),
|
||||
host: env('HOST', '0.0.0.0'),
|
||||
publicUrl,
|
||||
dataDir: path.resolve(env('DATA_DIR', './data')),
|
||||
sessionSecret: env('SESSION_SECRET'),
|
||||
/** Sitzungsdauer in Stunden (rollierend). */
|
||||
sessionHours: Number(env('SESSION_HOURS', '12')),
|
||||
|
||||
oidc: {
|
||||
issuer: env('OIDC_ISSUER'),
|
||||
clientId: env('OIDC_CLIENT_ID'),
|
||||
clientSecret: env('OIDC_CLIENT_SECRET'),
|
||||
redirectUri: env('OIDC_REDIRECT_URI', `${publicUrl}/auth/callback`),
|
||||
},
|
||||
|
||||
authentik: {
|
||||
/** Basis-URL von Authentik, z.B. https://login.handy-insel.de */
|
||||
url: env('AUTHENTIK_URL', 'https://login.handy-insel.de').replace(/\/$/, ''),
|
||||
/** Token eines Service-Accounts mit Recht "view_user" + "view_user_applications". */
|
||||
token: env('AUTHENTIK_TOKEN'),
|
||||
},
|
||||
|
||||
/** Gruppen, die Neuigkeiten unter "Aktuelles" schreiben dürfen. */
|
||||
editorGroups: list('EDITOR_GROUPS', 'HI-Admin,HI-Leitung'),
|
||||
/** Apps, die nie angezeigt werden (Slugs), z.B. das Portal selbst. */
|
||||
hiddenApps: list('HIDDEN_APPS', 'portal'),
|
||||
/** Reihenfolge der Kacheln (Slugs); nicht genannte Apps folgen alphabetisch. */
|
||||
appOrder: list(
|
||||
'APP_ORDER',
|
||||
'preisliste,vvl,zeiterfassung,timer,kundenkarte,datenuebertragungen,service,paperless,patch-mon,preisliste-dev,zeit-dev',
|
||||
),
|
||||
/**
|
||||
* Domains, von denen aus die Kopfleiste (topbar.js) die Navigation laden darf
|
||||
* und auf die nach dem Login zurückgeleitet werden darf.
|
||||
*/
|
||||
allowedDomains: list('ALLOWED_DOMAINS', 'handy-insel.de'),
|
||||
|
||||
/** Nur für lokale Entwicklung: Login überspringen und als dieser Benutzer anmelden. */
|
||||
devUser: isProduction ? '' : env('DEV_USER'),
|
||||
};
|
||||
|
||||
export function assertConfig(): void {
|
||||
const missing: string[] = [];
|
||||
if (!config.sessionSecret) missing.push('SESSION_SECRET');
|
||||
if (!config.devUser) {
|
||||
if (!config.oidc.issuer) missing.push('OIDC_ISSUER');
|
||||
if (!config.oidc.clientId) missing.push('OIDC_CLIENT_ID');
|
||||
if (!config.oidc.clientSecret) missing.push('OIDC_CLIENT_SECRET');
|
||||
}
|
||||
if (!config.authentik.token) console.warn('[config] AUTHENTIK_TOKEN fehlt – es werden keine Apps angezeigt.');
|
||||
if (missing.length) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft, ob eine URL/Origin zu einer erlaubten Domain gehört (exakt oder Subdomain, nur https außer lokal). */
|
||||
export function isAllowedUrl(raw: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!config.isProduction && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return true;
|
||||
if (url.protocol !== 'https:') return false;
|
||||
return config.allowedDomains.some((d) => url.hostname === d || url.hostname.endsWith(`.${d}`));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { config } from './config.js';
|
||||
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
export const db = new Database(path.join(config.dataDir, 'portal.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires ON sessions (expires);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
author TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
export interface NewsRow {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
pinned: number;
|
||||
author: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import './types.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import session from 'express-session';
|
||||
import helmet from 'helmet';
|
||||
import { assertConfig, config } from './config.js';
|
||||
import { initOidc } from './oidc.js';
|
||||
import { SqliteStore } from './sessionStore.js';
|
||||
import { authRouter } from './routes/auth.js';
|
||||
import { apiRouter } from './routes/api.js';
|
||||
|
||||
assertConfig();
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Im Build liegt das Frontend unter dist/public, der Server unter dist/server.
|
||||
const publicDir = path.resolve(here, '../public');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', config.authentik.url],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
connectSrc: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
},
|
||||
},
|
||||
// topbar.js und die Icons werden von den anderen Tools geladen.
|
||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||
}),
|
||||
);
|
||||
|
||||
const ttlMs = config.sessionHours * 60 * 60 * 1000;
|
||||
app.use(
|
||||
session({
|
||||
name: 'hi.portal.sid',
|
||||
secret: config.sessionSecret,
|
||||
store: new SqliteStore(ttlMs),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
// Lax: wird auch bei Aufrufen von anderen *.handy-insel.de-Tools (same-site) mitgeschickt.
|
||||
sameSite: 'lax',
|
||||
secure: config.isProduction,
|
||||
maxAge: ttlMs,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
// Öffentliche Assets für die Tools (Kopfleiste + Icons) – ohne Login erreichbar.
|
||||
app.get('/topbar.js', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
res.sendFile(path.join(publicDir, 'topbar.js'));
|
||||
});
|
||||
app.use(express.static(publicDir, { index: false, maxAge: '1h' }));
|
||||
|
||||
// Portal-Seite: nur für angemeldete Benutzer, sonst direkt zu Authentik.
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.session.user) return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('[error]', err);
|
||||
res.status(500).json({ error: 'Interner Fehler' });
|
||||
});
|
||||
|
||||
await initOidc();
|
||||
app.listen(config.port, config.host, () => {
|
||||
console.log(`[portal] läuft auf http://${config.host}:${config.port} (${config.publicUrl})`);
|
||||
if (config.devUser) console.warn(`[portal] DEV-Modus: automatisch angemeldet als "${config.devUser}"`);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import { config } from './config.js';
|
||||
import type { PortalUser } from './types.js';
|
||||
|
||||
let oidcConfig: oidc.Configuration | null = null;
|
||||
|
||||
/** Lädt die OIDC-Konfiguration von Authentik; bei Fehlern wird beim nächsten Login erneut versucht. */
|
||||
async function getConfig(): Promise<oidc.Configuration> {
|
||||
if (!oidcConfig) {
|
||||
oidcConfig = await oidc.discovery(new URL(config.oidc.issuer), config.oidc.clientId, config.oidc.clientSecret);
|
||||
}
|
||||
return oidcConfig;
|
||||
}
|
||||
|
||||
export async function initOidc(): Promise<void> {
|
||||
if (config.devUser) return;
|
||||
try {
|
||||
await getConfig();
|
||||
console.log('[oidc] Discovery erfolgreich:', config.oidc.issuer);
|
||||
} catch (err) {
|
||||
console.error('[oidc] Discovery fehlgeschlagen, wird beim ersten Login wiederholt:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildLoginUrl(): Promise<{ url: string; state: string; codeVerifier: string }> {
|
||||
const cfg = await getConfig();
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const state = oidc.randomState();
|
||||
const url = oidc.buildAuthorizationUrl(cfg, {
|
||||
redirect_uri: config.oidc.redirectUri,
|
||||
scope: 'openid profile email',
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
});
|
||||
return { url: url.href, state, codeVerifier };
|
||||
}
|
||||
|
||||
/** Tauscht den Code gegen Tokens und liefert den Benutzer aus dem ID-Token. */
|
||||
export async function handleCallback(
|
||||
query: string,
|
||||
expected: { state: string; codeVerifier: string },
|
||||
): Promise<{ user: PortalUser; idToken?: string }> {
|
||||
const cfg = await getConfig();
|
||||
// Hinter dem Reverse-Proxy die öffentliche Callback-URL rekonstruieren.
|
||||
const currentUrl = new URL(config.oidc.redirectUri);
|
||||
currentUrl.search = query;
|
||||
const tokens = await oidc.authorizationCodeGrant(cfg, currentUrl, {
|
||||
pkceCodeVerifier: expected.codeVerifier,
|
||||
expectedState: expected.state,
|
||||
});
|
||||
const claims = tokens.claims();
|
||||
if (!claims) throw new Error('Kein ID-Token erhalten');
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
||||
const username = str(claims.preferred_username) || str(claims.email) || claims.sub;
|
||||
return {
|
||||
user: {
|
||||
sub: claims.sub,
|
||||
username,
|
||||
name: str(claims.name) || username,
|
||||
email: str(claims.email),
|
||||
groups: Array.isArray(claims.groups) ? claims.groups.filter((g): g is string => typeof g === 'string') : [],
|
||||
},
|
||||
idToken: tokens.id_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** URL zum Abmelden bei Authentik (beendet die SSO-Sitzung für alle Tools). */
|
||||
export async function buildLogoutUrl(idToken?: string): Promise<string> {
|
||||
if (config.devUser) return '/';
|
||||
const cfg = await getConfig();
|
||||
return oidc.buildEndSessionUrl(cfg, {
|
||||
...(idToken ? { id_token_hint: idToken } : {}),
|
||||
post_logout_redirect_uri: `${config.publicUrl}/`,
|
||||
}).href;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import { config, isAllowedUrl } from '../config.js';
|
||||
import { appsForUser } from '../authentik.js';
|
||||
import { db, type NewsRow } from '../db.js';
|
||||
import type { PortalUser } from '../types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
function requireUser(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.user) return res.status(401).json({ error: 'nicht angemeldet', login: `${config.publicUrl}/auth/login` });
|
||||
next();
|
||||
}
|
||||
|
||||
function isEditor(user: PortalUser): boolean {
|
||||
return user.groups.some((g) => config.editorGroups.includes(g));
|
||||
}
|
||||
|
||||
function requireEditor(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.user || !isEditor(req.session.user)) return res.status(403).json({ error: 'keine Berechtigung' });
|
||||
next();
|
||||
}
|
||||
|
||||
function publicUser(user: PortalUser) {
|
||||
const initials = user.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]!.toUpperCase())
|
||||
.join('');
|
||||
return {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
firstName: user.name.split(/\s+/)[0] ?? user.name,
|
||||
email: user.email,
|
||||
initials: initials || user.username.slice(0, 2).toUpperCase(),
|
||||
groups: user.groups,
|
||||
canEditNews: isEditor(user),
|
||||
};
|
||||
}
|
||||
|
||||
function links() {
|
||||
return {
|
||||
portal: `${config.publicUrl}/`,
|
||||
account: `${config.authentik.url}/if/user/#/settings`,
|
||||
logout: `${config.publicUrl}/auth/logout`,
|
||||
login: `${config.publicUrl}/auth/login`,
|
||||
};
|
||||
}
|
||||
|
||||
apiRouter.get('/me', requireUser, (req, res) => {
|
||||
res.json({ user: publicUser(req.session.user!), links: links() });
|
||||
});
|
||||
|
||||
apiRouter.get('/apps', requireUser, async (req, res) => {
|
||||
try {
|
||||
res.json({ apps: await appsForUser(req.session.user!) });
|
||||
} catch (err) {
|
||||
console.error('[apps]', err);
|
||||
res.status(502).json({ error: 'Apps konnten nicht von Authentik geladen werden', apps: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation für die Kopfleiste in den einzelnen Tools (topbar.js).
|
||||
* Wird per CORS mit Cookies von *.handy-insel.de aufgerufen.
|
||||
*/
|
||||
apiRouter.options('/navigation', navigationCors, (_req, res) => res.sendStatus(204));
|
||||
apiRouter.get('/navigation', navigationCors, async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const user = req.session.user;
|
||||
if (!user) return res.status(401).json({ user: null, apps: [], links: links() });
|
||||
let apps: Awaited<ReturnType<typeof appsForUser>> = [];
|
||||
try {
|
||||
apps = await appsForUser(user);
|
||||
} catch (err) {
|
||||
console.error('[navigation]', err);
|
||||
}
|
||||
res.json({ user: publicUser(user), apps, links: links() });
|
||||
});
|
||||
|
||||
function navigationCors(req: Request, res: Response, next: NextFunction) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && isAllowedUrl(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '600');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---- Aktuelles ----
|
||||
|
||||
function toNews(row: NewsRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
pinned: !!row.pinned,
|
||||
author: row.author,
|
||||
createdAt: `${row.created_at.replace(' ', 'T')}Z`,
|
||||
updatedAt: `${row.updated_at.replace(' ', 'T')}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseNews(body: unknown): { title: string; body: string; pinned: boolean } | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
const title = typeof b.title === 'string' ? b.title.trim() : '';
|
||||
const text = typeof b.body === 'string' ? b.body.trim() : '';
|
||||
if (!title || title.length > 200 || text.length > 10_000) return null;
|
||||
return { title, body: text, pinned: b.pinned === true };
|
||||
}
|
||||
|
||||
apiRouter.get('/news', requireUser, (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||
const rows = db
|
||||
.prepare<[number], NewsRow>('SELECT * FROM news ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ?')
|
||||
.all(limit);
|
||||
res.json({ news: rows.map(toNews) });
|
||||
});
|
||||
|
||||
apiRouter.post('/news', requireEditor, (req, res) => {
|
||||
const input = parseNews(req.body);
|
||||
if (!input) return res.status(400).json({ error: 'Titel fehlt oder Text zu lang' });
|
||||
const info = db
|
||||
.prepare('INSERT INTO news (title, body, pinned, author) VALUES (?, ?, ?, ?)')
|
||||
.run(input.title, input.body, input.pinned ? 1 : 0, req.session.user!.name);
|
||||
const row = db.prepare<[number | bigint], NewsRow>('SELECT * FROM news WHERE id = ?').get(info.lastInsertRowid)!;
|
||||
res.status(201).json(toNews(row));
|
||||
});
|
||||
|
||||
apiRouter.put('/news/:id', requireEditor, (req, res) => {
|
||||
const input = parseNews(req.body);
|
||||
if (!input) return res.status(400).json({ error: 'Titel fehlt oder Text zu lang' });
|
||||
const info = db
|
||||
.prepare("UPDATE news SET title = ?, body = ?, pinned = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(input.title, input.body, input.pinned ? 1 : 0, Number(req.params.id));
|
||||
if (!info.changes) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
const row = db.prepare<[number], NewsRow>('SELECT * FROM news WHERE id = ?').get(Number(req.params.id))!;
|
||||
res.json(toNews(row));
|
||||
});
|
||||
|
||||
apiRouter.delete('/news/:id', requireEditor, (req, res) => {
|
||||
const info = db.prepare('DELETE FROM news WHERE id = ?').run(Number(req.params.id));
|
||||
if (!info.changes) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
res.sendStatus(204);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { config, isAllowedUrl } from '../config.js';
|
||||
import { buildLoginUrl, buildLogoutUrl, handleCallback } from '../oidc.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
/** Rücksprung nur auf das Portal selbst oder erlaubte Tool-Domains. */
|
||||
function safeReturnTo(raw: unknown): string {
|
||||
if (typeof raw !== 'string' || !raw) return '/';
|
||||
if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
|
||||
return isAllowedUrl(raw) ? raw : '/';
|
||||
}
|
||||
|
||||
authRouter.get('/login', async (req, res, next) => {
|
||||
try {
|
||||
const returnTo = safeReturnTo(req.query.returnTo);
|
||||
if (config.devUser) {
|
||||
req.session.user = {
|
||||
sub: `dev-${config.devUser}`,
|
||||
username: config.devUser,
|
||||
name: config.devUser.charAt(0).toUpperCase() + config.devUser.slice(1),
|
||||
email: `${config.devUser}@example.invalid`,
|
||||
groups: ['HI-Mitarbeiter', 'HI-Admin'],
|
||||
};
|
||||
return res.redirect(returnTo);
|
||||
}
|
||||
const { url, state, codeVerifier } = await buildLoginUrl();
|
||||
req.session.login = { state, codeVerifier, returnTo };
|
||||
req.session.save((err) => (err ? next(err) : res.redirect(url)));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get('/callback', async (req, res) => {
|
||||
const pending = req.session.login;
|
||||
if (!pending) return res.redirect('/auth/login');
|
||||
try {
|
||||
const query = req.originalUrl.slice(req.originalUrl.indexOf('?'));
|
||||
const { user, idToken } = await handleCallback(query, pending);
|
||||
const returnTo = pending.returnTo;
|
||||
// Neue Session-ID nach dem Login (Schutz vor Session Fixation).
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).send('Anmeldung fehlgeschlagen.');
|
||||
req.session.user = user;
|
||||
req.session.idToken = idToken;
|
||||
req.session.save(() => res.redirect(returnTo));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[auth] Callback fehlgeschlagen:', err);
|
||||
delete req.session.login;
|
||||
res.status(401).send('Anmeldung fehlgeschlagen. <a href="/auth/login">Erneut versuchen</a>');
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get('/logout', async (req, res, next) => {
|
||||
try {
|
||||
const url = await buildLogoutUrl(req.session.idToken);
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('hi.portal.sid');
|
||||
res.redirect(url);
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import session from 'express-session';
|
||||
import { db } from './db.js';
|
||||
|
||||
/** Minimaler express-session-Store auf Basis der Portal-SQLite-Datenbank. */
|
||||
export class SqliteStore extends session.Store {
|
||||
private getStmt = db.prepare<[string, number], { data: string }>('SELECT data FROM sessions WHERE sid = ? AND expires > ?');
|
||||
private setStmt = db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires) VALUES (?, ?, ?)');
|
||||
private delStmt = db.prepare('DELETE FROM sessions WHERE sid = ?');
|
||||
private touchStmt = db.prepare('UPDATE sessions SET expires = ? WHERE sid = ?');
|
||||
|
||||
constructor(private ttlMs: number) {
|
||||
super();
|
||||
const cleanup = () => db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());
|
||||
cleanup();
|
||||
setInterval(cleanup, 60 * 60 * 1000).unref();
|
||||
}
|
||||
|
||||
private expiry(sess: session.SessionData): number {
|
||||
const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : NaN;
|
||||
return Number.isFinite(exp) ? exp : Date.now() + this.ttlMs;
|
||||
}
|
||||
|
||||
get(sid: string, cb: (err: unknown, session?: session.SessionData | null) => void): void {
|
||||
try {
|
||||
const row = this.getStmt.get(sid, Date.now());
|
||||
cb(null, row ? JSON.parse(row.data) : null);
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
}
|
||||
|
||||
set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void): void {
|
||||
try {
|
||||
this.setStmt.run(sid, JSON.stringify(sess), this.expiry(sess));
|
||||
cb?.();
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sid: string, cb?: (err?: unknown) => void): void {
|
||||
try {
|
||||
this.delStmt.run(sid);
|
||||
cb?.();
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
touch(sid: string, sess: session.SessionData, cb?: () => void): void {
|
||||
this.touchStmt.run(this.expiry(sess), sid);
|
||||
cb?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'express-session';
|
||||
|
||||
export interface PortalUser {
|
||||
sub: string;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
/** Primärschlüssel des Benutzers in Authentik (für die App-Abfrage), wird nachgeladen. */
|
||||
authentikPk?: number;
|
||||
}
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user?: PortalUser;
|
||||
idToken?: string;
|
||||
login?: { state: string; codeVerifier: string; returnTo: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "../dist/server",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user