// Main web application bootstrap, route wiring, and lifecycle setup. const express = require('express'); const http = require('http'); const multer = require('multer'); const fs = require('fs'); const crypto = require('crypto'); const common = require('#src/common'); const { verifyPassword, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth'); const pages = require('#src/web/pages/index'); const registerMiddleware = require('#src/web/middleware'); const registerNotFoundHandler = require('#src/web/middleware/not-found'); const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue'); const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks/index'); const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh'); const { captureSlideThumbnail } = require('#src/web/lib/media/index'); const { createWebBootstrap } = require('#src/web/bootstrap'); const { registerRoutes } = require('#src/web/routes/register'); const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens'); const { initializeWebServer } = require('#src/web/lib/web-startup'); const { createWebConfig } = require('#src/web/lib/config'); const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { rbacData } = require('#src/web/lib/auth/index'); const { createPlayerActionService } = require('#src/web/lib/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index'); const { createBetterAuth, ensureBetterAuthClientIp, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth'); const { fetchAppSettings } = require('#src/data/app-settings'); const { sendAccountEmail } = require('#src/data/mailer'); const { recordRequestAuditEvent } = require('#src/data/audit-log'); const { hasAnyPermission } = require('#src/rbac'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); const { getMediaStorageId } = require('#src/media-storage'); const { formatDashboardDate, readArrayField, parseDateTimeLocal, parseTimeLocal, normalizeScheduleMode, getAuditUserId, getCanvasSignature, fetchPlaylistCanvasId, fetchScreensByPlaylistId, fetchScreensBySlideId, fetchScreensByTemplateId, fetchOrderedPlaylistSlides, redirectAfterSave } = require('#src/web/lib/helpers'); async function start() { // Core app and filesystem setup. const app = express(); const server = http.createServer(app); const pool = common.createPool(); const webConfig = createWebConfig(); fs.mkdirSync(webConfig.mediaDir, { recursive: true }); await getMediaStorageId(webConfig.mediaDir); await ensureFontLibrary(webConfig.mediaDir); await common.ensureSchema(pool, { mediaDir: webConfig.mediaDir }); await common.bootstrapDatabase(pool); const betterAuthService = await createBetterAuth(pool, { secret: process.env.AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL, sessionCookieName: webConfig.sessionCookieName, sendAccountEmail: function (settings, message) { return sendAccountEmail(settings, message); } }); const betterAuth = betterAuthService.auth; const toNodeHandler = betterAuthService.toNodeHandler; const fromNodeHeaders = betterAuthService.fromNodeHeaders; const betterAuthHandler = toNodeHandler(betterAuth); // Background services and request forwarding helpers. const backgroundTaskQueue = createBackgroundTaskQueue({ pool: pool, maxConcurrent: 1 }); const dataSourceTasks = createDataSourceTaskService({ pool: pool, common: common, backgroundTaskQueue: backgroundTaskQueue }); const playerActionService = createPlayerActionService({ pool: pool, common: common, playerInternalBaseUrl: webConfig.playerInternalUrl, bridgeInternalBaseUrl: webConfig.bridgeInternalUrl }); const notifyPlayerScreens = createNotifyPlayerScreens( playerActionService.forwardPlayerCommand, playerActionService.getScreenConnections, playerActionService.forwardPlayerCommandToBaseUrl, playerActionService.resolvePlayerBaseUrlForPublicUrl, playerActionService.forwardPlayerCommandToDevice ); // Centralized dashboard/player bootstrap. const webBootstrap = createWebBootstrap({ pool: pool, common: common, playerInternalBaseUrl: webConfig.playerInternalUrl, bridgeInternalBaseUrl: webConfig.bridgeInternalUrl, uploadDir: webConfig.uploadsDir, formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue, hasAnyPermission: hasAnyPermission, }); const upload = webBootstrap.upload; const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; // Background task initialization. // Session and auth helpers for route registration. const broadcastDashboardState = webBootstrap.broadcastDashboardState; const sessionService = createSessionService({ sessionCookieName: webConfig.sessionCookieName }); const parseCookies = sessionService.parseCookies; const clearSessionCookie = sessionService.clearSessionCookie; const setAuthMessageCookie = sessionService.setAuthMessageCookie; const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie; const loadCurrentUser = async function (pool, req) { ensureBetterAuthClientIp(req); const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) }); if (!authSession || !authSession.session || !authSession.session.token) return null; req.betterAuthSession = authSession; return sessionService.loadCurrentUser(pool, req, authSession.session.token); }; const requireAuth = sessionService.requireAuth; const startViewAsUser = sessionService.startViewAsUser; const stopViewAsUser = sessionService.stopViewAsUser; app.all('/api/auth/*splat', async function (req, res, next) { try { const currentUser = await loadCurrentUser(pool, req); const policyError = getRoleTwoFactorAuthError(currentUser, req.path); if (policyError) return res.status(403).json(policyError); const authPath = String(req.path || ''); if (currentUser && currentUser.isViewingAs && (authPath.startsWith('/api/auth/two-factor/') || authPath.startsWith('/api/auth/passkey/'))) { return res.status(403).json({ message: 'Exit View as before changing authentication settings.' }); } return betterAuthHandler(req, res, next); } catch (error) { next(error); } }); // Middleware and route registration. registerMiddleware(app, { pool: pool, pages: pages, loadCurrentUser: loadCurrentUser, requireAuth: requireAuth, MEDIA_DIR: webConfig.mediaDir, UPLOADS_DIR: webConfig.uploadsDir, THUMBNAILS_DIR: webConfig.thumbnailsDir, ASSET_DIR: webConfig.assetDir }); registerRoutes(app, { pool: pool, betterAuth: betterAuth, fromNodeHeaders: fromNodeHeaders, common: common, pages: pages, upload: upload, mediaDir: webConfig.mediaDir, uploadDir: webConfig.uploadsDir, startViewAsUser: startViewAsUser, stopViewAsUser: stopViewAsUser, clearSessionCookie: clearSessionCookie, setAuthMessageCookie: setAuthMessageCookie, consumeAuthMessageCookie: consumeAuthMessageCookie, parseCookies: parseCookies, hashSessionToken: hashSessionToken, verifyPassword: verifyPassword, sessionCookieName: webConfig.sessionCookieName, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, getRequestOrigin: getRequestOrigin, recordRequestAuditEvent: recordRequestAuditEvent, sendAccountEmail: function (settings, message) { return sendAccountEmail(settings, message); }, createOneTimeToken: createOneTimeToken, hashPassword: hashPassword, validatePasswordStrength: validatePasswordStrength, readArrayField: readArrayField, rbacData: rbacData, fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, fetchPlaylistCanvasId: fetchPlaylistCanvasId, getCanvasSignature: getCanvasSignature, normalizeScheduleMode: normalizeScheduleMode, parseDateTimeLocal: parseDateTimeLocal, parseTimeLocal: parseTimeLocal, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, dataSourceTasks: dataSourceTasks, playerActionService: playerActionService, playerInternalBaseUrl: webConfig.playerInternalUrl, isClientNameAvailable: isClientNameAvailable, findAvailableClientName: common.findAvailableClientName, withClientNameReservation: withClientNameReservation, requirePermission: function (permissionKey, options) { return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options)); }, hasAnyPermission: hasAnyPermission, backgroundTaskQueue: backgroundTaskQueue, uploadSyncService: webBootstrap.uploadSyncService, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, removeUnusedUploadFiles: removeUnusedUploadFiles, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, buildDashboardState: webBootstrap.buildDashboardState }); registerNotFoundHandler(app); app.use(function (error, req, res, _next) { const statusCode = Number(error && (error.statusCode || error.status)) || 500; if (statusCode >= 500) { console.error(error); } else if (statusCode >= 400 && statusCode !== 404) { console.warn(error && error.message ? error.message : 'Request failed.', { statusCode: statusCode }); } const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest'; const wantsHtml = !isXhr && !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html')); if (wantsHtml && pages.renderErrorPage) { const isPermissionError = statusCode === 403; const message = isPermissionError ? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE) : statusCode === 404 || statusCode >= 500 ? '' : String(error && error.message ? error.message : 'An unexpected error occurred.'); const title = isPermissionError ? 'Access denied' : statusCode === 404 ? 'Not found' : 'Something went wrong'; return res.status(statusCode).send(pages.renderErrorPage({ statusCode: statusCode, title: title, message: message, detail: statusCode >= 500 ? 'The server could not complete the request.' : '', backUrl: req.currentUser ? '/dashboard' : '/login', backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in' }, req.currentUser)); } res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error')); }); // Bootstrap and startup tasks. await initializeWebServer({ common: common, pool: pool, databaseInitialized: true, mediaDir: webConfig.mediaDir, backgroundTaskQueue: backgroundTaskQueue, webBootstrap: webBootstrap, notifyPlayerScreens: notifyPlayerScreens, forwardPlayerCommandToDevice: playerActionService.forwardPlayerCommandToDevice, loadCurrentUser: loadCurrentUser, initializeBackgroundTasks: initializeBackgroundTasks, captureSlideThumbnail: captureSlideThumbnail, server: server, webBaseUrl: webConfig.webInternalUrl, localPlayerInternalUrl: webConfig.playerInternalUrl, dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs }); server.listen(webConfig.port, function () { console.log(`Pulse Signage app listening on port ${webConfig.port}`); }); } module.exports = { start }; if (require.main === module) { start().catch(function (error) { console.error(error); process.exit(1); }); }