Release v2.13.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
2026-09-11 20:46:37 +01:00
parent 0a03cdd0b7
commit 394d23bb4d
47 changed files with 2408 additions and 559 deletions
+276
View File
@@ -0,0 +1,276 @@
// Shared media library routes.
const fs = require('fs');
const path = require('path');
const { fetchAppSettings } = require('#src/data/app-settings');
const { hasAnyPermission, hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const mediaLibrary = require('#src/web/lib/media/library');
function requireMediaAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) {
return next();
}
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
};
}
function getMediaType(file) {
const mimeType = String(file && file.mimetype || '').toLowerCase();
if (mimeType.indexOf('video/') === 0) {
return 'video';
}
if (mimeType.indexOf('image/') === 0) {
return 'image';
}
return null;
}
function getUploadLimits(settings) {
const imageMaxBytes = Number(settings && settings['uploads.image_max_bytes']) || 100 * 1024 * 1024;
const videoMaxBytes = Number(settings && settings['uploads.video_max_bytes']) || 1024 * 1024 * 1024;
const formatBytes = function (bytes) {
const megabytes = bytes / 1024 / 1024;
if (megabytes >= 1024 && megabytes % 1024 === 0) {
return (megabytes / 1024) + ' GB';
}
return (Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
};
return {
imageMaxBytes: imageMaxBytes,
videoMaxBytes: videoMaxBytes,
imageMaxLabel: formatBytes(imageMaxBytes),
videoMaxLabel: formatBytes(videoMaxBytes),
allowedMimeTypes: Array.isArray(settings && settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : []
};
}
function formatFileSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) {
return value + ' B';
}
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = -1;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 ? 0 : 1;
return size.toFixed(precision).replace(/\.0$/, '') + ' ' + units[unitIndex];
}
function getMediaFormat(asset) {
const name = String(asset && (asset.original_name || asset.media_path) || '').trim();
const match = name.match(/\.([a-z0-9]+)$/i);
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
}
function getAssetQuery(query) {
const source = query || {};
const sort = ['date-desc', 'date-asc', 'name-asc', 'name-desc', 'size-asc', 'size-desc'].includes(String(source.sort || ''))
? String(source.sort)
: 'date-desc';
const type = ['image', 'video'].includes(String(source.type || '')) ? String(source.type) : 'all';
const usage = ['used', 'unused'].includes(String(source.usage || '')) ? String(source.usage) : 'all';
return {
search: String(source.search || '').trim().toLowerCase(),
type: type,
usage: usage,
sort: sort
};
}
function filterAndSortAssets(assets, query) {
const filtered = assets.filter(function (asset) {
const matchesSearch = !query.search || String(asset.original_name || '').toLowerCase().includes(query.search);
const matchesType = query.type === 'all' || asset.media_type === query.type;
const isUsed = Boolean(asset.inUse);
const matchesUsage = query.usage === 'all' || (query.usage === 'used' ? isUsed : !isUsed);
return matchesSearch && matchesType && matchesUsage;
});
filtered.sort(function (left, right) {
const leftName = String(left.original_name || '').toLowerCase();
const rightName = String(right.original_name || '').toLowerCase();
const leftDate = String(left.created_at || '');
const rightDate = String(right.created_at || '');
if (query.sort === 'name-asc' || query.sort === 'name-desc') {
return (query.sort === 'name-asc' ? 1 : -1) * leftName.localeCompare(rightName);
}
if (query.sort === 'size-asc' || query.sort === 'size-desc') {
return (query.sort === 'size-asc' ? 1 : -1) * (Number(left.file_size) - Number(right.file_size));
}
return (query.sort === 'date-asc' ? 1 : -1) * leftDate.localeCompare(rightDate);
});
return filtered;
}
module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
const pageSize = 24;
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
}
app.get('/media-library', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const settings = await fetchAppSettings(deps.pool);
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
res.send(deps.pages.renderMediaLibraryPage({
assets: filteredAssets.slice(0, pageSize),
assetTotal: filteredAssets.length,
hasMore: filteredAssets.length > pageSize,
uploadLimits: getUploadLimits(settings)
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/media-library/assets', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
const assets = filteredAssets.slice(offset, offset + limit);
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
} catch (error) {
next(error);
}
});
app.post('/media-library', requireAccess, deps.requirePermission('media-library.create'), deps.upload.single('file'), async function (req, res, next) {
try {
if (!req.file) {
return res.redirect('/media-library?message=' + encodeURIComponent('Choose an image or video to upload.'));
}
const mediaType = getMediaType(req.file);
const settings = await fetchAppSettings(deps.pool);
const uploadLimits = getUploadLimits(settings);
const mimeType = String(req.file.mimetype || '').trim().toLowerCase();
const allowedMimeTypes = uploadLimits.allowedMimeTypes;
const maxBytes = mediaType === 'video' ? uploadLimits.videoMaxBytes : uploadLimits.imageMaxBytes;
if (allowedMimeTypes.indexOf(mimeType) === -1 || (mediaType !== 'image' && mediaType !== 'video')) {
await fs.promises.unlink(req.file.path).catch(function () {});
return res.redirect('/media-library?message=' + encodeURIComponent('This media type is disabled in Media Uploads settings.'));
}
if (Number(req.file.size || 0) > maxBytes) {
await fs.promises.unlink(req.file.path).catch(function () {});
const limitLabel = maxBytes >= 1024 * 1024 * 1024 && maxBytes % (1024 * 1024 * 1024) === 0
? (maxBytes / (1024 * 1024 * 1024)) + ' GB'
: Math.round(maxBytes / (1024 * 1024)) + ' MB';
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
}
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
await fs.promises.unlink(req.file.path).catch(function () {});
}
next(error);
}
});
async function deleteMediaAsset(assetId) {
const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]);
const asset = rows && rows[0];
if (!asset) {
return 'missing';
}
const referenceCount = await mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
if (referenceCount > 0) {
return 'in-use';
}
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
if (typeof deps.syncPlaylistUploadsOnChange === 'function') {
await deps.syncPlaylistUploadsOnChange({
key: 'media-library:delete:' + asset.id,
pool: deps.pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: [asset.media_path],
nextUploadRefs: []
});
}
return 'deleted';
}
app.post('/media-library/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const rawAssetIds = req.body && (req.body.assetIds || req.body['assetIds[]']);
const assetIds = Array.from(new Set((Array.isArray(rawAssetIds) ? rawAssetIds : [rawAssetIds]).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
if (!assetIds.length) {
return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.'));
}
let deletedCount = 0;
let inUseCount = 0;
for (let index = 0; index < assetIds.length; index += 1) {
const result = await deleteMediaAsset(assetIds[index]);
if (result === 'deleted') deletedCount += 1;
if (result === 'in-use') inUseCount += 1;
}
const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : '');
res.redirect('/media-library?message=' + encodeURIComponent(message));
} catch (error) {
next(error);
}
});
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const result = await deleteMediaAsset(req.params.id);
if (result === 'missing') {
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
}
if (result === 'in-use') {
return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.'));
}
res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,17 @@
// Media library page renderer.
const { renderView } = require('../../../view');
module.exports = function renderMediaLibraryPage(data, message, currentUser) {
return renderView('settings/media-library/list', {
title: 'Media library',
active: 'media-library',
bodyClass: 'media-library-page',
message: message,
currentUser: currentUser || null,
assets: data.assets || [],
assetTotal: Number(data.assetTotal || (data.assets || []).length),
hasMore: Boolean(data.hasMore),
uploadLimits: data.uploadLimits || {}
});
};