/**
* @module http-server
* @description Express.js HTTP server configuration and setup
*
* This module creates and configures the main Express application with:
* - CORS configuration for allowed domains
* - Authentication middleware setup
* - API route mounting for different modules
* - Static file serving
* - Error handling middleware
* - Compression and security headers
*
* The server handles REST API requests for the Commtool Members system
* including organization management, user authentication, and data operations.
*/
// @ts-check
/**
* @import {ExpressRequestAuthorized, ExpressResponse} from './types.js'
*/
import express from 'express';
import cors from 'cors';
import compression from 'compression';
import {readLogger, errorLoggerRead} from './utils/requestLogger.js'
import {checkRoot, setOrgaRoot} from './utils/organizationUtils.js'
import { loadOrganizationDomains, createCorsOriginChecker } from '@commtool/shared-auth';
import { query, UUID2hex } from '@commtool/sql-query';
// Keep merged Secrets, if they are needed elsewhere
export let mergedSecrets
export async function createApp() {
console.log('[http-server] createApp CALLED');
const app = express();
app.set('trust proxy', 1); // trust first proxy
app.use(express.urlencoded({ extended: true, limit: '200mb' }));
app.use(express.json({ limit: '200mb' }));
// Load organization domains from Vault for CORS
const appBaseDomain = process.env.APP_BASE || 'commtool.org';
const domainOrgMap = await loadOrganizationDomains(appBaseDomain);
// Legacy domains to support
const legacyDomains = ['adbmv.de', 'kpe.de'];
var corsOptions = {
origin: createCorsOriginChecker({
domainOrgMap,
additionalDomains: legacyDomains,
builtInDomains: [appBaseDomain, ...legacyDomains]
}),
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'Cache-Control', 'X-Organization', 'credentials', 'X-Fake-User'],
exposedHeaders: ['X-Organization'],
credentials: true,
};
// @ts-ignore
app.use(cors(corsOptions));
app.use(/** @type {any} */ (compression()));
// Dynamic import to ensure it loads after secrets are available
const {configureAuth, makeAuthCheck, createAuthRouter, authStatusEndpoint, registerRedirectUri} = await import('@commtool/shared-auth');
// Configure authentication AFTER CORS is set up
const authConfig = await configureAuth(app, {
secretsPath: process.env.LOGIN_SECRET_PATH,
appSecretsPath: process.env.GIT_SECRET_PATH
});
mergedSecrets = authConfig.appVaultSecrets;
console.log(mergedSecrets)
// Register redirect URI for OIDC login
const appUrl = process.env.APP_URL || mergedSecrets.appUrl;
if (appUrl) {
try {
await registerRedirectUri(appUrl, authConfig);
console.log(`[http-server] ✅ Redirect URI registered for: ${appUrl}`);
} catch (error) {
console.warn(`[http-server] ⚠️ Failed to register redirect URI:`, error.message);
}
}
const {default: member} = await import('./Router/extraApi.js');
const {default: event} = await import('./RouterEvents/eventApi.js');
const {default: location} = await import('./RouterLocation/locationApi.js');
const {filesPublic} = await import('./Router/files.js');
const {default: docu} = await import('./combineAPIDocs.js');
const {default: configFiles} = await import('./Router/apiConfigFile.js');
const authCheckPortal = (req,res,next)=> {
const check = makeAuthCheck({ // No roles required for portal access, just authentication
user: [],
bot: [],
employee: []
});
return check(req,res,next)
}
const authCheck = makeAuthCheck({
user: ['db-user', 'db-admin'], // User braucht eine dieser Org-Rollen
bot: [], // Bot braucht keine Org-Rollen, nur app-bott gruppe
employee: [] // Employee braucht keine Org-Rollen
})
// Define routes AFTER auth middleware is set up
// authCheckBot runs first to handle Bearer tokens, then authCheck for regular auth
// Portal-accessible org root avatar — must be registered BEFORE /api/member/* (which requires db-user)
// Only allows avatar/avatar_white/avatar_black of the session root org, not arbitrary member files
const {getFileWithPrefix, getPublicRailFiles, getPrivateRailFiles} = await import('./Router/files/controller.js');
const {getLanguageFileController} = await import('./Router/languageFile/controller.js');
// Language files for portal (no org role required, just authentication)
app.get(
'/api/member/languages/:app/:filename',
authCheckPortal,
/** @type {any} */ (getLanguageFileController)
);
app.get(
'/api/kpe20/languages/:app/:filename',
authCheckPortal,
/** @type {any} */ (getLanguageFileController)
);
// Rail files for portal (public app rail files + org-scoped private rail files)
app.get(
`/api/member/files/rail/:app/:side/:filename`,
authCheckPortal,
/** @type {any} */ (getPublicRailFiles)
);
app.get(
`/api/kpe20/files/rail/:app/:side/:filename`,
authCheckPortal,
/** @type {any} */ (getPublicRailFiles)
);
app.get(
'/api/member/files/rail/:UID/:side/:filename',
authCheckPortal,
/** @type {any} */ (async (req, res) => {
const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
const orgUID = req.headers['x-organization'] || authReq.user?.organization || authReq.user?.loginOrgaUID;
if (!orgUID || req.params.UID !== orgUID) {
return res.status(403).json({ success: false, message: 'not accessible' });
}
return getPrivateRailFiles(req, res);
})
);
app.get(
'/api/member/files/:UID/:prefix(avatar|avatar_white|avatar_black)/:filename',
authCheckPortal,
/** @type {any} */ (async (req, res) => {
const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
// Org context: prefer x-organization header (set by service worker), fall back to token claim
const orgUID = req.headers['x-organization'] || authReq.user?.organization || authReq.user?.loginOrgaUID;
if (!orgUID || req.params.UID !== orgUID) {
return res.status(403).json({ success: false, message: 'not accessible' });
}
return getFileWithPrefix(req, res);
})
);
/**
* @route /api/kpe20/*
* @group KPE20 API
* @description legacy KPE20 organization and member management API endpoints
* Requires authentication with 'bot' or 'user' role
*/
// @ts-ignore
app.use("/api/kpe20/", authCheck, member);
/**
* @route /api/member/*
* @group Member API
* @description new member organization and member management API endpoints
* Requires authentication with 'bot' or 'user' role
*/
// @ts-ignore
app.use("/api/member/", authCheck, member);
/**
* @route /api/event/*
* @group Event API
* @description Event management and scheduling API endpoints
* Requires authentication with 'bot' or 'user' role
*/
// @ts-ignore
app.use("/api/event/", authCheck, event);
/**
* @route /api/location/*
* @group Location API
* @description Location and venue management API endpoints
* Requires authentication with 'bot' or 'user' role
*/
// @ts-ignore
app.use("/api/location/", authCheck, location);
/**
* @route /api/public/*
* @group Public Files API
* @description Public file serving endpoints (no authentication required)
*/
// @ts-ignore
app.use("/api/public", filesPublic);
/**
* @route /api/docu/*
* @group Documentation API
* @description API documentation and OpenAPI specification endpoints
*/
// @ts-ignore
app.use("/api/docu", docu);
/**
* @route /api/configfiles/*
* @group Configuration Files API
* @description Configuration file management endpoints (upload, download, list, delete)
* Requires admin authentication
*/
// @ts-ignore
app.use("/api/configfiles", configFiles);
// get the config of the current organisation
/**
* @route GET /api/orga/config/:app
* @group Organization API
* @description Get the organization configuration for the current session
* This retrieves the organizational configuration based on the session root
* @param {string} app - Application identifier (must match configured apps)
*
* @example
* GET /api/orga/config/member
* Response: {"success": true, "result": {...organization config...}}
* @returns {Object} Response object
* @property {boolean} .success - Whether the config was successfully retrieved
* @property {object} .result - Organization configuration data
*/
// @ts-ignore
app.get(`/api/orga/config/:app`,authCheckPortal,checkRoot,readLogger,async (req, res, next)=>
{
try
{
const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
const {user,config}=await setOrgaRoot(authReq.session.root,authReq)
if(!config)
return res.status(400).json({success:false,message:'no valid orga root found'})
// Attach public org metadata (avatar, name) so clients don't need a
// separate /member/group/:UID call (which requires db-user role)
if (config.UID && !config.orgaData) {
try {
const rows = await query(
`SELECT Member.Display, Member.Data
FROM ObjectBase
INNER JOIN Member ON Member.UID = ObjectBase.UID
WHERE ObjectBase.UID = ? AND ObjectBase.Type IN ('group', 'ggroup')`,
[UUID2hex(config.UID)],
{ cast: ['json'] }
);
if (rows.length) {
const { avatar, avatar_white, name, shortName, country } = rows[0].Data || {};
config.orgaData = { avatar, avatar_white, name, shortName, country };
}
} catch (orgaErr) {
errorLoggerRead(orgaErr);
}
}
return res.json({success:true,result:config})
}
catch(e)
{
errorLoggerRead(e)
}
})
/**
* @route GET /api/auth/status
* @returns {object} Authentication status information
*/
// @ts-ignore
app.get("/api/auth/status", authStatusEndpoint);
/**
* @route GET /api/auth/user
* @group Authentication API
* @description Get current user information
* Requires authentication with 'bot' or 'user' role
* @returns {object} User information and permissions
*/
// @ts-ignore
app.get("/api/auth/user", authCheck, authStatusEndpoint);
/**
* @route /auth/*
* @group Authentication Routes
* @description User authentication routes (login, logout, callback)
*/
// @ts-ignore
app.use("/auth", createAuthRouter({
redirectAfterLogout: '/',
appSecrets: mergedSecrets
}));
/**
* @route GET /
* @group Root Route
* @description Welcome message at the root endpoint
* @returns {object} Welcome message
* can be used to check if the API is reachable (Health Check)
*/
// @ts-ignore
app.get("/", (req, res) => {
res.json({ message: "Welcome Commtool Members Api" });
});
/**
* @route GET /api/branding
* @group Branding API
* @description Get branding configuration for the current organization
* @param {string} orgUID - Organization identifier
* @returns {object} Branding configuration including favicon URL and app title
*/
// @ts-ignore
app.get("/api/branding", async (req, res) => {
try {
const { orgUID } = req.query;
if (!orgUID) {
return res.status(400).json({ error: 'orgUID parameter is required' });
}
// TODO: Load organization branding config from database/S3
// const orgBranding = await loadOrgConfig(orgUID);
// const { faviconUrl, appTitle } = orgBranding;
const faviconUrl = null; // Placeholder
const appTitle = null; // Placeholder
if (!faviconUrl && !appTitle) {
return res.status(404).json({ error: 'No branding configured for this organization' });
}
// Set cache headers for better performance
res.set({
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
'Content-Type': 'application/json'
});
res.json({
faviconUrl: faviconUrl || '/default-favicon.svg',
appTitle: appTitle || 'App'
});
} catch (error) {
console.error('Error loading branding:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
// Start crons (if they don't depend on the app being listened to yet)
// crons();
// forEveryOrga(triggerQueue);
console.log('[http-server] createApp RETURNING configured app');
return app;
}