Add media filename renaming and preserve library filters
This commit is contained in:
1 parent
2fd059d35a
commit
eb724167bc
6 files changed
+310
-20
No files matched your search
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
||||
### Added
|
||||
|
||||
- Added an All timetable display mode that includes past items while respecting date filters and the item limit.
|
||||
- Added filename renaming in Media Library image and video previews without changing stored media paths or slide references.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -19,6 +20,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Region inputs no longer submit the template form when Enter is pressed.
|
||||
- Region width and height can be cleared and retyped without minimum-size clamping until the field is committed.
|
||||
- Restored remove controls when selecting image or video media from the Media Library.
|
||||
- Preserved Media Library search, filters, and sort order after individual or bulk deletion.
|
||||
|
||||
## 2.15.4 - 2026-10-16
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const PERMISSION_SECTIONS = [
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View shared media assets.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload shared media assets.' },
|
||||
{ key: 'update', name: 'Update', description: 'Rename shared media assets.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,6 +6,15 @@ const search = gallery && gallery.querySelector('[data-media-gallery-search]');
|
||||
const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]');
|
||||
const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]');
|
||||
const sort = gallery && gallery.querySelector('[data-media-gallery-sort]');
|
||||
const initialGalleryQuery = new URLSearchParams(window.location.search);
|
||||
if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQuery.get('search');
|
||||
[ ['type', typeFilter], ['usage', usageFilter], ['sort', sort] ].forEach(function (entry) {
|
||||
const control = entry[1];
|
||||
const value = initialGalleryQuery.get(entry[0]);
|
||||
if (control && Array.from(control.options).some(function (option) { return option.value === value; })) {
|
||||
control.value = value;
|
||||
}
|
||||
});
|
||||
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
|
||||
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
|
||||
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
|
||||
@@ -369,14 +378,19 @@ if (uploadForm) {
|
||||
});
|
||||
}
|
||||
|
||||
function getGalleryQuery(offset, limit) {
|
||||
function getGalleryFilters() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('offset', String(offset));
|
||||
params.set('limit', String(limit));
|
||||
params.set('search', String(search && search.value || '').trim());
|
||||
params.set('type', typeFilter ? typeFilter.value : 'all');
|
||||
params.set('usage', usageFilter ? usageFilter.value : 'all');
|
||||
params.set('sort', sort ? sort.value : 'date-desc');
|
||||
return params;
|
||||
}
|
||||
|
||||
function getGalleryQuery(offset, limit) {
|
||||
const params = getGalleryFilters();
|
||||
params.set('offset', String(offset));
|
||||
params.set('limit', String(limit));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -439,7 +453,7 @@ function submitBulkDelete() {
|
||||
}
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/media-library/delete';
|
||||
form.action = '/media-library/delete?' + getGalleryFilters().toString();
|
||||
selectedAssetIds.forEach(function (id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
@@ -484,6 +498,12 @@ if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit);
|
||||
if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit);
|
||||
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
|
||||
if (gallery) {
|
||||
gallery.addEventListener('submit', function (event) {
|
||||
const form = event.target;
|
||||
const action = form && form.getAttribute('action');
|
||||
if (!/^\/media-library\/[^/?]+\/delete(?:\?|$)/.test(action || '')) return;
|
||||
form.action = action.split('?')[0] + '?' + getGalleryFilters().toString();
|
||||
}, true);
|
||||
gallery.addEventListener('click', function (event) {
|
||||
if (!selectionMode) return;
|
||||
const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null;
|
||||
@@ -534,6 +554,62 @@ function showPreviewModal() {
|
||||
}
|
||||
|
||||
if (modal && body) {
|
||||
const renameForm = modal.querySelector('[data-media-gallery-rename-form]');
|
||||
const filenameInput = modal.querySelector('[data-media-gallery-filename]');
|
||||
const renameSave = modal.querySelector('[data-media-gallery-rename-save]');
|
||||
const renameStatus = modal.querySelector('[data-media-gallery-rename-status]');
|
||||
let activePreview = null;
|
||||
|
||||
if (renameForm && filenameInput && renameSave && renameStatus) {
|
||||
renameForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
if (renameSave.disabled || !activePreview) return;
|
||||
const preview = activePreview;
|
||||
const action = renameForm.action;
|
||||
renameSave.disabled = true;
|
||||
filenameInput.disabled = true;
|
||||
renameStatus.hidden = true;
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ name: filenameInput.value })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Unable to rename media.');
|
||||
preview.dataset.mediaName = result.name;
|
||||
preview.setAttribute('aria-label', 'Preview ' + result.name);
|
||||
const column = preview.closest('[data-media-gallery-column]');
|
||||
const label = column && column.querySelector('.media-gallery-name');
|
||||
if (label) {
|
||||
label.textContent = result.name;
|
||||
label.title = result.name;
|
||||
}
|
||||
if (activePreview === preview) {
|
||||
filenameInput.value = result.name;
|
||||
if (title) title.textContent = result.name;
|
||||
const image = body.querySelector('img');
|
||||
if (image) image.alt = result.name;
|
||||
renameStatus.textContent = 'Filename saved.';
|
||||
renameStatus.classList.remove('text-danger');
|
||||
renameStatus.hidden = false;
|
||||
}
|
||||
await refreshGallery();
|
||||
} catch (error) {
|
||||
if (activePreview === preview) {
|
||||
renameStatus.textContent = error.message || 'Unable to rename media.';
|
||||
renameStatus.classList.add('text-danger');
|
||||
renameStatus.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
if (activePreview === preview) {
|
||||
renameSave.disabled = false;
|
||||
filenameInput.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null;
|
||||
if (!preview) {
|
||||
@@ -543,6 +619,17 @@ if (modal && body) {
|
||||
const path = preview.getAttribute('data-media-path') || '';
|
||||
const type = preview.getAttribute('data-media-type') || 'image';
|
||||
const name = preview.getAttribute('data-media-name') || 'Media preview';
|
||||
activePreview = preview;
|
||||
if (renameForm && filenameInput && renameSave && renameStatus) {
|
||||
const column = preview.closest('[data-media-gallery-column]');
|
||||
const assetId = column && column.dataset.mediaId;
|
||||
renameForm.hidden = !assetId;
|
||||
renameForm.action = '/media-library/' + encodeURIComponent(assetId || '') + '/rename';
|
||||
filenameInput.value = name;
|
||||
filenameInput.disabled = false;
|
||||
renameSave.disabled = false;
|
||||
renameStatus.hidden = true;
|
||||
}
|
||||
body.replaceChildren();
|
||||
if (title) {
|
||||
title.textContent = name;
|
||||
@@ -560,6 +647,7 @@ if (modal && body) {
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
activePreview = null;
|
||||
body.replaceChildren();
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ function requireMediaAccess(setAuthMessageCookie) {
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.update', 'media-library.delete'])) {
|
||||
return next();
|
||||
}
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
@@ -71,7 +71,7 @@ function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
function getMediaFormat(asset) {
|
||||
const name = String(asset && (asset.original_name || asset.media_path) || '').trim();
|
||||
const name = String(asset && (asset.media_path || asset.original_name) || '').trim();
|
||||
const match = name.match(/\.([a-z0-9]+)$/i);
|
||||
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
|
||||
}
|
||||
@@ -115,6 +115,13 @@ function filterAndSortAssets(assets, query) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getDeleteRedirect(req, message) {
|
||||
const params = new URLSearchParams(getAssetQuery(req.query));
|
||||
params.set('search', String(req.query && req.query.search || '').trim());
|
||||
params.set('message', message);
|
||||
return '/media-library?' + params.toString();
|
||||
}
|
||||
|
||||
module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
@@ -219,6 +226,37 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/media-library/:id/rename', requireAccess, deps.requirePermission('media-library.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = typeof (req.body && req.body.name) === 'string' ? req.body.name.trim() : '';
|
||||
if (!name || name.length > 255 || /[<>:"/\\|?*\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || /[. ]$/.test(name)) {
|
||||
return res.status(400).json({ error: 'Enter a valid filename of up to 255 characters.' });
|
||||
}
|
||||
const [rows] = await deps.pool.query('SELECT id, original_name, media_path FROM c_media_assets WHERE id = ?', [Number(req.params.id)]);
|
||||
const asset = rows && rows[0];
|
||||
if (!asset) {
|
||||
return res.status(404).json({ error: 'Media asset not found.' });
|
||||
}
|
||||
if (asset.original_name !== name) {
|
||||
await deps.pool.query('UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', [name, deps.getAuditUserId(req), asset.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(deps.pool, req, {
|
||||
category: 'media-assets',
|
||||
eventType: 'media.renamed',
|
||||
actorUserId: deps.getAuditUserId(req),
|
||||
targetType: 'media-asset',
|
||||
targetId: asset.id,
|
||||
targetLabel: name,
|
||||
details: { mediaPath: asset.media_path, previousName: asset.original_name, name: name }
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ id: asset.id, name: name });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteMediaAsset(assetId, req) {
|
||||
const [rows] = await deps.pool.query('SELECT id, media_path, original_name, media_type, file_size FROM c_media_assets WHERE id = ?', [Number(assetId)]);
|
||||
const asset = rows && rows[0];
|
||||
@@ -269,7 +307,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
if (!assetIds.length) {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'Select at least one media asset.'));
|
||||
}
|
||||
let deletedCount = 0;
|
||||
let inUseCount = 0;
|
||||
@@ -279,7 +317,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
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));
|
||||
res.redirect(getDeleteRedirect(req, message));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -289,12 +327,12 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
try {
|
||||
const result = await deleteMediaAsset(req.params.id, req);
|
||||
if (result === 'missing') {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'Media asset not found.'));
|
||||
}
|
||||
if (result === 'in-use') {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'This media is still used and cannot be deleted.'));
|
||||
}
|
||||
res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.'));
|
||||
res.redirect(getDeleteRedirect(req, 'Media deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
{{#if (hasPermission currentUser 'media-library.create')}}
|
||||
<button type="button" class="btn btn-primary btn-sm text-nowrap" data-media-library-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add media</button>
|
||||
{{/if}}
|
||||
{{#if assets.length}}
|
||||
<div class="media-gallery-controls">
|
||||
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
|
||||
<div class="media-gallery-search d-flex flex-wrap gap-2">
|
||||
@@ -48,12 +47,8 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if assets.length}}
|
||||
<div class="media-gallery-scroll">
|
||||
<div class="row g-3 g-xl-4">
|
||||
{{#each assets}}
|
||||
@@ -91,10 +86,7 @@
|
||||
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div>
|
||||
{{else}}
|
||||
<div class="media-gallery-empty text-body-secondary">No media yet.</div>
|
||||
{{/if}}
|
||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
|
||||
</section>
|
||||
|
||||
{{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}}
|
||||
@@ -103,6 +95,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
|
||||
{{#if (hasPermission currentUser 'media-library.update')}}
|
||||
<form class="modal-footer d-block" data-media-gallery-rename-form>
|
||||
<label for="media-gallery-filename" class="form-label">Filename</label>
|
||||
<div class="input-group">
|
||||
<input id="media-gallery-filename" name="name" type="text" class="form-control" maxlength="255" required data-media-gallery-filename />
|
||||
<button type="submit" class="btn btn-primary" title="Save filename" data-media-gallery-rename-save><i class="bi bi-floppy" aria-hidden="true"></i><span class="visually-hidden">Save filename</span></button>
|
||||
</div>
|
||||
<p class="small mb-0 mt-2" role="status" data-media-gallery-rename-status hidden></p>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/modal-shell}}
|
||||
|
||||
{{#if (hasPermission currentUser 'media-library.create')}}
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const vm = require('node:vm');
|
||||
const sharp = require('sharp');
|
||||
require('../src/common');
|
||||
const mediaLibrary = require('../src/web/lib/media/library');
|
||||
@@ -212,3 +213,161 @@ test('media library upload and delete routes audit successful asset changes', as
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media library delete redirects preserve filters for individual and bulk outcomes', async () => {
|
||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
||||
const handlers = {};
|
||||
let outcome = 'deleted';
|
||||
const query = { search: 'Photo & Video', type: 'image', usage: 'unused', sort: 'name-desc' };
|
||||
mediaLibrary.countMediaAssetReferences = async function () { return outcome === 'in-use' ? 1 : 0; };
|
||||
mediaLibrary.removeImageVariants = async function () {};
|
||||
try {
|
||||
registerMediaLibraryRoutes({
|
||||
get() {},
|
||||
post(route, ...middleware) { handlers[route] = middleware.at(-1); }
|
||||
}, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.startsWith('SELECT')) {
|
||||
return [outcome === 'missing' ? [] : [{ id: 9, media_path: '/media/uploads/missing-test.png' }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
pages: {},
|
||||
upload: { single() { return function () {}; } },
|
||||
uploadDir: path.join(os.tmpdir(), 'pulse-media-filter-test', 'uploads'),
|
||||
requirePermission() { return function () {}; }
|
||||
});
|
||||
for (const route of ['/media-library/:id/delete', '/media-library/delete']) {
|
||||
for (outcome of ['deleted', 'in-use', 'missing', 'empty']) {
|
||||
let location;
|
||||
await handlers[route]({ query, params: { id: '9' }, body: { 'assetIds[]': outcome === 'empty' ? [] : ['9'] } }, {
|
||||
redirect(value) { location = value; }
|
||||
}, function (error) { throw error; });
|
||||
const redirect = new URL(location, 'http://localhost');
|
||||
assert.equal(redirect.pathname, '/media-library');
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
assert.equal(redirect.searchParams.get(key), value, route + ': ' + outcome + ': ' + key);
|
||||
}
|
||||
assert.ok(redirect.searchParams.get('message'));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
||||
}
|
||||
});
|
||||
|
||||
test('media gallery restores controls and submits current filters with both delete actions', () => {
|
||||
const listeners = {};
|
||||
const controls = {};
|
||||
for (const [name, values] of Object.entries({
|
||||
search: [''], 'type-filter': ['all', 'image', 'video'],
|
||||
'usage-filter': ['all', 'used', 'unused'], sort: ['date-desc', 'name-desc', 'size-asc']
|
||||
})) {
|
||||
controls['[data-media-gallery-' + name + ']'] = {
|
||||
value: values[0], options: values.map(value => ({ value })), addEventListener() {}
|
||||
};
|
||||
}
|
||||
const gallery = {
|
||||
dataset: {}, querySelector(selector) { return controls[selector] || null; },
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener(name, callback) { listeners[name] = callback; }
|
||||
};
|
||||
let submittedForm;
|
||||
const context = vm.createContext({
|
||||
URLSearchParams,
|
||||
window: { location: { search: '?search=Photo+%26+Video&type=image&usage=unused&sort=name-desc' }, innerWidth: 1200, confirm() { return true; } },
|
||||
document: {
|
||||
getElementById() { return null; },
|
||||
querySelector(selector) { return selector === '.media-gallery' ? gallery : null; },
|
||||
createElement() { return { appendChild() {}, submit() { submittedForm = this; } }; },
|
||||
body: { appendChild() {} }
|
||||
}
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
|
||||
assert.equal(controls['[data-media-gallery-search]'].value, 'Photo & Video');
|
||||
assert.equal(controls['[data-media-gallery-type-filter]'].value, 'image');
|
||||
assert.equal(controls['[data-media-gallery-usage-filter]'].value, 'unused');
|
||||
assert.equal(controls['[data-media-gallery-sort]'].value, 'name-desc');
|
||||
controls['[data-media-gallery-search]'].value = 'Updated & current';
|
||||
controls['[data-media-gallery-sort]'].value = 'size-asc';
|
||||
const form = { getAttribute() { return '/media-library/9/delete'; } };
|
||||
listeners.submit({ target: form });
|
||||
vm.runInContext("selectedAssetIds.add('9'); submitBulkDelete();", context);
|
||||
for (const target of [form, submittedForm]) {
|
||||
const url = new URL(target.action, 'http://localhost');
|
||||
assert.equal(url.searchParams.get('search'), 'Updated & current');
|
||||
assert.equal(url.searchParams.get('type'), 'image');
|
||||
assert.equal(url.searchParams.get('usage'), 'unused');
|
||||
assert.equal(url.searchParams.get('sort'), 'size-asc');
|
||||
assert.equal(url.searchParams.has('offset'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty media gallery retains filters and its refreshable results container', () => {
|
||||
const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list');
|
||||
const html = renderMediaLibraryPage({ assets: [], assetTotal: 0 }, '', { permissions: ['media-library.read', 'media-library.delete'] });
|
||||
assert.match(html, /data-media-gallery-search/);
|
||||
assert.match(html, /data-media-gallery-type-filter/);
|
||||
assert.match(html, /data-media-gallery-usage-filter/);
|
||||
assert.match(html, /data-media-gallery-sort/);
|
||||
assert.match(html, /class="media-gallery-scroll"/);
|
||||
assert.match(html, /data-media-gallery-no-results>/);
|
||||
});
|
||||
|
||||
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
const events = [];
|
||||
const permissions = [];
|
||||
let exists = true;
|
||||
registerMediaLibraryRoutes({
|
||||
get() {}, post(route, ...middleware) { handlers[route] = middleware.at(-1); }
|
||||
}, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
queries.push({ sql, params });
|
||||
return [sql.startsWith('SELECT') && exists ? [{ id: 9, original_name: 'old.png', media_path: '/media/uploads/uuid.png' }] : []];
|
||||
}
|
||||
}, pages: {}, upload: { single() { return function () {}; } }, uploadDir: os.tmpdir(),
|
||||
getAuditUserId() { return 4; },
|
||||
requirePermission(permission) { permissions.push(permission); return function () {}; },
|
||||
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
||||
});
|
||||
assert.ok(permissions.includes('media-library.update'));
|
||||
const rename = handlers['/media-library/:id/rename'];
|
||||
const response = { statusCode: 200, status(code) { this.statusCode = code; return this; }, json(data) { this.data = data; } };
|
||||
const next = function (error) { throw error; };
|
||||
for (const name of ['', ' ', '../photo.png', 'a\\b.png', 'a\n.png', 'a?b.png', 'a'.repeat(256), ['photo.png'], '..']) {
|
||||
await rename({ params: { id: '9' }, body: { name } }, response, next);
|
||||
assert.equal(response.statusCode, 400);
|
||||
}
|
||||
assert.equal(queries.length, 0);
|
||||
response.statusCode = 200;
|
||||
await rename({ params: { id: '9' }, body: { name: ' New photo.png ' } }, response, next);
|
||||
assert.deepEqual(response.data, { id: 9, name: 'New photo.png' });
|
||||
assert.deepEqual(queries[1], {
|
||||
sql: 'UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', params: ['New photo.png', 4, 9]
|
||||
});
|
||||
assert.equal(events[0].eventType, 'media.renamed');
|
||||
assert.deepEqual(events[0].details, { mediaPath: '/media/uploads/uuid.png', previousName: 'old.png', name: 'New photo.png' });
|
||||
await rename({ params: { id: '9' }, body: { name: 'old.png' } }, response, next);
|
||||
assert.equal(queries.filter(query => query.sql.startsWith('UPDATE')).length, 1);
|
||||
exists = false;
|
||||
await rename({ params: { id: '9' }, body: { name: 'missing.png' } }, response, next);
|
||||
assert.equal(response.statusCode, 404);
|
||||
});
|
||||
|
||||
test('media preview offers filename editing only with media update permission', () => {
|
||||
const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list');
|
||||
const data = { assets: [], assetTotal: 0 };
|
||||
const readOnly = renderMediaLibraryPage(data, '', { permissions: ['media-library.read'] });
|
||||
assert.doesNotMatch(readOnly, /data-media-gallery-rename-form/);
|
||||
const editable = renderMediaLibraryPage(data, '', { permissions: ['media-library.read', 'media-library.update'] });
|
||||
assert.match(editable, /data-media-gallery-rename-form/);
|
||||
assert.match(editable, /maxlength="255" required data-media-gallery-filename/);
|
||||
assert.match(editable, /data-media-gallery-rename-save/);
|
||||
});
|
||||
Reference in new issue
Block a user