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/pages'); 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 registerAdminDataSourceRoutes = require('./web/routes/data-sources'); const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue'); const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails'); const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); const { createWebBootstrap } = require('./web/bootstrap'); const { requirePermission } = require('./rbac'); const rbacData = require('./web/lib/rbac-data'); const { createPlayerActionService } = require('./web/lib/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check'); const { createSessionService } = require('./web/lib/session'); const { hasAnyPermission } = require('./rbac'); const { formatDashboardDate, readArrayField, parseDateTimeLocal, parseTimeLocal, normalizeScheduleMode, getAuditUserId, getCanvasSignature, fetchPlaylistCanvasSignature, fetchScreensByPlaylistId, fetchScreensBySlideId, fetchScreensByTemplateId, fetchOrderedPlaylistSlides, redirectAfterSave } = require('./web/lib/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 MEDIA_DIR = path.join(__dirname, '..', 'media'); const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads'); const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails'); const ASSET_DIR = path.join(__dirname, 'web', 'public'); const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, ''); const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250)); const backgroundTaskQueue = createBackgroundTaskQueue({ pool: pool, maxConcurrent: 1 }); 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: UPLOADS_DIR, dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), 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 collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; const runMediaSyncTask = webBootstrap.runMediaSyncTask; 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; backgroundTaskQueue.setTaskHandler('media-sync', function (task) { return runMediaSyncTask(task && task.payload ? task.payload : task); }); backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) { const payload = task && task.payload ? task.payload : {}; const sourceType = String(payload.sourceType || '').trim(); const sourceId = Number(payload.sourceId || 0); if (sourceType === 'api-source') { const apiSource = await common.fetchApiSourceById(pool, sourceId); if (!apiSource) { throw new Error('API source not found.'); } return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null); } if (sourceType === 'rss-feed') { const rssFeed = await common.fetchRssFeedById(pool, sourceId); if (!rssFeed) { throw new Error('RSS feed not found.'); } return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); } throw new Error('Unsupported data source refresh task.'); }); backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) { const payload = task && task.payload ? task.payload : {}; const slideId = Number(payload.slideId || 0); if (!Number.isFinite(slideId) || slideId <= 0) { throw new Error('Slide id is required.'); } return captureSlideThumbnail({ pool: pool, common: common, mediaDir: MEDIA_DIR, baseUrl: PLAYER_INTERNAL_BASE_URL, slideId: slideId, previousThumbnailPath: payload.previousThumbnailPath || null }); }); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/assets', express.static(ASSET_DIR)); app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist'))); app.use('/media', express.static(MEDIA_DIR)); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); fs.mkdirSync(THUMBNAILS_DIR, { recursive: true }); app.use(async function (req, _res, next) { try { req.currentUser = await loadCurrentUser(pool, req); next(); } catch (error) { next(error); } }); app.use(function (req, res, next) { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { return next(); } return requireAuth(req, res, next); }); 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, common: common, pages: pages, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, verifyPassword: verifyPassword, hashPassword: hashPassword, createUserSession: createUserSession, setSessionCookie: setSessionCookie }); registerAdminUsersRoutes(app, { pool: pool, common: common, 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, common: common, 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: UPLOADS_DIR, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, removeUnusedUploadFiles: removeUnusedUploadFiles, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, backgroundTaskQueue: backgroundTaskQueue, hasAnyPermission: hasAnyPermission, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, requirePermission: requirePermission }); registerAdminDataSourceRoutes(app, { pool: pool, common: common, pages: pages, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, fetchRssFeedItems: common.fetchRssFeedItems, backgroundTaskQueue: backgroundTaskQueue, requirePermission: requirePermission }); registerAdminSettingsRoutes(app, { pages: pages, backgroundTaskQueue: backgroundTaskQueue }); 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 ? '/dashboard' : '/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 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 : '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 ? '/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')); }); // Ensure schema and mirror media before the web service starts handling traffic. await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); await backgroundTaskQueue.initialize(); async function syncRecurringRefreshes() { const apiSourcesData = await common.fetchApiSourcesData(pool); (apiSourcesData.apiSources || []).forEach(function (apiSource) { backgroundTaskQueue.registerRecurringTask({ key: 'api-source-refresh:' + Number(apiSource.id), title: 'API source refresh', category: 'data-source', intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), metadata: { sourceType: 'api-source', sourceId: Number(apiSource.id), sourceName: apiSource.name }, run: function () { return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); } }); }); const rssFeedsData = await common.fetchRssFeedsData(pool); (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { backgroundTaskQueue.registerRecurringTask({ key: 'rss-feed-refresh:' + Number(rssFeed.id), title: 'RSS feed refresh', category: 'data-source', intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit), metadata: { sourceType: 'rss-feed', sourceId: Number(rssFeed.id), sourceName: rssFeed.name }, run: function () { return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); } }); }); } async function registerRecurringMaintenanceTasks() { async function runUnusedUploadSweep() { const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR); if (!uploadPaths.length) { return; } await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths); } backgroundTaskQueue.registerRecurringTask({ key: 'unused-upload-sweep', title: 'Unused upload sweep', category: 'media-sync', intervalMs: 24 * 60 * 60 * 1000, metadata: { uploadDir: UPLOADS_DIR }, run: runUnusedUploadSweep }); } async function scheduleInitialDataSourceRefreshes() { const apiSourcesData = await common.fetchApiSourcesData(pool); const rssFeedsData = await common.fetchRssFeedsData(pool); const startupSources = []; (apiSourcesData.apiSources || []).forEach(function (apiSource) { startupSources.push({ type: 'api-source', id: Number(apiSource.id), name: apiSource.name, run: function () { return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); } }); }); (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { startupSources.push({ type: 'rss-feed', id: Number(rssFeed.id), name: rssFeed.name, run: function () { return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); } }); }); startupSources.forEach(function (source, index) { const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS; setTimeout(function () { backgroundTaskQueue.enqueueTask({ key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(), title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', category: 'data-source', taskType: 'data-source-refresh', metadata: { sourceType: source.type, sourceId: source.id, sourceName: source.name, startupRefresh: true }, payload: { sourceType: source.type, sourceId: source.id, sourceName: source.name, startupRefresh: true } }).catch(function (error) { console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); }); }, startupDelayMs); }); } await syncRecurringRefreshes(); await registerRecurringMaintenanceTasks(); scheduleInitialDataSourceRefreshes().catch(function (error) { console.warn('Unable to schedule startup data source refreshes:', error); }); fs.mkdirSync(MEDIA_DIR, { recursive: true }); await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) { console.warn('Unable to sync existing media 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); }); }