diff --git a/public/language/en-GB/admin/manage/uploads.json b/public/language/en-GB/admin/manage/uploads.json index 21bc8201fc..72a695ccdc 100644 --- a/public/language/en-GB/admin/manage/uploads.json +++ b/public/language/en-GB/admin/manage/uploads.json @@ -5,5 +5,7 @@ "orphaned": "Orphaned", "size/filecount": "Size / Filecount", "confirm-delete": "Do you really want to delete this file?", - "filecount": "%1 files" + "filecount": "%1 files", + "new-folder": "New Folder", + "name-new-folder": "Enter a name for new the folder" } \ No newline at end of file diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index a6c5bfb8aa..1891190106 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -28,6 +28,8 @@ "invalid-event": "Invalid event: %1", "local-login-disabled": "Local login system has been disabled for non-privileged accounts.", "csrf-invalid": "We were unable to log you in, likely due to an expired session. Please try again", + "invalid-path": "Invalid path", + "folder-exists": "Folder exists", "invalid-pagination-value": "Invalid pagination value, must be at least %1 and at most %2", diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index abad800516..8ac39fe982 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -143,4 +143,6 @@ paths: /admin/analytics/{set}: $ref: 'write/admin/analytics/set.yaml' /files/: - $ref: 'write/files.yaml' \ No newline at end of file + $ref: 'write/files.yaml' + /files/folder: + $ref: 'write/files/folder.yaml' \ No newline at end of file diff --git a/public/openapi/write/files/folder.yaml b/public/openapi/write/files/folder.yaml new file mode 100644 index 0000000000..84295a2917 --- /dev/null +++ b/public/openapi/write/files/folder.yaml @@ -0,0 +1,36 @@ +put: + tags: + - files + summary: create a new folder + description: This operation creates a new folder inside upload path + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + path: + type: string + description: Path to the file (relative to the configured `upload_path`) + example: /files + folderName: + type: string + description: New folder name + example: myfiles + required: + - path + - folderName + responses: + '200': + description: Folder created + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/src/admin/manage/uploads.js b/public/src/admin/manage/uploads.js index 253e5ac1af..6ed59d2f77 100644 --- a/public/src/admin/manage/uploads.js +++ b/public/src/admin/manage/uploads.js @@ -1,7 +1,6 @@ 'use strict'; - -define('admin/manage/uploads', ['uploader', 'api'], function (uploader, api) { +define('admin/manage/uploads', ['api', 'bootbox', 'uploader'], function (api, bootbox, uploader) { var Uploads = {}; Uploads.init = function () { @@ -29,6 +28,21 @@ define('admin/manage/uploads', ['uploader', 'api'], function (uploader, api) { }).catch(app.alertError); }); }); + + $('#new-folder').on('click', async function () { + bootbox.prompt('[[admin/manage/uploads:name-new-folder]]', (newFolderName) => { + if (!newFolderName || !newFolderName.trim()) { + return; + } + + api.put('/files/folder', { + path: ajaxify.data.currentFolder, + folderName: newFolderName, + }).then(() => { + ajaxify.refresh(); + }).catch(app.alertError); + }); + }); }; return Uploads; diff --git a/src/controllers/write/files.js b/src/controllers/write/files.js index 564424f5cd..61a6320094 100644 --- a/src/controllers/write/files.js +++ b/src/controllers/write/files.js @@ -9,3 +9,8 @@ Files.delete = async (req, res) => { await fs.unlink(res.locals.cleanedPath); helpers.formatApiResponse(200, res); }; + +Files.createFolder = async (req, res) => { + await fs.mkdir(res.locals.folderPath); + helpers.formatApiResponse(200, res); +}; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index c92d8d2d13..49718cba14 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -14,6 +14,7 @@ const user = require('../user'); const groups = require('../groups'); const topics = require('../topics'); const posts = require('../posts'); +const slugify = require('../slugify'); const helpers = require('./helpers'); const controllerHelpers = require('../controllers/helpers'); @@ -86,3 +87,20 @@ Assert.path = helpers.try(async (req, res, next) => { next(); }); + +Assert.folderName = helpers.try(async (req, res, next) => { + const folderName = slugify(path.basename(req.body.folderName.trim())); + const folderPath = path.join(res.locals.cleanedPath, folderName); + + // slugify removes invalid characters, folderName may become empty + if (!folderName) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:invalid-path]]')); + } + if (await file.exists(folderPath)) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:folder-exists]]')); + } + + res.locals.folderPath = folderPath; + + next(); +}); diff --git a/src/routes/write/files.js b/src/routes/write/files.js index f0d2aab037..97ddde6d01 100644 --- a/src/routes/write/files.js +++ b/src/routes/write/files.js @@ -8,7 +8,7 @@ const routeHelpers = require('../helpers'); const { setupApiRoute } = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn, middleware.admin.checkPrivileges]; // setupApiRoute(router, 'put', '/', [ // ...middlewares, @@ -21,5 +21,13 @@ module.exports = function () { middleware.assert.path, ], controllers.write.files.delete); + setupApiRoute(router, 'put', '/folder', [ + ...middlewares, + middleware.checkRequired.bind(null, ['path', 'folderName']), + middleware.assert.path, + // Should come after assert.path + middleware.assert.folderName, + ], controllers.write.files.createFolder); + return router; }; diff --git a/src/views/admin/manage/uploads.tpl b/src/views/admin/manage/uploads.tpl index bec935d1af..e478d2b023 100644 --- a/src/views/admin/manage/uploads.tpl +++ b/src/views/admin/manage/uploads.tpl @@ -1,6 +1,13 @@
- +
+
+ +
+
+ +
+
diff --git a/test/helpers/index.js b/test/helpers/index.js index 6f9710faf7..ecb2ebd0a4 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -183,4 +183,20 @@ helpers.invite = async function (body, uid, jar, csrf_token) { return { res, body }; }; +helpers.createFolder = function (path, folderName, jar, csrf_token) { + return requestAsync.put(`${nconf.get('url')}/api/v3/files/folder`, { + jar, + body: { + path, + folderName, + }, + json: true, + headers: { + 'x-csrf-token': csrf_token, + }, + simple: false, + resolveWithFullResponse: true, + }); +}; + require('../../src/promisify')(helpers); diff --git a/test/uploads.js b/test/uploads.js index 27facd2e67..6fbf1e4f11 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -1,10 +1,11 @@ 'use strict'; const async = require('async'); -const assert = require('assert'); +const assert = require('assert'); const nconf = require('nconf'); const path = require('path'); const request = require('request'); +const requestAsync = require('request-promise-native'); const db = require('./mocks/databasemock'); const categories = require('../src/categories'); @@ -372,14 +373,28 @@ describe('Upload Controllers', () => { describe('admin uploads', () => { let jar; let csrf_token; + let regularJar; + let regular_csrf_token; before((done) => { - helpers.loginUser('admin', 'barbar', (err, _jar, _csrf_token) => { - assert.ifError(err); - jar = _jar; - csrf_token = _csrf_token; - done(); - }); + async.parallel([ + function (next) { + helpers.loginUser('admin', 'barbar', (err, _jar, _csrf_token) => { + assert.ifError(err); + jar = _jar; + csrf_token = _csrf_token; + next(); + }); + }, + function (next) { + helpers.loginUser('regular', 'zugzug', (err, _jar, _csrf_token) => { + assert.ifError(err); + regularJar = _jar; + regular_csrf_token = _csrf_token; + next(); + }); + }, + ], done); }); it('should upload site logo', (done) => { @@ -490,5 +505,67 @@ describe('Upload Controllers', () => { done(); }); }); + + describe('ACP uploads screen', () => { + it('should create a folder', async () => { + const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 200); + assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder'))); + }); + + it('should fail to create a folder if it already exists', async () => { + const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'Folder exists', + }); + }); + + it('should fail to create a folder as a non-admin', async () => { + const res = await helpers.createFolder('', 'hisfolder', regularJar, regular_csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'You are not authorised to make this call', + }); + }); + + it('should fail to create a folder in wrong directory', async () => { + const res = await helpers.createFolder('../traversing', 'unexpectedfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'Invalid path', + }); + }); + + it('should use basename of given folderName to create new folder', async () => { + const res = await helpers.createFolder('/myfolder', '../another folder', jar, csrf_token); + assert.strictEqual(res.statusCode, 200); + const slugifiedName = 'another-folder'; + assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder', slugifiedName))); + }); + + it('should fail to delete a file as a non-admin', async () => { + const res = await requestAsync.delete(`${nconf.get('url')}/api/v3/files`, { + body: { + path: '/system/test.png', + }, + jar: regularJar, + json: true, + headers: { + 'x-csrf-token': regular_csrf_token, + }, + simple: false, + resolveWithFullResponse: true, + }); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'You are not authorised to make this call', + }); + }); + }); }); });