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 @@
-