diff --git a/src/webui.js b/src/webui.js index 2d578cd..1bb7ecb 100644 --- a/src/webui.js +++ b/src/webui.js @@ -339,6 +339,108 @@ function createUploadMiddleware(uploadDir) { return multer({ storage }); } +function normalizeUploadReference(uploadPath) { + const value = String(uploadPath || '').trim(); + if (!value || !value.startsWith('/uploads/')) { + return null; + } + return value; +} + +function collectUploadReferencesFromValue(value, refs) { + if (!value) { + return refs; + } + const stack = [value]; + while (stack.length) { + const current = stack.pop(); + if (Array.isArray(current)) { + current.forEach(function (item) { + stack.push(item); + }); + continue; + } + if (current && typeof current === 'object') { + Object.keys(current).forEach(function (key) { + stack.push(current[key]); + }); + continue; + } + if (typeof current === 'string') { + const reference = normalizeUploadReference(current); + if (reference) { + refs.add(reference); + } + } + } + return refs; +} + +function collectUploadReferencesFromSlide(slide) { + const refs = new Set(); + if (!slide) { + return refs; + } + collectUploadReferencesFromValue(slide.media_path, refs); + collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs); + return refs; +} + +function collectUploadReferencesFromTemplate(template) { + const refs = new Set(); + if (!template) { + return refs; + } + collectUploadReferencesFromValue(template.background_image_path, refs); + return refs; +} + +function collectUploadReferencesFromPayload(payload) { + const refs = new Set(); + if (!payload) { + return refs; + } + collectUploadReferencesFromValue(payload.mediaPath, refs); + collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); + collectUploadReferencesFromValue(payload.backgroundImagePath, refs); + return refs; +} + +async function countUploadReferences(pool, uploadPath) { + const [slideRows] = await pool.query( + `SELECT COUNT(*) AS ref_count + FROM slides + WHERE media_path = ? + OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`, + [uploadPath, uploadPath] + ); + const [templateRows] = await pool.query( + 'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?', + [uploadPath] + ); + return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); +} + +async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { + const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); + for (let i = 0; i < uniquePaths.length; i += 1) { + const uploadPath = uniquePaths[i]; + const referenceCount = await countUploadReferences(pool, uploadPath); + if (referenceCount > 0) { + continue; + } + + const filePath = path.join(uploadDir, path.basename(uploadPath)); + try { + await fs.promises.unlink(filePath); + } catch (error) { + if (error && error.code !== 'ENOENT') { + console.warn('Unable to remove unused upload file:', filePath, error); + } + } + } +} + function parseCookies(cookieHeader) { return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { if (!pair) { @@ -1448,12 +1550,17 @@ async function start() { return res.status(404).send('Slide not found'); } const affectedScreens = await fetchScreensBySlideId(pool, slide.id); + const existingUploadRefs = collectUploadReferencesFromSlide(slide); const payload = await common.buildSlidePayload(pool, req, slide); + const nextUploadRefs = collectUploadReferencesFromPayload(payload); const actorId = getAuditUserId(req); await pool.query( 'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?', [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id] ); + await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) { + return !nextUploadRefs.has(reference); + })); await notifyPlayerScreens(affectedScreens, 'refresh'); await broadcastDashboardState(); res.redirect('/admin/slides/' + slide.id + '/edit?message=' + encodeURIComponent('Slide updated.')); @@ -1469,7 +1576,9 @@ async function start() { return res.status(404).send('Slide not found'); } const affectedScreens = await fetchScreensBySlideId(pool, slide.id); + const uploadRefs = collectUploadReferencesFromSlide(slide); await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]); + await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs)); await notifyPlayerScreens(affectedScreens, 'refresh'); await broadcastDashboardState(); res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.')); @@ -1536,12 +1645,17 @@ async function start() { if (!template) { return res.status(404).send('Template not found'); } + const existingUploadRefs = collectUploadReferencesFromTemplate(template); const payload = await common.buildTemplatePayload(pool, req, template); + const nextUploadRefs = collectUploadReferencesFromPayload(payload); const actorId = getAuditUserId(req); await pool.query( 'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, modified_by = ? WHERE id = ?', [payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, template.id] ); + await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) { + return !nextUploadRefs.has(reference); + })); await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); for (let i = 0; i < payload.regions.length; i += 1) { const region = payload.regions[i]; @@ -1562,9 +1676,11 @@ async function start() { if (!template) { return res.status(404).send('Template not found'); } + const uploadRefs = collectUploadReferencesFromTemplate(template); await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]); await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]); + await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs)); res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.')); } catch (error) { next(error);