const express = require('express'); const http = require('http'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const common = require('./common'); const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth'); const pages = require('./web/routes'); const registerAuthRoutes = require('./web/routes/auth'); const registerAdminPagesRoutes = require('./web/routes/admin-pages'); const registerAdminAccountRoutes = require('./web/routes/admin-account'); const registerAdminUsersRoutes = require('./web/routes/admin-users'); const registerAdminManageRoutes = require('./web/routes/admin-manage'); const registerAdminScreenCommandRoutes = require('./web/routes/admin-client-commands'); const registerAdminContentRoutes = require('./web/routes/admin-content'); const { createWebBootstrap } = require('./web/bootstrap'); const { requirePermission } = require('./rbac'); const rbacData = require('./web/rbac-data'); const { createPlayerActionService } = require('./web/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check'); const { createSessionService } = require('./web/session'); const { formatDashboardDate, readArrayField, parseDateTimeLocal, parseTimeLocal, normalizeScheduleMode, getAuditUserId, getCanvasSignature, fetchPlaylistCanvasSignature, fetchScreensByPlaylistId, fetchScreensBySlideId, fetchScreensByTemplateId, fetchOrderedPlaylistSlides, redirectAfterSave } = require('./web/helpers'); const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, ''); const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws'); const SESSION_COOKIE_NAME = 'digital_signage_session'; const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000; async function start() { const app = express(); const server = http.createServer(app); const pool = common.createPool(); const PORT = Number(process.env.WEB_PORT || 3000); const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); const ASSET_DIR = path.join(__dirname, 'web', 'public'); const playerActionService = createPlayerActionService({ common: common, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL }); const notifyPlayerScreens = function (slugs, commandOrPayload) { const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { return String(slug || '').trim(); }).filter(Boolean))); if (!uniqueSlugs.length) { return Promise.resolve(0); } return Promise.allSettled(uniqueSlugs.map(function (slug) { return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh'); })).then(function (results) { return results.filter(function (result) { return result.status === 'fulfilled'; }).length; }); }; const webBootstrap = createWebBootstrap({ pool: pool, common: common, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, uploadDir: UPLOAD_DIR, dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens }); const upload = webBootstrap.upload; const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; const broadcastDashboardState = webBootstrap.broadcastDashboardState; const sessionService = createSessionService({ sessionCookieName: SESSION_COOKIE_NAME, sessionMaxAgeMs: SESSION_MAX_AGE_MS, hashSessionToken: hashSessionToken, createSessionToken: createSessionToken }); const parseCookies = sessionService.parseCookies; const clearSessionCookie = sessionService.clearSessionCookie; const setSessionCookie = sessionService.setSessionCookie; const loadCurrentUser = sessionService.loadCurrentUser; const createUserSession = sessionService.createUserSession; const requireAuth = sessionService.requireAuth; app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/assets', express.static(ASSET_DIR)); app.use('/uploads', express.static(UPLOAD_DIR)); app.use(async function (req, _res, next) { try { req.currentUser = await loadCurrentUser(pool, req); next(); } catch (error) { next(error); } }); app.use('/admin', requireAuth); registerAuthRoutes(app, { pool: pool, pages: pages, createUserSession: createUserSession, setSessionCookie: setSessionCookie, clearSessionCookie: clearSessionCookie, parseCookies: parseCookies, hashSessionToken: hashSessionToken, verifyPassword: verifyPassword, sessionCookieName: SESSION_COOKIE_NAME }); registerAdminPagesRoutes(app, { pool: pool, common: common, pages: pages, requirePermission: requirePermission, buildDashboardState: webBootstrap.buildDashboardState }); registerAdminAccountRoutes(app, { pool: pool, pages: pages, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, verifyPassword: verifyPassword, hashPassword: hashPassword, createUserSession: createUserSession, setSessionCookie: setSessionCookie }); registerAdminUsersRoutes(app, { pool: pool, pages: pages, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, hashPassword: hashPassword, readArrayField: readArrayField, rbacData: rbacData, requirePermission: requirePermission }); registerAdminManageRoutes(app, { pool: pool, common: common, pages: pages, fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, getCanvasSignature: getCanvasSignature, normalizeScheduleMode: normalizeScheduleMode, parseDateTimeLocal: parseDateTimeLocal, parseTimeLocal: parseTimeLocal, readArrayField: readArrayField, getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage, getScreenConnections: playerActionService.getScreenConnections, getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage, forwardPlayerCommand: playerActionService.forwardPlayerCommand, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, requirePermission: requirePermission }); registerAdminScreenCommandRoutes(app, { pool: pool, forwardPlayerCommand: playerActionService.forwardPlayerCommand, getScreenConnections: playerActionService.getScreenConnections, isClientNameAvailable: isClientNameAvailable, withClientNameReservation: withClientNameReservation, requirePermission: requirePermission }); const registerAdminRbacRoutes = require('./web/routes/admin-rbac'); registerAdminRbacRoutes(app, { pool: pool, pages: pages, getAuditUserId: getAuditUserId, rbacData: rbacData, readArrayField: readArrayField, permissions: require('./rbac').PERMISSIONS, normalizePermissionKeys: require('./rbac').normalizePermissionKeys, requirePermission: requirePermission }); registerAdminContentRoutes(app, { pool: pool, common: common, pages: pages, upload: upload, uploadDir: UPLOAD_DIR, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, requirePermission: requirePermission }); app.use(function (req, res, next) { const pathName = String(req.originalUrl || ''); const wantsHtml = !pathName.startsWith('/api/') && (!req.accepts || req.accepts('html')); if (!wantsHtml) { return next(); } return res.status(404).send(pages.renderErrorPage({ statusCode: 404, title: 'Not found', errorTitle: 'Oops! Page not found.', message: 'We could not find the page you were looking for.', backUrl: req.currentUser ? '/admin' : '/login', backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in' }, req.currentUser)); }); app.use(function (error, req, res, _next) { console.error(error); const statusCode = Number(error && (error.statusCode || error.status)) || 500; const wantsHtml = !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 : 'You do not have permission to access this area.') : 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, errorTitle: title, message: message, detail: statusCode >= 500 ? 'The server could not complete the request.' : '', backUrl: req.currentUser ? '/admin' : '/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')); }); // Ensure schema and mirror uploads before the web service starts handling traffic. await common.ensureSchema(pool); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) { console.warn('Unable to sync existing uploads to player:', error); }); webBootstrap.installDashboardWebsocket(server, loadCurrentUser); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); } module.exports = { start }; if (require.main === module) { start().catch(function (error) { console.error(error); process.exit(1); }); }