From c7506d77b07c24d0d232828b7a3739d8b27d4268 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?=
 <baris@nodebb.org>
Date: Fri, 26 Jan 2018 18:56:17 -0500
Subject: [PATCH] closes #6247

---
 .../language/en-GB/admin/manage/uploads.json  |   7 ++
 public/language/en-GB/admin/menu.json         |   1 +
 public/src/admin/manage/uploads.js            |  35 ++++++
 src/controllers/admin/uploads.js              | 113 ++++++++++++++++++
 src/file.js                                   |   2 +-
 src/pagination.js                             |   5 +-
 src/routes/admin.js                           |   3 +
 src/socket.io/admin.js                        |  13 ++
 src/views/admin/manage/uploads.tpl            |  39 ++++++
 src/views/admin/partials/menu.tpl             |   2 +
 10 files changed, 215 insertions(+), 5 deletions(-)
 create mode 100644 public/language/en-GB/admin/manage/uploads.json
 create mode 100644 public/src/admin/manage/uploads.js
 create mode 100644 src/views/admin/manage/uploads.tpl

diff --git a/public/language/en-GB/admin/manage/uploads.json b/public/language/en-GB/admin/manage/uploads.json
new file mode 100644
index 0000000000..13e69cafa7
--- /dev/null
+++ b/public/language/en-GB/admin/manage/uploads.json
@@ -0,0 +1,7 @@
+{
+    "upload-file": "Upload File",
+    "filename": "Filename",
+    "size/filecount": "Size / Filecount",
+    "confirm-delete": "Do you really want to delete this file?",
+    "filecount": "%1 files"
+}
\ No newline at end of file
diff --git a/public/language/en-GB/admin/menu.json b/public/language/en-GB/admin/menu.json
index 8f44bcd304..51099e9af4 100644
--- a/public/language/en-GB/admin/menu.json
+++ b/public/language/en-GB/admin/menu.json
@@ -17,6 +17,7 @@
 	"manage/post-queue": "Post Queue",
 	"manage/groups": "Groups",
 	"manage/ip-blacklist": "IP Blacklist",
+	"manage/uploads": "Uploads",
 
 	"section-settings": "Settings",
 	"settings/general": "General",
diff --git a/public/src/admin/manage/uploads.js b/public/src/admin/manage/uploads.js
new file mode 100644
index 0000000000..98da2ef557
--- /dev/null
+++ b/public/src/admin/manage/uploads.js
@@ -0,0 +1,35 @@
+'use strict';
+
+
+define('admin/manage/uploads', ['uploader'], function (uploader) {
+	var Uploads = {};
+
+	Uploads.init = function () {
+		$('#upload').on('click', function () {
+			uploader.show({
+				title: '[[admin/manage/uploads:upload-file]]',
+				route: config.relative_path + '/api/admin/upload/file',
+				params: { folder: ajaxify.data.currentFolder },
+			}, function () {
+				ajaxify.refresh();
+			});
+		});
+
+		$('.delete').on('click', function () {
+			var file = $(this).parents('[data-path]');
+			bootbox.confirm('[[admin/manage/uploads:confirm-delete]]', function (ok) {
+				if (!ok) {
+					return;
+				}
+				socket.emit('admin.uploads.delete', file.attr('data-path'), function (err) {
+					if (err) {
+						return app.alertError(err.message);
+					}
+					file.remove();
+				});
+			});
+		});
+	};
+
+	return Uploads;
+});
diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js
index dc5bd7ce37..db26284205 100644
--- a/src/controllers/admin/uploads.js
+++ b/src/controllers/admin/uploads.js
@@ -4,16 +4,109 @@ var path = require('path');
 var async = require('async');
 var nconf = require('nconf');
 var mime = require('mime');
+var fs = require('fs');
 
 var meta = require('../../meta');
 var file = require('../../file');
 var image = require('../../image');
 var plugins = require('../../plugins');
+var pagination = require('../../pagination');
 
 var allowedImageTypes = ['image/png', 'image/jpeg', 'image/pjpeg', 'image/jpg', 'image/gif', 'image/svg+xml'];
 
 var uploadsController = module.exports;
 
+uploadsController.get = function (req, res, next) {
+	var currentFolder = path.join(nconf.get('upload_path'), req.query.dir || '');
+	if (!currentFolder.startsWith(nconf.get('upload_path'))) {
+		return next(new Error('[[error:invalid-path]]'));
+	}
+	var itemsPerPage = 20;
+	var itemCount = 0;
+	var page = parseInt(req.query.page, 10) || 1;
+	async.waterfall([
+		function (next) {
+			fs.readdir(currentFolder, next);
+		},
+		function (files, next) {
+			files = files.filter(function (filename) {
+				return filename !== '.gitignore';
+			});
+
+			itemCount = files.length;
+			var start = Math.max(0, (page - 1) * itemsPerPage);
+			var stop = start + itemsPerPage;
+			files = files.slice(start, stop);
+
+			filesToData(currentFolder, files, next);
+		},
+		function (files) {
+			files.sort(function (a, b) {
+				if (a.isDirectory && !b.isDirectory) {
+					return -1;
+				} else if (!a.isDirectory && b.isDirectory) {
+					return 1;
+				}
+				return 0;
+			});
+			res.render('admin/manage/uploads', {
+				currentFolder: currentFolder.replace(nconf.get('upload_path'), ''),
+				files: files,
+				breadcrumbs: buildBreadcrumbs(currentFolder),
+				pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), req.query),
+			});
+		},
+	], next);
+};
+
+function buildBreadcrumbs(currentFolder) {
+	var crumbs = [];
+	var parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep);
+	var currentPath = '';
+	parts.forEach(function (part) {
+		var dir = path.join(currentPath, part);
+		crumbs.push({
+			text: part || 'Uploads',
+			url: part ? '/admin/manage/uploads?dir=' + dir : '/admin/manage/uploads',
+		});
+		currentPath = dir;
+	});
+
+	return crumbs;
+}
+
+function filesToData(currentDir, files, callback) {
+	async.map(files, function (file, next) {
+		var stat;
+		async.waterfall([
+			function (next) {
+				fs.stat(path.join(currentDir, file), next);
+			},
+			function (_stat, next) {
+				stat = _stat;
+				if (stat.isDirectory()) {
+					fs.readdir(path.join(currentDir, file), next);
+				} else {
+					next(null, []);
+				}
+			},
+			function (filesInDir, next) {
+				var url = nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '') + '/' + file;
+				next(null, {
+					name: file,
+					path: path.join(currentDir, file).replace(nconf.get('upload_path'), ''),
+					url: url,
+					fileCount: filesInDir.length - 1, // ignore .gitignore
+					size: stat.size,
+					sizeHumanReadable: (stat.size / 1024).toFixed(1) + 'KiB',
+					isDirectory: stat.isDirectory(),
+					isFile: stat.isFile(),
+				});
+			},
+		], next);
+	}, callback);
+}
+
 uploadsController.uploadCategoryPicture = function (req, res, next) {
 	var uploadedFile = req.files.files[0];
 	var params = null;
@@ -110,6 +203,25 @@ uploadsController.uploadSound = function (req, res, next) {
 	});
 };
 
+uploadsController.uploadFile = function (req, res, next) {
+	var uploadedFile = req.files.files[0];
+	var params;
+	try {
+		params = JSON.parse(req.body.params);
+	} catch (e) {
+		file.delete(uploadedFile.path);
+		return next(new Error('[[error:invalid-json]]'));
+	}
+
+	file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path, function (err, data) {
+		file.delete(uploadedFile.path);
+		if (err) {
+			return next(err);
+		}
+		res.json([{ url: data.url }]);
+	});
+};
+
 uploadsController.uploadDefaultAvatar = function (req, res, next) {
 	upload('avatar-default', req, res, next);
 };
@@ -173,3 +285,4 @@ function uploadImage(filename, folder, uploadedFile, req, res, next) {
 		res.json([{ name: uploadedFile.name, url: image.url.startsWith('http') ? image.url : nconf.get('relative_path') + image.url }]);
 	});
 }
+
diff --git a/src/file.js b/src/file.js
index 8027f7ad77..e31ae18399 100644
--- a/src/file.js
+++ b/src/file.js
@@ -81,7 +81,7 @@ file.saveFileToLocal = function (filename, folder, tempPath, callback) {
 			}
 
 			callback(null, {
-				url: '/assets/uploads/' + folder + '/' + filename,
+				url: '/assets/uploads/' + (folder ? folder + '/' : '') + filename,
 				path: uploadPath,
 			});
 		});
diff --git a/src/pagination.js b/src/pagination.js
index 8405fafd50..5789573b92 100644
--- a/src/pagination.js
+++ b/src/pagination.js
@@ -3,7 +3,7 @@
 var qs = require('querystring');
 var _ = require('lodash');
 
-var pagination = {};
+var pagination = module.exports;
 
 pagination.create = function (currentPage, pageCount, queryObj) {
 	if (pageCount <= 1) {
@@ -76,6 +76,3 @@ pagination.create = function (currentPage, pageCount, queryObj) {
 	}
 	return data;
 };
-
-
-module.exports = pagination;
diff --git a/src/routes/admin.js b/src/routes/admin.js
index db0ce7798c..98008cfeec 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -17,6 +17,7 @@ function apiRoutes(router, middleware, controllers) {
 	router.post('/uploadlogo', middlewares, controllers.admin.uploads.uploadLogo);
 	router.post('/uploadOgImage', middlewares, controllers.admin.uploads.uploadOgImage);
 	router.post('/upload/sound', middlewares, controllers.admin.uploads.uploadSound);
+	router.post('/upload/file', middlewares, controllers.admin.uploads.uploadFile);
 	router.post('/uploadDefaultAvatar', middlewares, controllers.admin.uploads.uploadDefaultAvatar);
 }
 
@@ -77,6 +78,8 @@ function addRoutes(router, middleware, controllers) {
 	router.get('/manage/groups', middlewares, controllers.admin.groups.list);
 	router.get('/manage/groups/:name', middlewares, controllers.admin.groups.get);
 
+	router.get('/manage/uploads', middlewares, controllers.admin.uploads.get);
+
 	router.get('/settings/:term?', middlewares, controllers.admin.settings.get);
 
 	router.get('/appearance/:term?', middlewares, controllers.admin.appearance.get);
diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js
index 628bbd97da..668dea0dbc 100644
--- a/src/socket.io/admin.js
+++ b/src/socket.io/admin.js
@@ -2,6 +2,9 @@
 
 var async = require('async');
 var winston = require('winston');
+var fs = require('fs');
+var path = require('path');
+var nconf = require('nconf');
 
 var meta = require('../meta');
 var plugins = require('../plugins');
@@ -37,6 +40,7 @@ var SocketAdmin = {
 	analytics: {},
 	logs: {},
 	errors: {},
+	uploads: {},
 };
 
 SocketAdmin.before = function (socket, method, data, next) {
@@ -336,4 +340,13 @@ SocketAdmin.reloadAllSessions = function (socket, data, callback) {
 	callback();
 };
 
+SocketAdmin.uploads.delete = function (socket, pathToFile, callback) {
+	pathToFile = path.join(nconf.get('upload_path'), pathToFile);
+	if (!pathToFile.startsWith(nconf.get('upload_path'))) {
+		return callback(new Error('[[error:invalid-path]]'));
+	}
+
+	fs.unlink(pathToFile, callback);
+};
+
 module.exports = SocketAdmin;
diff --git a/src/views/admin/manage/uploads.tpl b/src/views/admin/manage/uploads.tpl
new file mode 100644
index 0000000000..0af5bd43c8
--- /dev/null
+++ b/src/views/admin/manage/uploads.tpl
@@ -0,0 +1,39 @@
+<!-- IMPORT partials/breadcrumbs.tpl -->
+<div class="clearfix">
+	<button id="upload" class="btn-success pull-right"><i class="fa fa-upload"></i> [[global:upload]]</button>
+</div>
+
+<div class="table-responsive">
+	<table class="table table-striped users-table">
+		<thead>
+			<tr>
+				<th>[[admin/manage/uploads:filename]]</th>
+				<th class="text-right">[[admin/manage/uploads:size/filecount]]</th>
+				<th></th>
+			</tr>
+		</thead>
+		<tbody>
+			<!-- BEGIN files -->
+			<tr data-path="{files.path}">
+				<!-- IF files.isDirectory -->
+				<td class="col-md-9" role="button">
+					<i class="fa fa-fw fa-folder-o"></i> <a href="{config.relative}/admin/manage/uploads?dir={files.path}">{files.name}</a>
+				</td>
+				<!-- ENDIF files.isDirectory -->
+
+				<!-- IF files.isFile -->
+				<td class="col-md-9">
+					<i class="fa fa-fw fa-file-text-o"></i> <a href="{config.relative_path}{files.url}" target="_blank">{files.name}</a>
+				</td>
+				<!-- ENDIF files.isFile -->
+
+				<td class="col-md-2 text-right"><!-- IF files.size -->{files.sizeHumanReadable}<!-- ELSE -->[[admin/manage/uploads:filecount, {files.fileCount}]]<!-- ENDIF files.size --></td>
+
+				<td role="button" class="col-md-1 text-right"><i class="delete fa fa-fw fa-trash-o <!-- IF !files.isFile --> hidden<!-- ENDIF !files.isFile -->"></i></td>
+			</tr>
+			<!-- END files -->
+		</tbody>
+	</table>
+</div>
+
+<!-- IMPORT partials/paginator.tpl -->
\ No newline at end of file
diff --git a/src/views/admin/partials/menu.tpl b/src/views/admin/partials/menu.tpl
index 87d9bfeecb..24fe0becb4 100644
--- a/src/views/admin/partials/menu.tpl
+++ b/src/views/admin/partials/menu.tpl
@@ -23,6 +23,7 @@
 			<li><a href="{relative_path}/admin/manage/registration">[[admin/menu:manage/registration]]</a></li>
 			<li><a href="{relative_path}/admin/manage/post-queue">[[admin/menu:manage/post-queue]]</a></li>
 			<li><a href="{relative_path}/admin/manage/ip-blacklist">[[admin/menu:manage/ip-blacklist]]</a></li>
+			<li><a href="{relative_path}/admin/manage/uploads">[[admin/menu:manage/uploads]]</a></li>
 		</ul>
 	</section>
 
@@ -198,6 +199,7 @@
 					<li><a href="{relative_path}/admin/manage/registration">[[admin/menu:manage/registration]]</a></li>
 					<li><a href="{relative_path}/admin/manage/post-queue">[[admin/menu:manage/post-queue]]</a></li>
 					<li><a href="{relative_path}/admin/manage/ip-blacklist">[[admin/menu:manage/ip-blacklist]]</a></li>
+					<li><a href="{relative_path}/admin/manage/uploads">[[admin/menu:manage/uploads]]</a></li>
 				</ul>
 			</li>
 			<li class="dropdown menu-item">