Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m15s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
815 lines
36 KiB
JavaScript
815 lines
36 KiB
JavaScript
// Admin content routes for media uploads and related files.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
|
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
|
|
|
module.exports = function registerContentRoutes(app, deps) {
|
|
const pool = deps.pool;
|
|
const common = deps.common;
|
|
const pages = deps.pages;
|
|
const upload = deps.upload;
|
|
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
|
const fetchScreensBySlideId = deps.fetchScreensBySlideId;
|
|
const fetchScreensByTemplateId = deps.fetchScreensByTemplateId;
|
|
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
|
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
|
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
|
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
|
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
|
const getAuditUserId = deps.getAuditUserId;
|
|
const redirectAfterSave = deps.redirectAfterSave;
|
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
|
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
|
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
|
|
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
|
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
|
const requirePermission = deps.requirePermission;
|
|
const hasAnyPermission = deps.hasAnyPermission;
|
|
const { buildPagination } = require('#src/web/lib/pagination');
|
|
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate');
|
|
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
|
|
|
const LIST_PAGE_SIZE = 25;
|
|
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
|
|
async function fetchSlideFormData() {
|
|
const data = await common.fetchTemplatesData(pool);
|
|
const rssData = await common.fetchRssFeedsData(pool);
|
|
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
|
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
|
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
|
return Object.assign({}, source, {
|
|
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
|
});
|
|
}) : [];
|
|
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
|
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
|
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
|
: [];
|
|
return Object.assign({}, feed, { items: items.map(function (item) {
|
|
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
|
}) });
|
|
}));
|
|
|
|
return Object.assign(data, rssData, apiData, timetableData, {
|
|
rssFeeds: rssFeeds,
|
|
apiSources: apiSources,
|
|
timetableGroups: timetableData.timetableGroups || [],
|
|
fontLibrary: loadFontLibrary(deps.uploadDir)
|
|
});
|
|
}
|
|
|
|
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
|
throw new Error('registerContentRoutes requires the content route dependencies.');
|
|
}
|
|
|
|
async function removeMediaFile(uploadPath) {
|
|
const normalizedPath = String(uploadPath || '').trim();
|
|
if (!normalizedPath || !normalizedPath.startsWith('/media/')) {
|
|
return;
|
|
}
|
|
|
|
const mediaRoot = path.dirname(deps.uploadDir);
|
|
const filePath = path.join(mediaRoot, normalizedPath.replace(/^\/+media\//, ''));
|
|
|
|
try {
|
|
await fs.promises.unlink(filePath);
|
|
} catch (error) {
|
|
if (!error || error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireSlideUploadPermission(req, res, next) {
|
|
if (!req.currentUser) {
|
|
if (typeof setAuthMessageCookie === 'function') {
|
|
setAuthMessageCookie(res, 'Please sign in to continue.');
|
|
return res.redirect('/login');
|
|
}
|
|
return res.redirect('/login');
|
|
}
|
|
|
|
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
|
|
return next();
|
|
}
|
|
|
|
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
|
error.statusCode = 403;
|
|
error.expose = true;
|
|
next(error);
|
|
}
|
|
|
|
function getUploadedFileMediaType(file, uploadContext) {
|
|
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
|
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
|
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
|
|
|
|
if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].indexOf(extension) === -1) {
|
|
return null;
|
|
}
|
|
|
|
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
|
return 'video';
|
|
}
|
|
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].indexOf(extension) !== -1) {
|
|
return 'image';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getUploadedFileLimitBytes(file, uploadContext) {
|
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
|
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
|
}
|
|
|
|
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
|
}
|
|
|
|
function getUploadedFileLimitLabel(file, uploadContext) {
|
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
|
return '10 MB';
|
|
}
|
|
|
|
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
|
|
}
|
|
|
|
async function removeUploadedFile(file) {
|
|
if (!file || !file.filename || !deps.uploadDir) {
|
|
return;
|
|
}
|
|
|
|
const filePath = path.join(deps.uploadDir, file.filename);
|
|
try {
|
|
await fs.promises.unlink(filePath);
|
|
} catch (_error) {
|
|
// Ignore cleanup failures.
|
|
}
|
|
}
|
|
|
|
async function validateUploadedFiles(files, uploadContext) {
|
|
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
|
for (let i = 0; i < list.length; i += 1) {
|
|
const file = list[i];
|
|
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
|
if (!mediaType) {
|
|
await removeUploadedFile(file);
|
|
const error = new Error('Unsupported upload type.');
|
|
error.statusCode = 400;
|
|
error.expose = true;
|
|
throw error;
|
|
}
|
|
|
|
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
|
|
await removeUploadedFile(file);
|
|
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
|
|
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
|
|
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
|
|
error.statusCode = 400;
|
|
error.expose = true;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchTemplateSlideCount(templateId) {
|
|
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM c_slides WHERE template_id = ?', [templateId]);
|
|
return Number(rows[0] && rows[0].slide_count) || 0;
|
|
}
|
|
|
|
async function fetchCanvasSizeUsageCount(canvasSizeId) {
|
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSizeId]);
|
|
return Number(rows[0] && rows[0].ref_count) || 0;
|
|
}
|
|
|
|
async function fetchTemplateRegionUsage(template) {
|
|
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
|
|
const usage = new Set();
|
|
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
|
|
.map((region) => String(region && region.region_key || '').trim())
|
|
.filter(Boolean));
|
|
|
|
if (!regionKeys.size || !slides.length) {
|
|
return [];
|
|
}
|
|
|
|
slides.forEach((slide) => {
|
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
|
if (!content || typeof content !== 'object') {
|
|
return;
|
|
}
|
|
regionKeys.forEach((regionKey) => {
|
|
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
|
|
usage.add(regionKey);
|
|
}
|
|
});
|
|
});
|
|
|
|
return Array.from(usage);
|
|
}
|
|
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
|
|
|
|
async function fetchSlidesByTemplateId(templateId) {
|
|
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
|
return slides;
|
|
}
|
|
|
|
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
|
|
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
|
|
return (Array.isArray(existingRegions) ? existingRegions : [])
|
|
.map((region) => String(region && region.region_key || '').trim())
|
|
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
|
}
|
|
|
|
|
|
app.get('/templates/:id/duplicate', requirePermission('templates.read'), requirePermission('templates.create'), async function (req, res, next) {
|
|
try {
|
|
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
|
if (!template) {
|
|
return res.status(404).send('Template not found');
|
|
}
|
|
|
|
const canvasSizes = await common.fetchCanvasSizesData(pool);
|
|
let duplicateName = buildDuplicateTemplateName(template.name);
|
|
let duplicateIndex = 2;
|
|
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
|
|
duplicateName = buildDuplicateTemplateName(template.name) + ' (' + duplicateIndex + ')';
|
|
duplicateIndex += 1;
|
|
}
|
|
|
|
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
|
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
|
if (!removedRegionKeys.length) {
|
|
return '';
|
|
}
|
|
|
|
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
|
|
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
|
|
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
|
|
}
|
|
|
|
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
|
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
return backgroundTaskQueue.enqueueTask({
|
|
key: 'slide-thumbnail:' + Number(slideId),
|
|
title: 'Refresh slide thumbnail',
|
|
category: 'slides',
|
|
taskType: 'slide-thumbnail-refresh',
|
|
payload: {
|
|
slideId: Number(slideId),
|
|
previousThumbnailPath: String(previousThumbnailPath || '').trim()
|
|
},
|
|
persist: true
|
|
});
|
|
}
|
|
|
|
function queueTemplateSlideThumbnailRefresh(templateId) {
|
|
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
return backgroundTaskQueue.enqueueTask({
|
|
key: 'template-slide-thumbnail:' + Number(templateId),
|
|
title: 'Refresh slide thumbnails',
|
|
category: 'slides',
|
|
taskType: 'template-slide-thumbnail-refresh',
|
|
payload: {
|
|
templateId: Number(templateId)
|
|
},
|
|
persist: true
|
|
});
|
|
}
|
|
|
|
async function canvasSizeExists(width, height, ignoreId) {
|
|
const params = [width, height];
|
|
let query = 'SELECT COUNT(*) AS count FROM c_canvas_sizes WHERE width = ? AND height = ?';
|
|
if (ignoreId) {
|
|
query += ' AND id <> ?';
|
|
params.push(ignoreId);
|
|
}
|
|
const [rows] = await pool.query(query, params);
|
|
return Number(rows[0] && rows[0].count) > 0;
|
|
}
|
|
|
|
async function fetchScreenSlideCountsBySlug(screenSlugs) {
|
|
const uniqueSlugs = Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).map(function (value) {
|
|
return String(value || '').trim();
|
|
}).filter(Boolean);
|
|
if (!uniqueSlugs.length) {
|
|
return {};
|
|
}
|
|
|
|
const [rows] = await pool.query(
|
|
`SELECT s.slug, COUNT(ps.id) AS slide_count
|
|
FROM d_screens s
|
|
LEFT JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
|
WHERE s.slug IN (?)
|
|
GROUP BY s.id, s.slug`,
|
|
[uniqueSlugs]
|
|
);
|
|
|
|
return rows.reduce(function (counts, row) {
|
|
counts[String(row.slug || '').trim()] = Number(row.slide_count) || 0;
|
|
return counts;
|
|
}, {});
|
|
}
|
|
|
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
|
try {
|
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
|
const search = common.getSearchQuery(req);
|
|
const sort = common.getSortQuery(req);
|
|
const direction = common.getSortDirectionQuery(req);
|
|
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
|
res.send(pages.renderSlidesPage({
|
|
slides: data.slides || [],
|
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
|
try {
|
|
const data = await fetchSlideFormData();
|
|
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/slides/:id/duplicate', requirePermission('slides.read'), requirePermission('slides.create'), async function (req, res, next) {
|
|
try {
|
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
|
if (!slide) {
|
|
return res.status(404).send('Slide not found');
|
|
}
|
|
|
|
const data = await fetchSlideFormData();
|
|
let duplicateName = buildDuplicateSlideName(slide.title);
|
|
let duplicateIndex = 2;
|
|
while (await common.fetchDuplicateName(pool, 'c_slides', duplicateName, null, 'title')) {
|
|
duplicateName = buildDuplicateSlideName(slide.title) + ' (' + duplicateIndex + ')';
|
|
duplicateIndex += 1;
|
|
}
|
|
|
|
res.send(pages.renderSlideAddPage(buildDuplicateSlide(slide, duplicateName), data, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
|
try {
|
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
|
if (!slide) {
|
|
return res.status(404).send('Slide not found');
|
|
}
|
|
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
|
|
const data = await fetchSlideFormData();
|
|
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file was uploaded.' });
|
|
}
|
|
|
|
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
|
|
|
|
await validateUploadedFiles([req.file], uploadContext);
|
|
|
|
res.json({
|
|
path: '/media/uploads/' + req.file.filename,
|
|
filename: req.file.filename,
|
|
originalName: req.file.originalname || ''
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/slides/uploads/cleanup', requireSlideUploadPermission, async function (req, res, next) {
|
|
try {
|
|
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
|
? req.body.uploadPaths
|
|
: req.body && req.body.uploadPaths
|
|
? [req.body.uploadPaths]
|
|
: [];
|
|
|
|
if (!uploadPaths.length) {
|
|
return res.status(400).json({ error: 'No upload paths were provided.' });
|
|
}
|
|
|
|
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
|
res.sendStatus(204);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
|
try {
|
|
await validateUploadedFiles(req.files || []);
|
|
const payload = await common.buildSlidePayload(pool, req, null);
|
|
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
|
|
return res.status(400).send('A slide with that title already exists.');
|
|
}
|
|
const actorId = getAuditUserId(req);
|
|
const [result] = await pool.query(
|
|
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
|
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
|
|
);
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'slide:create:' + result.insertId,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
|
});
|
|
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
|
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
|
});
|
|
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
|
closeUrl: '/slides',
|
|
newUrl: '/slides/new',
|
|
message: 'Slide created.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
|
try {
|
|
await validateUploadedFiles(req.files || []);
|
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
|
if (!slide) {
|
|
return res.status(404).send('Slide not found');
|
|
}
|
|
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
|
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
|
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
|
|
const payload = await common.buildSlidePayload(pool, req, slide);
|
|
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, slide.id, 'title')) {
|
|
return res.status(400).send('A slide with that title already exists.');
|
|
}
|
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
|
const actorId = getAuditUserId(req);
|
|
await pool.query(
|
|
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
|
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
|
);
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'slide:update:' + slide.id,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
previousUploadRefs: existingUploadRefs,
|
|
nextUploadRefs: nextUploadRefs,
|
|
blockedSlideIds: [slide.id],
|
|
refreshScreenSlugs: affectedScreens,
|
|
screenSlideCounts: screenSlideCounts
|
|
});
|
|
await broadcastDashboardState();
|
|
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
|
});
|
|
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
|
closeUrl: '/slides',
|
|
newUrl: '/slides/new',
|
|
message: 'Slide updated.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
|
try {
|
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
|
if (!slide) {
|
|
return res.status(404).send('Slide not found');
|
|
}
|
|
const blockMessage = await getSlideDeleteBlockMessage(pool, slide);
|
|
if (blockMessage) {
|
|
return res.redirect('/slides?message=' + encodeURIComponent(blockMessage));
|
|
}
|
|
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
|
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
|
const uploadRefs = collectUploadReferencesFromSlide(slide);
|
|
await pool.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
|
|
await removeMediaFile(slide.thumbnail_path);
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'slide:delete:' + slide.id,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
previousUploadRefs: uploadRefs,
|
|
blockedSlideIds: [slide.id],
|
|
refreshScreenSlugs: affectedScreens,
|
|
screenSlideCounts: screenSlideCounts
|
|
});
|
|
await broadcastDashboardState();
|
|
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
|
try {
|
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
|
const search = common.getSearchQuery(req);
|
|
const sort = common.getSortQuery(req);
|
|
const direction = common.getSortDirectionQuery(req);
|
|
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
|
res.send(pages.renderTemplatesPage({
|
|
templates: data.templates || [],
|
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
|
try {
|
|
const data = await common.fetchCanvasSizesData(pool);
|
|
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
|
try {
|
|
const payload = await common.buildTemplatePayload(pool, req, null);
|
|
if (!payload.regions.length) {
|
|
return res.status(400).send('At least 1 region needs to be added.');
|
|
}
|
|
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name)) {
|
|
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
|
}
|
|
const actorId = getAuditUserId(req);
|
|
const [result] = await pool.query(
|
|
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
|
|
);
|
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
|
const region = payload.regions[i];
|
|
await pool.query(
|
|
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
|
);
|
|
}
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'template:create:' + result.insertId,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
|
});
|
|
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
|
|
closeUrl: '/templates',
|
|
newUrl: '/templates/new',
|
|
message: 'Template created.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
|
|
try {
|
|
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
|
if (!template) {
|
|
return res.status(404).send('Template not found');
|
|
}
|
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
|
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
|
|
const sizeData = await common.fetchCanvasSizesData(pool);
|
|
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
|
try {
|
|
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
|
if (!template) {
|
|
return res.status(404).send('Template not found');
|
|
}
|
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
|
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
|
const payload = await common.buildTemplatePayload(pool, req, template);
|
|
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name, template.id)) {
|
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
|
}
|
|
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
|
if (regionDeleteBlockMessage) {
|
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
|
|
}
|
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
|
const actorId = getAuditUserId(req);
|
|
await pool.query(
|
|
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
|
);
|
|
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
|
const region = payload.regions[i];
|
|
await pool.query(
|
|
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
|
);
|
|
}
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'template:update:' + template.id,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
previousUploadRefs: existingUploadRefs,
|
|
nextUploadRefs: nextUploadRefs
|
|
});
|
|
const templateSlides = await fetchSlidesByTemplateId(template.id);
|
|
if (templateSlides.length > 0) {
|
|
queueTemplateSlideThumbnailRefresh(template.id).catch(function (error) {
|
|
console.warn('Unable to queue template slide thumbnail refresh:', error);
|
|
});
|
|
}
|
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
|
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
|
closeUrl: '/templates',
|
|
newUrl: '/templates/new',
|
|
message: 'Template updated.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
|
try {
|
|
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
|
if (!template) {
|
|
return res.status(404).send('Template not found');
|
|
}
|
|
const blockMessage = await getTemplateDeleteBlockMessage(pool, template);
|
|
if (blockMessage) {
|
|
return res.redirect('/templates?message=' + encodeURIComponent(blockMessage));
|
|
}
|
|
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
|
await pool.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
|
|
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
|
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
|
|
await syncPlaylistUploadsOnChange({
|
|
key: 'template:delete:' + template.id,
|
|
pool: pool,
|
|
localUploadDir: deps.uploadDir,
|
|
previousUploadRefs: uploadRefs
|
|
});
|
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
|
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
|
try {
|
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
|
const search = common.getSearchQuery(req);
|
|
const sort = common.getSortQuery(req);
|
|
const direction = common.getSortDirectionQuery(req);
|
|
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
|
res.send(pages.renderCanvasSizesPage({
|
|
canvasSizes: data.canvasSizes || [],
|
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
|
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
});
|
|
|
|
app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
|
try {
|
|
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
|
if (!canvasSize) {
|
|
return res.status(404).send('Canvas size not found');
|
|
}
|
|
|
|
let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name);
|
|
let duplicateIndex = 2;
|
|
while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) {
|
|
duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')';
|
|
duplicateIndex += 1;
|
|
}
|
|
|
|
res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
|
try {
|
|
const payload = common.buildCanvasSizePayload(req, null);
|
|
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name)) {
|
|
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
|
}
|
|
if (await canvasSizeExists(payload.width, payload.height)) {
|
|
return res.status(400).send('That canvas size already exists.');
|
|
}
|
|
const actorId = getAuditUserId(req);
|
|
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
|
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
|
closeUrl: '/canvas-sizes',
|
|
newUrl: '/canvas-sizes/new',
|
|
message: 'Canvas size created.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
|
try {
|
|
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
|
if (!canvasSize) {
|
|
return res.status(404).send('Canvas size not found');
|
|
}
|
|
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
|
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
|
try {
|
|
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
|
if (!canvasSize) {
|
|
return res.status(404).send('Canvas size not found');
|
|
}
|
|
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
|
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
|
if (canvasSize.inUse && (payload.width !== Number(canvasSize.width) || payload.height !== Number(canvasSize.height))) {
|
|
return res.status(400).send('This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
|
|
}
|
|
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
|
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
|
}
|
|
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
|
return res.status(400).send('That canvas size already exists.');
|
|
}
|
|
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
|
redirectAfterSave(req, res, '/canvas-sizes', {
|
|
closeUrl: '/canvas-sizes',
|
|
newUrl: '/canvas-sizes/new',
|
|
message: 'Canvas size updated.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
|
|
try {
|
|
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
|
if (!canvasSize) {
|
|
return res.status(404).send('Canvas size not found');
|
|
}
|
|
const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize);
|
|
if (blockMessage) {
|
|
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
|
}
|
|
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
|
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
|
|
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
};
|