diff --git a/backend/Controllers/FileController.php b/backend/Controllers/FileController.php index 323ef4f..fa38fcd 100644 --- a/backend/Controllers/FileController.php +++ b/backend/Controllers/FileController.php @@ -143,6 +143,20 @@ class FileController return $response->json('Done'); } + + public function chmodItems(Request $request, Response $response) + { + $items = $request->input('items', []); + $permissions = $request->input('permissions', 0); + /** @var null|'all'|'folders'|'files' */ + $recursive = $request->input('recursive', null); + + foreach ($items as $item) { + $this->storage->chmod($item->path, $permissions, $recursive); + } + + return $response->json('Done'); + } public function renameItem(Request $request, Response $response) { diff --git a/backend/Controllers/routes.php b/backend/Controllers/routes.php index b11298d..6042911 100644 --- a/backend/Controllers/routes.php +++ b/backend/Controllers/routes.php @@ -138,6 +138,17 @@ return [ 'read', 'write', 'zip', ], ], + [ + 'route' => [ + 'POST', '/chmoditems', '\Filegator\Controllers\FileController@chmodItems', + ], + 'roles' => [ + 'guest', 'user', 'admin', + ], + 'permissions' => [ + 'read', 'write', 'chmod', + ], + ], [ 'route' => [ 'POST', '/deleteitems', '\Filegator\Controllers\FileController@deleteItems', diff --git a/backend/Services/Auth/Adapters/LDAP.php b/backend/Services/Auth/Adapters/LDAP.php index e5d8bab..872ea36 100644 --- a/backend/Services/Auth/Adapters/LDAP.php +++ b/backend/Services/Auth/Adapters/LDAP.php @@ -218,7 +218,7 @@ class LDAP implements Service, AuthInterface // ...but not for admins if ($user['role'] == 'admin'){ $user['homedir'] = '/'; - $user['permissions'] = 'read|write|upload|download|batchdownload|zip'; + $user['permissions'] = 'read|write|upload|download|batchdownload|zip|chmod'; } if(is_array($user) && !empty($user)) $users[] = $user; diff --git a/backend/Services/Auth/User.php b/backend/Services/Auth/User.php index 758d988..c0ece05 100644 --- a/backend/Services/Auth/User.php +++ b/backend/Services/Auth/User.php @@ -24,7 +24,7 @@ class User implements \JsonSerializable protected $available_roles = ['guest', 'user', 'admin']; - protected $available_permissions = ['read', 'write', 'upload', 'download', 'batchdownload', 'zip']; + protected $available_permissions = ['read', 'write', 'upload', 'download', 'batchdownload', 'zip', 'chmod']; public function __construct() { diff --git a/backend/Services/Storage/Adapters/FilegatorFtp.php b/backend/Services/Storage/Adapters/FilegatorFtp.php new file mode 100644 index 0000000..995cfb9 --- /dev/null +++ b/backend/Services/Storage/Adapters/FilegatorFtp.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE file + */ + +namespace Filegator\Services\Storage\Adapters; + +use League\Flysystem\Adapter\Ftp; +use League\Flysystem\NotSupportedException; + +class FilegatorFtp extends Ftp +{ + + /** + * Normalize a file entry. + * + * @param string $item + * @param string $base + * + * @return array normalized file array + * + * @throws NotSupportedException + */ + protected function normalizeObject($item, $base) + { + $systemType = $this->systemType ?: $this->detectSystemType($item); + + if ($systemType === 'unix') { + $result = $this->normalizeUnixObject($item, $base); + return $this->afterNormalizeUnixObject($result, $item, $base); + } elseif ($systemType === 'windows') { + $result = $this->normalizeWindowsObject($item, $base); + return $this->afterNormalizeWindowsObject($result, $item, $base); + } + + throw NotSupportedException::forFtpSystemType($systemType); + } + + /** + * Normalize a Unix file entry, with permissions. + * + * Given $item contains: + * '-rw-r--r-- 1 ftp ftp 409 Aug 19 09:01 file1.txt' + * + * This function will return: + * [ + * 'type' => 'file', + * 'path' => 'file1.txt', + * 'visibility' => 'public', + * 'size' => 409, + * 'timestamp' => 1566205260, + * 'permissions' => 644 + * ] + * + * @param array $result original normalized file array + * @param string $item + * @param string $base + * + * @return array normalized file array + */ + protected function afterNormalizeUnixObject($result, $item, $base) + { + $item = preg_replace('#\s+#', ' ', trim($item), 7); + list($permissions, /* $number */, /* $owner */, /* $group */, $size, $month, $day, $timeOrYear, $name) = explode(' ', $item, 9); + $permissions = $this->normalizePermissions($permissions); + + $result['permissions'] = decoct($permissions); + return $result; + } + + /** + * Normalize a Windows/DOS file entry, with permissions. + * + * @param array $result original normalized file array + * @param string $item + * @param string $base + * + * @return array normalized file array + */ + protected function afterNormalizeWindowsObject($result, $item, $base) + { + $result['permissions'] = 777; + return $result; + } + +} \ No newline at end of file diff --git a/backend/Services/Storage/DirectoryCollection.php b/backend/Services/Storage/DirectoryCollection.php index a4816d1..91d4771 100644 --- a/backend/Services/Storage/DirectoryCollection.php +++ b/backend/Services/Storage/DirectoryCollection.php @@ -23,7 +23,7 @@ class DirectoryCollection implements \JsonSerializable $this->location = $location; } - public function addFile(string $type, string $path, string $name, int $size, int $timestamp) + public function addFile(string $type, string $path, string $name, int $size, int $timestamp, int $permissions) { if (! in_array($type, ['dir', 'file', 'back'])) { throw new \Exception('Invalid file type.'); @@ -35,6 +35,7 @@ class DirectoryCollection implements \JsonSerializable 'name' => $name, 'size' => $size, 'time' => $timestamp, + 'permissions' => $permissions, ]); } diff --git a/backend/Services/Storage/Filesystem.php b/backend/Services/Storage/Filesystem.php index fe3807f..cc08037 100644 --- a/backend/Services/Storage/Filesystem.php +++ b/backend/Services/Storage/Filesystem.php @@ -12,6 +12,7 @@ namespace Filegator\Services\Storage; use Filegator\Services\Service; use League\Flysystem\Filesystem as Flysystem; +use League\Flysystem\Util; class Filesystem implements Service { @@ -176,6 +177,72 @@ class Filesystem implements Service return $this->storage->putStream($destination, $resource); } + + /** + * Change file permissions one item, with optional recursion + * + * @param string $path + * @param int $permissions + * @param null|'all'|'folders'|'files' $recursive + * @return bool + * @throws \Exception + */ + public function chmod(string $path, int $permissions, string $recursive = null) + { + $path = $this->applyPathPrefix($path); + $path = Util::normalizePath($path); + $adapter = $this->storage->getAdapter(); + + $mainResult = $this->chmodItem($path, $permissions); + if ($recursive !== null) { + if (method_exists($adapter, 'setRecurseManually')) { + $adapter->setRecurseManually(true); // this is needed for ftp driver + } + $contents = $this->storage->listContents($path, true); + foreach ($contents as $item) { + try { + if ($item['type'] == 'dir' && ($recursive == 'all' || $recursive == 'folders')) { + $this->chmodItem($item['path'], $permissions); + } + if ($item['type'] == 'file' && ($recursive == 'all' || $recursive == 'files')) { + $this->chmodItem($item['path'], $permissions); + } + } catch (\Exception $e) { + continue; + } + } + } + + return $mainResult; + } + /** + * Change file permissions for a single item + * + * @param string $path + * @param int $permissions + * @return bool + * @throws \Exception + */ + public function chmodItem(string $path, int $permissions) + { + $adapter = $this->storage->getAdapter(); + + switch (get_class($adapter)) { + case 'League\Flysystem\Adapter\Local': + $absolutePath = $adapter->applyPathPrefix($path); + return chmod($absolutePath, octdec($permissions)); + break; + case 'League\Flysystem\Sftp\SftpAdapter': + return $adapter->getConnection()->chmod($path, octdec($permissions)); + break; + case 'Filegator\Services\Storage\Adapters\FilegatorFtp': + return ftp_chmod($adapter->getConnection(), octdec($permissions), $path) !== false; + break; + default: + throw new \Exception('Selected adapter does not support unix permissions'); + break; + } + } public function setPathPrefix(string $path_prefix) { @@ -204,16 +271,39 @@ class Filesystem implements Service $dirname = isset($entry['dirname']) ? $entry['dirname'] : $path; $size = isset($entry['size']) ? $entry['size'] : 0; $timestamp = isset($entry['timestamp']) ? $entry['timestamp'] : 0; + $permissions = $this->getPermissions($entry); - $collection->addFile($entry['type'], $userpath, $name, $size, $timestamp); + $collection->addFile($entry['type'], $userpath, $name, $size, $timestamp, $permissions); } if (! $recursive && $this->addSeparators($path) !== $this->separator) { - $collection->addFile('back', $this->getParent($path), '..', 0, 0); + $collection->addFile('back', $this->getParent($path), '..', 0, 0, -1); } return $collection; } + + protected function getPermissions(array $entry): int + { + $adapter = $this->storage->getAdapter(); + $path = $entry['path']; + + switch (get_class($adapter)) { + case 'League\Flysystem\Adapter\Local': + $path = $adapter->applyPathPrefix($path); // get the full path + $permissions = substr(sprintf('%o', fileperms($path)), -3); + return $permissions; + break; + case 'League\Flysystem\Sftp\SftpAdapter': + $stat = $adapter->getConnection()->stat($path); + return $stat && isset($stat['permissions']) ? substr(decoct($stat['permissions']), -3) : -1; + break; + case 'Filegator\Services\Storage\Adapters\FilegatorFtp': + return isset($entry['permissions']) ? $entry['permissions'] : -1; + break; + } + return -1; + } protected function upcountCallback($matches) { diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index 90c0b44..1600dd2 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -43,7 +43,7 @@ Sample configuration: 'separator' => '/', 'config' => [], 'adapter' => function () { - return new \League\Flysystem\Adapter\Ftp([ + return new \Filegator\Services\Storage\Adapters\FilegatorFtp([ 'host' => 'example.com', 'username' => 'demo', 'password' => 'password', diff --git a/frontend/api/api.js b/frontend/api/api.js index c330d37..76e5616 100644 --- a/frontend/api/api.js +++ b/frontend/api/api.js @@ -119,6 +119,17 @@ const api = { .catch(error => reject(error)) }) }, + chmodItems(params) { + return new Promise((resolve, reject) => { + axios.post('chmoditems', { + permissions: params.permissions, + items: params.items, + recursive: params.recursive, + }) + .then(res => resolve(res.data.data)) + .catch(error => reject(error)) + }) + }, removeItems(params) { return new Promise((resolve, reject) => { axios.post('deleteitems', { diff --git a/frontend/views/Browser.vue b/frontend/views/Browser.vue index 913d3bf..be29bc9 100644 --- a/frontend/views/Browser.vue +++ b/frontend/views/Browser.vue @@ -135,6 +135,9 @@ {{ lang('Zip') }} + + {{ lang('Permissions') }} ({{ props.row.permissions }}) + {{ lang('Delete') }} @@ -164,6 +167,7 @@ import Vue from 'vue' import Menu from './partials/Menu' import Tree from './partials/Tree' +import Permissions from './partials/Permissions' import Editor from './partials/Editor' import Gallery from './partials/Gallery' import Search from './partials/Search' @@ -491,6 +495,37 @@ export default { } }) }, + chmod(event, item) { + this.$modal.open({ + parent: this, + hasModalCard: true, + component: Permissions, + props: { + name: item.name, + permissions: item.permissions, + isDir: item.type == 'dir', + }, + events: { + saved: (permissions, recursive = null) => { + this.isLoading = true + api.chmodItems({ + items: item ? [item] : this.getSelected(), + permissions: permissions, + recursive: recursive, + }) + .then(() => { + this.isLoading = false + this.loadFiles() + }) + .catch(error => { + this.isLoading = false + this.handleError(error) + }) + this.checked = [] + } + }, + }) + }, rename(event, item) { this.$dialog.prompt({ message: this.lang('New name'), diff --git a/frontend/views/partials/Permissions.vue b/frontend/views/partials/Permissions.vue new file mode 100644 index 0000000..9a1b5ab --- /dev/null +++ b/frontend/views/partials/Permissions.vue @@ -0,0 +1,143 @@ + + + + + {{ lang('Change permissions for') }} {{ name }} + + + + + + {{ type }} + + + + {{ permission }} + + + + + + {{ lang('Permissions') }} + + + + newPermissions = parseInt(v.slice(-3))" + maxlength="4" + required + /> + + + + + + {{ lang('Recursive') }} + + + + + + {{ lang('No') }} + + + {{ lang('Both folders and files') }} + + + {{ lang('Apply only for folders') }} + + + {{ lang('Apply only for files') }} + + + + + + + + + + + + + diff --git a/frontend/views/partials/UserEdit.vue b/frontend/views/partials/UserEdit.vue index 0ad5aea..b4b3350 100644 --- a/frontend/views/partials/UserEdit.vue +++ b/frontend/views/partials/UserEdit.vue @@ -56,6 +56,9 @@ {{ lang('Zip') }} + + {{ lang('Chmod') }} + @@ -96,6 +99,7 @@ export default { download: _.find(this.user.permissions, p => p == 'download') ? true : false, batchdownload: _.find(this.user.permissions, p => p == 'batchdownload') ? true : false, zip: _.find(this.user.permissions, p => p == 'zip') ? true : false, + chmod: _.find(this.user.permissions, p => p == 'chmod') ? true : false, } } }, @@ -107,6 +111,7 @@ export default { this.permissions.write = false this.permissions.batchdownload = false this.permissions.zip = false + this.permissions.chmod = false } }, 'permissions.write' (val) { @@ -114,6 +119,7 @@ export default { this.permissions.read = true } else { this.permissions.zip = false + this.permissions.chmod = false } }, 'permissions.download' (val) { @@ -133,6 +139,12 @@ export default { this.permissions.write = true } }, + 'permissions.chmod' (val) { + if (val) { + this.permissions.read = true + this.permissions.write = true + } + }, }, methods: { selectDir() { diff --git a/private/users.json.blank b/private/users.json.blank index 2f2f519..cc80be6 100755 --- a/private/users.json.blank +++ b/private/users.json.blank @@ -1 +1 @@ -{"1":{"username":"admin","name":"Admin","role":"admin","homedir":"\/","permissions":"read|write|upload|download|batchdownload|zip","password":"$2y$10$Nu35w4pteLfc7BDCIkDPkecjw8wsH8Y2GMfIewUbXLT7zzW6WOxwq"},"2":{"username":"guest","name":"Guest","role":"guest","homedir":"\/","permissions":"","password":""}} +{"1":{"username":"admin","name":"Admin","role":"admin","homedir":"\/","permissions":"read|write|upload|download|batchdownload|zip|chmod","password":"$2y$10$Nu35w4pteLfc7BDCIkDPkecjw8wsH8Y2GMfIewUbXLT7zzW6WOxwq"},"2":{"username":"guest","name":"Guest","role":"guest","homedir":"\/","permissions":"","password":""}} diff --git a/tests/backend/Unit/CollectionTest.php b/tests/backend/Unit/CollectionTest.php index 1c726c8..f129bf7 100644 --- a/tests/backend/Unit/CollectionTest.php +++ b/tests/backend/Unit/CollectionTest.php @@ -89,17 +89,17 @@ class CollectionTest extends TestCase { $dir = new DirectoryCollection('/sub1/sub2'); - $dir->addFile('back', '/sub1', '..', 0, 1558942228); - $dir->addFile('file', '/sub1/sub2/test.txt', 'test.txt', 30000, 1558942228); - $dir->addFile('file', '/sub1/sub2/test2.txt', 'test.txt', 30000, 1558942228); - $dir->addFile('dir', '/sub1/sub2/sub3', 'sub3', 0, 1558942228); + $dir->addFile('back', '/sub1', '..', 0, 1558942228, 644); + $dir->addFile('file', '/sub1/sub2/test.txt', 'test.txt', 30000, 1558942228, 644); + $dir->addFile('file', '/sub1/sub2/test2.txt', 'test.txt', 30000, 1558942228, 644); + $dir->addFile('dir', '/sub1/sub2/sub3', 'sub3', 0, 1558942228, 644); $json = json_encode($dir); - $this->assertEquals('{"location":"\/sub1\/sub2","files":[{"type":"back","path":"\/sub1","name":"..","size":0,"time":1558942228},{"type":"dir","path":"\/sub1\/sub2\/sub3","name":"sub3","size":0,"time":1558942228},{"type":"file","path":"\/sub1\/sub2\/test.txt","name":"test.txt","size":30000,"time":1558942228},{"type":"file","path":"\/sub1\/sub2\/test2.txt","name":"test.txt","size":30000,"time":1558942228}]}', $json); + $this->assertEquals('{"location":"\/sub1\/sub2","files":[{"type":"back","path":"\/sub1","name":"..","size":0,"time":1558942228,"permissions":644},{"type":"dir","path":"\/sub1\/sub2\/sub3","name":"sub3","size":0,"time":1558942228,"permissions":644},{"type":"file","path":"\/sub1\/sub2\/test.txt","name":"test.txt","size":30000,"time":1558942228,"permissions":644},{"type":"file","path":"\/sub1\/sub2\/test2.txt","name":"test.txt","size":30000,"time":1558942228,"permissions":644}]}', $json); $this->expectException(Exception::class); - $dir->addFile('badType', 'aaa', 'aa', 0, 1558942228); + $dir->addFile('badType', 'aaa', 'aa', 0, 1558942228, 644); } public function testUserCollection() diff --git a/tests/backend/Unit/FilesystemTest.php b/tests/backend/Unit/FilesystemTest.php index 02ff4b2..45bd258 100644 --- a/tests/backend/Unit/FilesystemTest.php +++ b/tests/backend/Unit/FilesystemTest.php @@ -102,6 +102,7 @@ class FilesystemTest extends TestCase 'name' => '..', 'size' => 0, 'time' => 0, + 'permissions' => -1 ], ], ])); @@ -123,6 +124,7 @@ class FilesystemTest extends TestCase 'type' => 'back', 'path' => '/john', 'name' => '..', + 'permissions' => -1, 'size' => 0, 'time' => 0, ], @@ -130,6 +132,7 @@ class FilesystemTest extends TestCase 'type' => 'file', 'path' => '/john/johnsub/john2.txt', 'name' => 'john2.txt', + 'permissions' => 644, 'size' => 0, 'time' => 0, ], @@ -153,6 +156,7 @@ class FilesystemTest extends TestCase 'type' => 'dir', 'path' => '/johnsub', 'name' => 'johnsub', + 'permissions' => 755, 'size' => 0, 'time' => -1, ], @@ -160,6 +164,7 @@ class FilesystemTest extends TestCase 'type' => 'file', 'path' => '/john.txt', 'name' => 'john.txt', + 'permissions' => 644, 'size' => 0, 'time' => -1, ], @@ -184,6 +189,7 @@ class FilesystemTest extends TestCase 'type' => 'back', 'path' => '/', 'name' => '..', + 'permissions' => -1, 'size' => 0, 'time' => 0, ], @@ -191,6 +197,7 @@ class FilesystemTest extends TestCase 'type' => 'file', 'path' => '/johnsub/john2.txt', 'name' => 'john2.txt', + 'permissions' => 644, 'size' => 0, 'time' => 0, ], @@ -859,6 +866,7 @@ class FilesystemTest extends TestCase 'type' => 'file', 'path' => '/john.txt', 'name' => 'john.txt', + 'permissions' => 644, 'size' => 0, 'time' => -1, ], @@ -874,6 +882,7 @@ class FilesystemTest extends TestCase 'type' => 'back', 'path' => '/', 'name' => '..', + 'permissions' => -1, 'size' => 0, 'time' => -1, ], @@ -881,6 +890,7 @@ class FilesystemTest extends TestCase 'type' => 'file', 'path' => '/john.txt', 'name' => 'john.txt', + 'permissions' => 644, 'size' => 0, 'time' => -1, ],
+ {{ lang('Change permissions for') }} {{ name }} +