mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
fix: #10144, automatically delete uploads from disk on post purge, ACP option to keep uploads on disk if desired
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
"private": "Make uploaded files private",
|
"private": "Make uploaded files private",
|
||||||
"strip-exif-data": "Strip EXIF Data",
|
"strip-exif-data": "Strip EXIF Data",
|
||||||
|
"preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged",
|
||||||
"private-extensions": "File extensions to make private",
|
"private-extensions": "File extensions to make private",
|
||||||
"private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. <code>pdf,xls,doc</code>). An empty list means all files are private.",
|
"private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. <code>pdf,xls,doc</code>). An empty list means all files are private.",
|
||||||
"resize-image-width-threshold": "Resize images if they are wider than specified width",
|
"resize-image-width-threshold": "Resize images if they are wider than specified width",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const db = require('../database');
|
|||||||
const image = require('../image');
|
const image = require('../image');
|
||||||
const topics = require('../topics');
|
const topics = require('../topics');
|
||||||
const file = require('../file');
|
const file = require('../file');
|
||||||
|
const meta = require('../meta');
|
||||||
|
|
||||||
module.exports = function (Posts) {
|
module.exports = function (Posts) {
|
||||||
Posts.uploads = {};
|
Posts.uploads = {};
|
||||||
@@ -117,15 +118,35 @@ module.exports = function (Posts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]);
|
const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]);
|
||||||
await Promise.all([
|
const promises = [
|
||||||
db.sortedSetRemove(`post:${pid}:uploads`, filePaths),
|
db.sortedSetRemove(`post:${pid}:uploads`, filePaths),
|
||||||
db.sortedSetRemoveBulk(bulkRemove),
|
db.sortedSetRemoveBulk(bulkRemove),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if (!meta.config.preserveOrphanedUploads) {
|
||||||
|
const deletePaths = (await Promise.all(
|
||||||
|
filePaths.map(async filePath => (await Posts.uploads.isOrphan(filePath) ? filePath : false))
|
||||||
|
)).filter(Boolean);
|
||||||
|
promises.push(Posts.uploads.deleteFromDisk(deletePaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
Posts.uploads.dissociateAll = async (pid) => {
|
Posts.uploads.dissociateAll = async (pid) => {
|
||||||
const current = await Posts.uploads.list(pid);
|
const current = await Posts.uploads.list(pid);
|
||||||
await Promise.all(current.map(async path => await Posts.uploads.dissociate(pid, path)));
|
await Posts.uploads.dissociate(pid, current);
|
||||||
|
};
|
||||||
|
|
||||||
|
Posts.uploads.deleteFromDisk = async (filePaths) => {
|
||||||
|
if (typeof filePaths === 'string') {
|
||||||
|
filePaths = [filePaths];
|
||||||
|
} else if (!Array.isArray(filePaths)) {
|
||||||
|
throw new Error(`[[error:wrong-parameter-type, filePaths, ${typeof filePaths}, array]]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
filePaths = (await _filterValidPaths(filePaths)).map(_getFullPath);
|
||||||
|
await Promise.all(filePaths.map(file.delete));
|
||||||
};
|
};
|
||||||
|
|
||||||
Posts.uploads.saveSize = async (filePaths) => {
|
Posts.uploads.saveSize = async (filePaths) => {
|
||||||
|
|||||||
@@ -8,15 +8,22 @@
|
|||||||
<form>
|
<form>
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
|
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
|
||||||
<input class="mdl-switch__input" type="checkbox" data-field="privateUploads">
|
<input class="mdl-switch__input" type="checkbox" data-field="stripEXIFData">
|
||||||
<span class="mdl-switch__label"><strong>[[admin/settings/uploads:private]]</strong></span>
|
<span class="mdl-switch__label"><strong>[[admin/settings/uploads:strip-exif-data]]</strong></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
|
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
|
||||||
<input class="mdl-switch__input" type="checkbox" data-field="stripEXIFData">
|
<input class="mdl-switch__input" type="checkbox" data-field="preserveOrphanedUploads">
|
||||||
<span class="mdl-switch__label"><strong>[[admin/settings/uploads:strip-exif-data]]</strong></span>
|
<span class="mdl-switch__label"><strong>[[admin/settings/uploads:preserve-orphaned-uploads]]</strong></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox">
|
||||||
|
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
|
||||||
|
<input class="mdl-switch__input" type="checkbox" data-field="privateUploads">
|
||||||
|
<span class="mdl-switch__label"><strong>[[admin/settings/uploads:private]]</strong></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
const nconf = require('nconf');
|
const nconf = require('nconf');
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
@@ -14,6 +15,15 @@ const categories = require('../../src/categories');
|
|||||||
const topics = require('../../src/topics');
|
const topics = require('../../src/topics');
|
||||||
const posts = require('../../src/posts');
|
const posts = require('../../src/posts');
|
||||||
const user = require('../../src/user');
|
const user = require('../../src/user');
|
||||||
|
const meta = require('../../src/meta');
|
||||||
|
const file = require('../../src/file');
|
||||||
|
const utils = require('../../public/src/utils');
|
||||||
|
|
||||||
|
const _filenames = ['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp'];
|
||||||
|
const _recreateFiles = () => {
|
||||||
|
// Create stub files for testing
|
||||||
|
_filenames.forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w')));
|
||||||
|
};
|
||||||
|
|
||||||
describe('upload methods', () => {
|
describe('upload methods', () => {
|
||||||
let pid;
|
let pid;
|
||||||
@@ -22,9 +32,7 @@ describe('upload methods', () => {
|
|||||||
let uid;
|
let uid;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
// Create stub files for testing
|
_recreateFiles();
|
||||||
['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp']
|
|
||||||
.forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w')));
|
|
||||||
|
|
||||||
uid = await user.create({
|
uid = await user.create({
|
||||||
username: 'uploads user',
|
username: 'uploads user',
|
||||||
@@ -225,6 +233,111 @@ describe('upload methods', () => {
|
|||||||
assert.equal(uploads.length, 0);
|
assert.equal(uploads.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Deletion from disk on purge', () => {
|
||||||
|
let postData;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
_recreateFiles();
|
||||||
|
|
||||||
|
({ postData } = await topics.post({
|
||||||
|
uid,
|
||||||
|
cid,
|
||||||
|
title: 'Testing deletion from disk on purge',
|
||||||
|
content: 'these images:  and another ',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await topics.purge(postData.tid, uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should purge the images from disk if the post is purged', async () => {
|
||||||
|
await posts.purge(postData.pid, uid);
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), false);
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave the images behind if `preserveOrphanedUploads` is enabled', async () => {
|
||||||
|
meta.config.preserveOrphanedUploads = 1;
|
||||||
|
|
||||||
|
await posts.purge(postData.pid, uid);
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true);
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), true);
|
||||||
|
|
||||||
|
delete meta.config.preserveOrphanedUploads;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave images behind if they are used in another post', async () => {
|
||||||
|
const { postData: secondPost } = await topics.post({
|
||||||
|
uid,
|
||||||
|
cid,
|
||||||
|
title: 'Second topic',
|
||||||
|
content: 'just abracadabra: ',
|
||||||
|
});
|
||||||
|
|
||||||
|
await posts.purge(secondPost.pid, uid);
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.deleteFromDisk()', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_recreateFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work if you pass in a string path', async () => {
|
||||||
|
await posts.uploads.deleteFromDisk('abracadabra.png');
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/abracadabra.png')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if a non-string or non-array is passed', async () => {
|
||||||
|
try {
|
||||||
|
await posts.uploads.deleteFromDisk({
|
||||||
|
files: ['abracadabra.png'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
assert(!!err);
|
||||||
|
assert.strictEqual(err.message, '[[error:wrong-parameter-type, filePaths, object, array]]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete the files passed in, from disk', async () => {
|
||||||
|
await posts.uploads.deleteFromDisk(['abracadabra.png', 'shazam.jpg']);
|
||||||
|
|
||||||
|
const existsOnDisk = await Promise.all(_filenames.map(async (filename) => {
|
||||||
|
const fullPath = path.resolve(nconf.get('upload_path'), 'files', filename);
|
||||||
|
return file.exists(fullPath);
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepStrictEqual(existsOnDisk, [false, false, true, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delete files if they are not in `uploads/files/` (path traversal)', async () => {
|
||||||
|
const tmpFilePath = path.resolve(os.tmpdir(), `derp${utils.generateUUID()}`);
|
||||||
|
await fs.promises.appendFile(tmpFilePath, '');
|
||||||
|
await posts.uploads.deleteFromDisk(['../files/503.html', tmpFilePath]);
|
||||||
|
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), '../files/503.html')), true);
|
||||||
|
assert.strictEqual(await file.exists(tmpFilePath), true);
|
||||||
|
|
||||||
|
await file.delete(tmpFilePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete files even if they are not orphans', async () => {
|
||||||
|
await topics.post({
|
||||||
|
uid,
|
||||||
|
cid,
|
||||||
|
title: 'To be orphaned',
|
||||||
|
content: 'this image is not an orphan: ',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(await posts.uploads.isOrphan('wut.txt'), false);
|
||||||
|
await posts.uploads.deleteFromDisk(['wut.txt']);
|
||||||
|
|
||||||
|
assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/wut.txt')), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('post uploads management', () => {
|
describe('post uploads management', () => {
|
||||||
@@ -234,9 +347,7 @@ describe('post uploads management', () => {
|
|||||||
let cid;
|
let cid;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
// Create stub files for testing
|
_recreateFiles();
|
||||||
['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp']
|
|
||||||
.forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w')));
|
|
||||||
|
|
||||||
uid = await user.create({
|
uid = await user.create({
|
||||||
username: 'uploads user',
|
username: 'uploads user',
|
||||||
|
|||||||
Reference in New Issue
Block a user