292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
// 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);
|
|
});
|
|
}
|