feat: create folders in ACP uploads #9638 (#9750)

* feat: create folders in ACP uploads #9638

* fix: openapi

* test: missing tests

* fix: eslint

* fix: tests
isekai-main
gasoved 3 years ago committed by GitHub
parent f2028d7009
commit 3df79683f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -144,3 +144,5 @@ paths:
$ref: 'write/admin/analytics/set.yaml'
/files/:
$ref: 'write/files.yaml'
/files/folder:
$ref: '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: {}

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

@ -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);
};

@ -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();
});

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

@ -1,6 +1,13 @@
<!-- 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 class="pull-right">
<div class="btn-group">
<button id="new-folder" class="btn-primary"><i class="fa fa-folder"></i> [[admin/manage/uploads:new-folder]]</button>
</div>
<div class="btn-group">
<button id="upload" class="btn-success"><i class="fa fa-upload"></i> [[global:upload]]</button>
</div>
</div>
</div>
<div class="table-responsive">

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

@ -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',
});
});
});
});
});

Loading…
Cancel
Save