From 9f909b0d85c6c6961bb7da80a7f2bdce265f07d6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 3 Jan 2024 16:23:17 -0600 Subject: [PATCH] Update:Library folder browser to also work for debian and windows --- .../modals/libraries/EditLibrary.vue | 2 +- ...olderChooser.vue => LazyFolderChooser.vue} | 109 ++++++++++++------ server/controllers/FileSystemController.js | 60 ++++++++-- server/routers/ApiRouter.js | 29 ----- server/utils/fileUtils.js | 63 ++++++++++ 5 files changed, 184 insertions(+), 79 deletions(-) rename client/components/modals/libraries/{FolderChooser.vue => LazyFolderChooser.vue} (58%) diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 598f3bcd..d29d7929 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -31,7 +31,7 @@ {{ $strings.ButtonBrowseForFolder }} - + diff --git a/client/components/modals/libraries/FolderChooser.vue b/client/components/modals/libraries/LazyFolderChooser.vue similarity index 58% rename from client/components/modals/libraries/FolderChooser.vue rename to client/components/modals/libraries/LazyFolderChooser.vue index 6383d102..0254f760 100644 --- a/client/components/modals/libraries/FolderChooser.vue +++ b/client/components/modals/libraries/LazyFolderChooser.vue @@ -4,29 +4,32 @@ arrow_back

{{ $strings.HeaderChooseAFolder }}

-
-

{{ selectedPath || '\\' }}

+
+

{{ selectedPath || '/' }}

-
+
-
+
folder

..

-
+
folder

{{ dir.dirname }}

- arrow_right + arrow_right
-
+
folder

{{ dir.dirname }}

+
+ +
-
+

{{ $strings.MessageLoadingFolders }}

@@ -51,11 +54,12 @@ export default { }, data() { return { - loadingFolders: false, - allFolders: [], + initialLoad: false, + loadingDirs: false, + isPosix: true, + rootDirs: [], directories: [], selectedPath: '', - selectedFullPath: '', subdirs: [], level: 0, currentDir: null, @@ -98,59 +102,88 @@ export default { } }, methods: { - goBack() { - var splitPaths = this.selectedPath.split('\\').slice(1) - var prev = splitPaths.slice(0, -1).join('\\') + async goBack() { + let selPath = this.selectedPath.replace(/^\//, '') + var splitPaths = selPath.split('/') - var currDirs = this.allFolders - for (let i = 0; i < splitPaths.length; i++) { - var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i]) - if (_dir && _dir.path.slice(1) === prev) { - this.directories = currDirs - this.selectDir(_dir) - return - } else if (_dir) { - currDirs = _dir.dirs - } + let previousPath = '' + let lookupPath = '' + + if (splitPaths.length > 2) { + lookupPath = splitPaths.slice(0, -2).join('/') } + previousPath = splitPaths.slice(0, -1).join('/') + + if (!this.isPosix) { + // For windows drives add a trailing slash. e.g. C:/ + if (!this.isPosix && lookupPath.endsWith(':')) { + lookupPath += '/' + } + if (!this.isPosix && previousPath.endsWith(':')) { + previousPath += '/' + } + } else { + // Add leading slash + if (previousPath) previousPath = '/' + previousPath + if (lookupPath) lookupPath = '/' + lookupPath + } + + this.level-- + this.subdirs = this.directories + this.selectedPath = previousPath + this.directories = await this.fetchDirs(lookupPath, this.level) }, - selectDir(dir) { + async selectDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, - selectSubDir(dir) { + async selectSubDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level this.directories = this.subdirs - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, selectFolder() { if (!this.selectedPath) { console.error('No Selected path') return } - if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) { + if (this.paths.find((p) => p.startsWith(this.selectedPath))) { this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`) return } - this.$emit('select', this.selectedFullPath) + this.$emit('select', this.selectedPath) this.selectedPath = '' - this.selectedFullPath = '' + }, + fetchDirs(path, level) { + this.loadingDirs = true + return this.$axios + .$get(`/api/filesystem?path=${path}&level=${level}`) + .then((data) => { + console.log('Fetched directories', data.directories) + this.isPosix = !!data.posix + return data.directories + }) + .catch((error) => { + console.error('Failed to get filesystem paths', error) + this.$toast.error('Failed to get filesystem paths') + return [] + }) + .finally(() => { + this.loadingDirs = false + }) }, async init() { - this.loadingFolders = true - this.allFolders = await this.$store.dispatch('libraries/loadFolders') - this.loadingFolders = false + this.initialLoad = true + this.rootDirs = await this.fetchDirs('', 0) + this.initialLoad = false - this.directories = this.allFolders + this.directories = this.rootDirs this.subdirs = [] this.selectedPath = '' - this.selectedFullPath = '' } }, mounted() { diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index cee52cb2..88459e51 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -1,31 +1,69 @@ const Path = require('path') const Logger = require('../Logger') -const Database = require('../Database') const fs = require('../libs/fsExtra') +const { toNumber } = require('../utils/index') +const fileUtils = require('../utils/fileUtils') class FileSystemController { constructor() { } + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getPaths(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user) return res.sendStatus(403) } - const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => { - return Path.sep + dirname - }) + const relpath = req.query.path + const level = toNumber(req.query.level, 0) - // Do not include existing mapped library paths in response - const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths() - libraryFoldersPaths.forEach((path) => { - let dir = path || '' - if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') - excludedDirs.push(dir) + // Validate path. Must be absolute + if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) { + Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`) + return res.status(400).send('Invalid "path" query string') + } + Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`) + + let directories = [] + + // Windows returns drives first + if (global.isWin) { + if (relpath) { + directories = await fileUtils.getDirectoriesInPath(relpath, level) + } else { + const drives = await fileUtils.getWindowsDrives().catch((error) => { + Logger.error(`[FileSystemController] Failed to get windows drives`, error) + return [] + }) + if (drives.length) { + directories = drives.map(d => { + return { + path: d, + dirname: d, + level: 0 + } + }) + } + } + } else { + directories = await fileUtils.getDirectoriesInPath(relpath || '/', level) + } + + // Exclude some dirs from this project to be cleaner in Docker + const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => { + return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname)) + }) + directories = directories.filter(dir => { + return !excludedDirs.includes(dir.path) }) res.json({ - directories: await this.getDirectories(global.appRoot, '/', excludedDirs) + posix: !global.isWin, + directories }) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3edce256..2956cd52 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -320,35 +320,6 @@ class ApiRouter { this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) } - async getDirectories(dir, relpath, excludedDirs, level = 0) { - try { - const paths = await fs.readdir(dir) - - let dirs = await Promise.all(paths.map(async dirname => { - const fullPath = Path.join(dir, dirname) - const path = Path.join(relpath, dirname) - - const isDir = (await fs.lstat(fullPath)).isDirectory() - if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') { - return { - path, - dirname, - fullPath, - level, - dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : [] - } - } else { - return false - } - })) - dirs = dirs.filter(d => d) - return dirs - } catch (error) { - Logger.error('Failed to readdir', dir, error) - return [] - } - } - // // Helper Methods // diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 89ad9e60..14e4d743 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,6 +1,7 @@ const axios = require('axios') const Path = require('path') const ssrfFilter = require('ssrf-req-filter') +const exec = require('child_process').exec const fs = require('../libs/fsExtra') const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') @@ -378,3 +379,65 @@ module.exports.isWritable = async (directory) => { } } +/** + * Get Windows drives as array e.g. ["C:/", "F:/"] + * + * @returns {Promise} + */ +module.exports.getWindowsDrives = async () => { + if (!global.isWin) { + return [] + } + return new Promise((resolve, reject) => { + exec('wmic logicaldisk get name', async (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + let drives = stdout?.split(/\r?\n/).map(line => line.trim()).filter(line => line).slice(1) + const validDrives = [] + for (const drive of drives) { + let drivepath = drive + '/' + if (await fs.pathExists(drivepath)) { + validDrives.push(drivepath) + } else { + Logger.error(`Invalid drive ${drivepath}`) + } + } + resolve(validDrives) + }) + }) +} + +/** + * Get array of directory paths in a directory + * + * @param {string} dirPath + * @param {number} level + * @returns {Promise<{ path:string, dirname:string, level:number }[]>} + */ +module.exports.getDirectoriesInPath = async (dirPath, level) => { + try { + const paths = await fs.readdir(dirPath) + let dirs = await Promise.all(paths.map(async dirname => { + const fullPath = Path.join(dirPath, dirname) + + const lstat = await fs.lstat(fullPath).catch((error) => { + Logger.debug(`Failed to lstat "${fullPath}"`, error) + return null + }) + if (!lstat?.isDirectory()) return null + + return { + path: this.filePathToPOSIX(fullPath), + dirname, + level + } + })) + dirs = dirs.filter(d => d) + return dirs + } catch (error) { + Logger.error('Failed to readdir', dirPath, error) + return [] + } +} \ No newline at end of file