Release 1.5.3
This commit is contained in:
@@ -600,22 +600,32 @@ function createBackgroundTaskQueue(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskSortTime(task) {
|
||||
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||
if (Number.isFinite(finishedTime)) {
|
||||
return finishedTime;
|
||||
}
|
||||
|
||||
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
if (Number.isFinite(createdTime)) {
|
||||
return createdTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const statusRank = {
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 2,
|
||||
completed: 3,
|
||||
canceled: 4
|
||||
};
|
||||
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
|
||||
return rightQueuedTime - leftQueuedTime;
|
||||
}
|
||||
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
|
||||
return Number.isFinite(leftQueuedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function buildQueryString(query) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.keys(query || {}).forEach(function (key) {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
||||
const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize));
|
||||
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
||||
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
||||
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
||||
const pages = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: buildQueryString(nextQuery)
|
||||
});
|
||||
}
|
||||
|
||||
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: Number(totalItems) || 0,
|
||||
hasMultiplePages: totalPages > 1,
|
||||
startItem: startIndex + 1,
|
||||
endItem: endIndex,
|
||||
hasPrevious: safeCurrentPage > 1,
|
||||
hasNext: safeCurrentPage < totalPages,
|
||||
previousUrl: buildQueryString(previousQuery),
|
||||
nextUrl: buildQueryString(nextQuery),
|
||||
pages: pages,
|
||||
pageSize: normalizedPageSize,
|
||||
pageParam: normalizedPageParam,
|
||||
itemLabel: String(itemLabel || 'items'),
|
||||
ariaLabel: String(ariaLabel || 'Pagination')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizePageNumber,
|
||||
buildQueryString,
|
||||
buildPagination
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
const { fetchPagedRows } = require('../../data/utils');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -27,6 +28,21 @@ async function fetchRoles(pool) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRolesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
ORDER BY r.name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ roles: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
@@ -106,6 +122,40 @@ async function fetchUsersWithRoles(pool) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
ORDER BY u.id ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
users: (paged.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
}),
|
||||
totalItems: paged.totalItems,
|
||||
totalPages: paged.totalPages,
|
||||
currentPage: paged.currentPage,
|
||||
pageSize: paged.pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
@@ -184,11 +234,13 @@ module.exports = {
|
||||
PERMISSIONS,
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
fetchRolesForUser,
|
||||
fetchUsersWithRoles,
|
||||
fetchUsersWithRolesPage,
|
||||
fetchUserWithRoles,
|
||||
syncUserRoles,
|
||||
syncRoleUsers,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const sharp = require('sharp');
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor
|
||||
} = require('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const PLAYER_VIEWPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
};
|
||||
const THUMBNAIL_MAX_SIZE = {
|
||||
width: 480,
|
||||
height: 270
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveAssetUrl(baseUrl, value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||
return raw;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return normalizedBaseUrl + raw;
|
||||
}
|
||||
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function getCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionContent(slide, region) {
|
||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||
if (!hasVisibleContent(renderedBody)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'rtmp') {
|
||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
function buildSlideHtml(slide, baseUrl) {
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111');
|
||||
const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : '';
|
||||
|
||||
if (!template) {
|
||||
const mediaPath = String(slide && slide.media_path || '').trim();
|
||||
const mediaUrl = resolveAssetUrl(baseUrl, mediaPath);
|
||||
const kind = mediaKind(mediaPath || slide.media_type || '');
|
||||
const mediaMarkup = kind === 'image' && mediaUrl
|
||||
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
|
||||
: '';
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) {
|
||||
return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
}) : [];
|
||||
|
||||
const regionsHtml = regions.map(function (region) {
|
||||
const content = getRegionContent(slide, region);
|
||||
const left = Math.max(0, Number(region.x || 0));
|
||||
const top = Math.max(0, Number(region.y || 0));
|
||||
const width = Math.max(1, Number(region.width || 1));
|
||||
const height = Math.max(1, Number(region.height || 1));
|
||||
const regionType = String(region.region_type || 'text').toLowerCase();
|
||||
const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
||||
const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
||||
const innerHtml = regionType === 'text'
|
||||
? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content)
|
||||
: buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl);
|
||||
|
||||
if (!innerHtml) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
|
||||
innerHtml,
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
|
||||
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
|
||||
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
|
||||
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
|
||||
regionsHtml,
|
||||
' </div>',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
if (usingSystemChromium) {
|
||||
return puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
],
|
||||
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||
executablePath: executablePath,
|
||||
headless: true
|
||||
});
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: 'shell'
|
||||
});
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||
const slideId = Number(options && options.slideId || 0);
|
||||
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||
|
||||
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath,
|
||||
body: {}
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true });
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
await page.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.png()
|
||||
.toFile(thumbnailTempPath);
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
slideId: slide.id,
|
||||
thumbnailPath: thumbnailPath,
|
||||
filePath: filePath,
|
||||
fullSizePath: fullSizePath,
|
||||
mediaKind: mediaKind(slide.media_path || '')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
};
|
||||
+45
-13
@@ -51,6 +51,32 @@ function createUploadSyncService(options) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUploadRelativePath(uploadPath) {
|
||||
const value = normalizeUploadReference(uploadPath);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.replace(/^\/media\//, '');
|
||||
}
|
||||
|
||||
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relativePath.startsWith('uploads/')) {
|
||||
return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length));
|
||||
}
|
||||
|
||||
return path.join(path.dirname(normalizedUploadDir), relativePath);
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromValue(value, refs) {
|
||||
if (!value) {
|
||||
return refs;
|
||||
@@ -134,7 +160,7 @@ function createUploadSyncService(options) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, path.basename(uploadPath));
|
||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
@@ -174,7 +200,7 @@ function createUploadSyncService(options) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
|
||||
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
@@ -232,8 +258,11 @@ function createUploadSyncService(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
const sourcePath = path.join(localUploadDir, filename);
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||
if (!relativePath || !sourcePath) {
|
||||
return false;
|
||||
}
|
||||
let fileBuffer = null;
|
||||
try {
|
||||
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||
@@ -247,10 +276,10 @@ function createUploadSyncService(options) {
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`,
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -259,12 +288,12 @@ function createUploadSyncService(options) {
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText);
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', filename, error);
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -274,13 +303,16 @@ function createUploadSyncService(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -288,12 +320,12 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText);
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', filename, error);
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user