519 lines
20 KiB
JavaScript
519 lines
20 KiB
JavaScript
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);
|
|
});
|
|
}
|