Release v2.13.1
This commit is contained in:
@@ -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 || {}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user