diff --git a/install/data/defaults.json b/install/data/defaults.json index fefa23b8ea..ee355aadab 100644 --- a/install/data/defaults.json +++ b/install/data/defaults.json @@ -45,6 +45,7 @@ "allowMultipleBadges": 0, "maximumFileSize": 2048, "stripEXIFData": 1, + "orphanExpiryDays": 0, "resizeImageWidthThreshold": 2000, "resizeImageWidth": 760, "rejectImageWidth": 5000, diff --git a/public/language/en-GB/admin/settings/uploads.json b/public/language/en-GB/admin/settings/uploads.json index af99a3ae77..5ca546848c 100644 --- a/public/language/en-GB/admin/settings/uploads.json +++ b/public/language/en-GB/admin/settings/uploads.json @@ -1,8 +1,10 @@ { "posts": "Posts", + "orphans": "Orphaned Files", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", + "orphanExpiryDays": "Days to keep orphaned files", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/src/posts/uploads.js b/src/posts/uploads.js index 2f7d1a0b2f..95b2be22b0 100644 --- a/src/posts/uploads.js +++ b/src/posts/uploads.js @@ -1,11 +1,13 @@ 'use strict'; const nconf = require('nconf'); +const fs = require('fs').promises; const crypto = require('crypto'); const path = require('path'); const winston = require('winston'); const mime = require('mime'); const validator = require('validator'); +const cronJob = require('cron').CronJob; const db = require('../database'); const image = require('../image'); @@ -27,6 +29,29 @@ module.exports = function (Posts) { return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false; }))).filter(Boolean); + const runJobs = nconf.get('runJobs'); + if (runJobs) { + new cronJob('0 2 * * 0', (async () => { + const now = Date.now(); + const days = meta.config.orphanExpiryDays; + if (!days) { + return; + } + + let orphans = await Posts.uploads.getOrphans(); + + orphans = await Promise.all(orphans.map(async (relPath) => { + const { mtimeMs } = await fs.stat(_getFullPath(relPath)); + return mtimeMs < now - (1000 * 60 * 60 * 24 * meta.config.orphanExpiryDays) ? relPath : null; + })); + orphans = orphans.filter(Boolean); + + orphans.forEach((relPath) => { + file.delete(_getFullPath(relPath)); + }); + }), null, true); + } + Posts.uploads.sync = async function (pid) { // Scans a post's content and updates sorted set of uploads @@ -78,6 +103,16 @@ module.exports = function (Posts) { })); }; + Posts.uploads.getOrphans = async () => { + let files = await fs.readdir(_getFullPath('/files')); + files = files.filter(filename => filename !== '.gitignore'); + + files = await Promise.all(files.map(async filename => (await Posts.uploads.isOrphan(`files/${filename}`) ? `files/${filename}` : null))); + files = files.filter(Boolean); + + return files; + }; + Posts.uploads.isOrphan = async function (filePath) { const length = await db.sortedSetCard(`upload:${md5(filePath)}:pids`); return length === 0; diff --git a/src/views/admin/settings/uploads.tpl b/src/views/admin/settings/uploads.tpl index b2b1bb8c47..8cb352c987 100644 --- a/src/views/admin/settings/uploads.tpl +++ b/src/views/admin/settings/uploads.tpl @@ -13,13 +13,6 @@ -
- -
-
+ +
+
+ [[admin/settings/uploads:orphans]] +
+
+
+ +
+ +
+
+ + +
+
+