// @ts-check
/**
* @import {ExpressRequestAuthorized, ExpressResponse} from './../../types.js'
*/
/**
* OrgaSettings Controller
*
* HTTP layer for domain and mail/SMTP settings endpoints.
* All endpoints require the user to be an organisation admin (checkAdmin middleware).
*/
import Busboy from 'busboy';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import * as service from './service.js';
import * as registryService from './registryService.js';
/**
* Apps und Domains laufen über `registryService`; `service.js` bleibt als
* **Vault-Adapter** im Spiel (Lesefallback während der Umstellung) und für
* **Mail**.
*
* Die Validierung liegt bewusst auch im `registryService`: sie muss gegen
* denselben Bestand prüfen, in den sie schreibt. Eine Fassung, die noch den
* Vault-Bestand las, sah in der Datenbank längst aufgelöste Domains nicht — eine
* Domain ließ sich damit zweimal vergeben.
*
* `registryService` entscheidet selbst über `REGISTRY_READ_MODE`, ob er die DB
* oder (bei `vault`) den Vault bedient. Die HTTP-Oberfläche bleibt in beiden
* Fällen identisch — genau das macht `admin` zum Abnahmewerkzeug.
*/
const ALLOWED_ICON_MIME = new Set(['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif', 'image/webp']);
const isHttpsUrl = (value) => {
try {
const url = new URL(value);
return url.protocol === 'https:';
} catch {
return false;
}
};
const EXTERNAL_KEY_RE = /^ext-[a-z0-9]+(?:-[a-z0-9]+)*$/;
const validateAppConfig = (appId, appConfig) => {
if (!appConfig || typeof appConfig !== 'object' || Array.isArray(appConfig))
return `App "${appId}" must be an object.`;
if (appConfig.roles !== undefined && !Array.isArray(appConfig.roles))
return `App "${appId}" field "roles" must be an array.`;
const isExternal = appConfig.external === true;
const hasDomain = typeof appConfig.domain === 'string' && appConfig.domain.trim() !== '';
const hasUrl = typeof appConfig.url === 'string' && appConfig.url.trim() !== '';
if (isExternal) {
// Strict key validation only for org-created external links
if (!EXTERNAL_KEY_RE.test(appId))
return `External app key "${appId}" must start with "ext-" and contain only lowercase letters, digits and hyphens.`;
if (!appConfig.title || typeof appConfig.title !== 'string' || !appConfig.title.trim())
return `External app "${appId}" requires a non-empty title.`;
if (hasDomain)
return `External app "${appId}" must not define "domain".`;
if (!hasUrl)
return `External app "${appId}" requires a "url".`;
if (!isHttpsUrl(appConfig.url.trim()))
return `External app "${appId}" must use an https:// URL.`;
return null;
}
// CommTool-managed apps: only reject if both url and domain are set (ambiguous)
if (hasUrl && hasDomain)
return `App "${appId}" must not define both "url" and "domain".`;
return null;
};
// ── Domains ───────────────────────────────────────────────────────────────────
/**
* GET /kpe20/orgaSettings/domains
* Returns the domain settings for the current organisation.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const getDomainsController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const domains = await registryService.getOrgDomains(orgId);
res.json({ success: true, result: domains });
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load domain settings.' });
}
};
/**
* PUT /kpe20/orgaSettings/domains
* Validates and saves the domain settings for the current organisation.
*
* Request body: { [domain: string]: "internal" | "external" }
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const saveDomainsController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const domains = req.body;
if (!domains || typeof domains !== 'object' || Array.isArray(domains))
return res.status(400).json({ success: false, message: 'Request body must be an object.' });
// Bewusst noch gegen Vault validiert (siehe Dateikopf): der Bestand
// liegt in der Migrationsphase in beiden Quellen.
const errors = await registryService.validateDomains(domains, orgId);
if (errors.length > 0)
return res.status(400).json({ success: false, errors });
await registryService.saveOrgDomains(orgId, domains);
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to save domain settings.' });
}
};
// ── Mail settings ─────────────────────────────────────────────────────────────
/**
* GET /kpe20/orgaSettings/mail
* Returns the SMTP settings for the current organisation (password masked).
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const getMailController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const settings = await service.getMailSettings(orgId);
res.json({ success: true, result: settings });
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load mail settings.' });
}
};
/**
* PUT /kpe20/orgaSettings/mail
* Saves the SMTP settings for the current organisation.
*
* Request body: { host, port, user, password, userName?, from?, secure? }
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
// ── Apps ─────────────────────────────────────────────────────────────────────
/**
* GET /kpe20/orgaSettings/apps
* Returns the app registry for the current organisation.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const getAppsController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const apps = await registryService.getOrgApps(orgId);
res.json({ success: true, result: apps });
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load app settings.' });
}
};
/**
* PUT /kpe20/orgaSettings/apps
* Saves the app registry for the current organisation.
*
* Request body: { [appId]: { domain, roles, title, description?, port? } }
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const saveAppsController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const apps = req.body;
if (!apps || typeof apps !== 'object' || Array.isArray(apps))
return res.status(400).json({ success: false, message: 'Request body must be an object.' });
const validationErrors = Object.entries(apps)
.map(([appId, appConfig]) => validateAppConfig(appId, appConfig))
.filter(Boolean);
if (validationErrors.length > 0)
return res.status(400).json({ success: false, errors: validationErrors });
await registryService.saveOrgApps(orgId, apps);
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to save app settings.' });
}
};
/**
* PUT /kpe20/orgaSettings/apps/:appId
* Speichert **einen** App-Eintrag.
*
* Der Edit-Dialog bearbeitet genau eine Zeile — also wird auch nur diese
* geschrieben. Über `POST /apps` (ganze Map) wären es bei fünf Apps fünf
* sequenzielle Schreibvorgänge, obwohl sich eine Zeile geändert hat.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const saveAppController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const appId = req.params.appId;
const config = req.body;
const error = validateAppConfig(appId, config);
if (error)
return res.status(400).json({ success: false, errors: [error] });
await registryService.saveOrgApp(orgId, appId, config);
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to save app settings.' });
}
};
/**
* DELETE /kpe20/orgaSettings/apps/:appId
* Entfernt **einen** App-Eintrag samt seiner Links.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const deleteAppController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
await registryService.deleteOrgApp(orgId, req.params.appId);
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to delete app settings.' });
}
};
/**
* POST /kpe20/orgaSettings/apps/:appId/icon
* Upload an icon image for a specific app to the public bucket.
* Stores the resulting public URL in Vault under apps[appId].icon.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const uploadAppIconController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const { appId } = req.params;
const iconUrl = await new Promise((resolve, reject) => {
const busboy = Busboy({ headers: req.headers });
/** @type {Promise<string>|null} */
let uploadPromise = null;
busboy.on('file', (fieldname, stream, info) => {
const { mimeType } = info;
if (!ALLOWED_ICON_MIME.has(mimeType)) {
stream.resume();
reject(new Error(`File type "${mimeType}" is not allowed. Use PNG, JPG, SVG, GIF or WebP.`));
return;
}
// Store the promise — busboy may emit 'finish' before the upload completes
uploadPromise = registryService.uploadOrgAppIcon(orgId, appId, stream, mimeType);
});
busboy.on('finish', () => {
if (!uploadPromise) { reject(new Error('No file received.')); return; }
uploadPromise.then(resolve).catch(reject);
});
busboy.on('error', reject);
// @ts-ignore — req is a Node.js IncomingMessage at runtime; TypeScript typedef is narrower
req.pipe(busboy);
});
res.json({ success: true, iconUrl });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: e.message ?? 'Failed to upload icon.' });
}
};
export const saveMailController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const settings = req.body;
// Basic input validation
const portNum = parseInt(settings?.port, 10);
if (!settings?.host || typeof settings.host !== 'string' || !settings.host.trim())
return res.status(400).json({ success: false, message: 'Field "host" is required.' });
if (isNaN(portNum) || portNum < 1 || portNum > 65535)
return res.status(400).json({ success: false, message: 'Field "port" must be a number between 1 and 65535.' });
if (!settings?.user || typeof settings.user !== 'string' || !settings.user.trim())
return res.status(400).json({ success: false, message: 'Field "user" is required.' });
// Sanitise numeric field
settings.port = portNum;
await service.saveMailSettings(orgId, settings);
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: `Failed to save mail settings: ${e.message}` });
}
};
// ── Deployments (die eigene Wahl der Organisation) ────────────────────────────
//
// Die Organisation bestimmt hier **ihr eigenes** Release und ihren
// Backend-Zweig — nicht den Katalog. Was wählbar ist, gibt der Vertrag vor
// (`AppBackendBranch`, Importpfad `saveBackendBranches`); was ausgeliefert wird,
// entscheidet der Zeiger (`AppRelease.Current`, CommTool). Diese drei Endpunkte
// können deshalb keinen Katalog und keinen Zeiger verändern — es gibt keinen
// gemeinsamen Schreibweg, und das ist die Absicht (§6.9).
/**
* GET /kpe20/orgaSettings/deployments
* Katalog und aktuelle Wahl für alle Apps dieser Organisation.
*
* Die Antwort liefert die **Namen** der wählbaren Zweige und Versionen, keine
* Backend-URLs — ein Kunden-Admin soll die Infrastruktur-Hosts nicht auszählen.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const getDeploymentsController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const catalog = await registryService.getOrgCatalog(orgId);
res.json({ success: true, result: catalog });
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load deployments.' });
}
};
/**
* GET /kpe20/orgaSettings/app-offers
* Das Angebot für den „Add app"-Dialog: je Produkt die Anzeige-Vorgaben und
* seine Umgebungen (Zweig-Namen) — **ohne** die Ziel-URLs der Zweige.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const getAppOffersController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const offers = await registryService.getAppOffers(orgId);
// Nur die Anzeige-Felder durchreichen. Die Backend-Ziele (`api`,
// `baseUrl`, …) sind in `getAppOffers` bereits entfernt — der Kunde wählt
// den Namen der Umgebung, nicht den Host.
const products = {};
for (const [product, offer] of Object.entries(offers.products ?? {})) {
products[product] = {
title: offer.title ?? null,
description: offer.description ?? null,
icon: offer.icon ?? null,
roles: Array.isArray(offer.roles) ? offer.roles : [],
domainPrefix: offer.domain ?? null,
branches: offer.branches ?? [],
environments: offer.environments ?? {},
};
}
res.json({
success: true,
result: {
products,
domains: offers.domains ?? [],
platformDomain: offers.platformDomain ?? '',
},
});
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load app offers.' });
}
};
/**
* PUT /kpe20/orgaSettings/deployments/:appKey
* Setzt Version und Backend-Zweig für die eigene Organisation.
*
* Body: `{ branch: "prod", version?: "5.9.0" | null }`
*
* Die Prüfung gegen Katalog und Releases passiert im Service
* (`saveOrgDeployment`) und **nicht** hier: beide Regeln brauchen einen
* Datenbank-Blick, den ein Controller nicht haben soll, und dort ist auch die
* Meldung formuliert, die der Admin zu sehen bekommt.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const saveDeploymentController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const { appKey } = req.params;
const { branch, version = null } = req.body ?? {};
if (typeof branch !== 'string' || !branch.trim())
return res.status(400).json({ success: false, message: 'Field "branch" is required.' });
if (version !== null && typeof version !== 'string')
return res.status(400).json({ success: false, message: 'Field "version" must be a string or null.' });
const deployment = await registryService.saveOrgDeployment({ appKey, orgId, version, branch });
res.json({ success: true, result: deployment });
} catch (e) {
// Ein unbekannter Zweig oder ein unbekanntes Release ist ein Fehler des
// Aufrufers, kein Serverfehler — die Meldung nennt die gültigen Werte.
const isInputError = /^Unknown |^Invalid /.test(e?.message ?? '');
if (isInputError) {
return res.status(400).json({ success: false, message: e.message });
}
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to save deployment.' });
}
};
/**
* DELETE /kpe20/orgaSettings/deployments/:appKey
* Nimmt die Organisation aus der Zuordnung — ab hier folgt sie wieder dem
* Zeiger und den Backends des Releases.
*
* @param {ExpressRequestAuthorized} req
* @param {ExpressResponse} res
*/
export const clearDeploymentController = async (req, res) => {
try {
const orgId = req.session.root;
if (!orgId)
return res.status(400).json({ success: false, message: 'No organisation in session.' });
const removed = await registryService.clearOrgDeployment(req.params.appKey, orgId);
res.json({ success: true, removed });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to clear deployment.' });
}
};