From e836354e5a8d4a8732485f301ee992192d0af7a5 Mon Sep 17 00:00:00 2001 From: David Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:55:04 -0400 Subject: [PATCH] feat(web-ui): add browse feature to find directories/executables/files (#4848) --- docs/api.md | 3 + src/confighttp.cpp | 188 +++++ src/confighttp.h | 27 + src_assets/common/assets/web/apps.html | 217 +++++- .../assets/web/public/assets/locale/en.json | 11 + tests/unit/test_confighttp.cpp | 675 +++++++++++++++++- tests/unit/test_process.cpp | 2 +- 7 files changed, 1113 insertions(+), 10 deletions(-) diff --git a/docs/api.md b/docs/api.md index 55fbbf890..b41699f77 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \ ## DELETE /api/apps/{index} @copydoc confighttp::deleteApp() +## GET /api/browse +@copydoc confighttp::browseDirectory() + ## GET /api/clients/list @copydoc confighttp::getClients() diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 87caf5726..40be25368 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1462,6 +1462,193 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Checks whether a directory entry qualifies as an executable file. + * @param entry The directory entry to check. + * @param status The cached file status for the entry. + * @return True if the file should be included in an executable-type listing. + */ + bool is_browsable_executable([[maybe_unused]] const fs::directory_entry &entry, [[maybe_unused]] const fs::file_status &status) { +#ifdef _WIN32 + auto ext = entry.path().extension().string(); + boost::algorithm::to_lower(ext); + return ext == ".exe" || ext == ".bat" || ext == ".cmd" || ext == ".com" || ext == ".ps1"; +#else + const auto perms = status.permissions(); + return (perms & fs::perms::owner_exec) != fs::perms::none || + (perms & fs::perms::group_exec) != fs::perms::none || + (perms & fs::perms::others_exec) != fs::perms::none; +#endif + } + +#ifdef _WIN32 + /** + * @brief Builds a JSON array of available Windows drive letters. + * @return JSON array of drive-letter entries. + */ + nlohmann::json get_windows_drives() { + nlohmann::json entries = nlohmann::json::array(); + const DWORD drives = GetLogicalDrives(); + for (int i = 0; i < 26; ++i) { + if (drives & (1 << i)) { + const auto drive_letter = static_cast('A' + i); + const auto drive_path = std::string(1, drive_letter) + ":\\"; + nlohmann::json entry; + entry["name"] = drive_path; + entry["type"] = "directory"; + entry["path"] = drive_path; + entries.push_back(entry); + } + } + return entries; + } +#endif + + /** + * @brief Lists, filters, and sorts the entries of a directory for the browse API. + * @param dir_path The directory to list. + * @param type_str Filter type: "directory", "executable", "file", or "any". + * @return Sorted JSON array of entry objects with name/type/path fields. + */ + nlohmann::json build_browse_entries(const fs::path &dir_path, const std::string &type_str) { + nlohmann::json entries = nlohmann::json::array(); + + std::error_code iter_ec; + for (auto it = fs::directory_iterator(dir_path, fs::directory_options::skip_permission_denied, iter_ec); + !iter_ec && it != fs::directory_iterator(); + it.increment(iter_ec)) { + try { + const auto status = it->status(); + const bool is_dir = fs::is_directory(status); + + if (const bool is_regular = fs::is_regular_file(status); !is_dir && !is_regular) { + continue; + } + + // Apply type filter (directories are always included for navigation) + if (type_str == "directory" && !is_dir) { + continue; + } + + if (type_str == "executable" && !is_dir && !is_browsable_executable(*it, status)) { + continue; + } + + nlohmann::json file_entry; + file_entry["name"] = it->path().filename().string(); + file_entry["path"] = it->path().string(); + file_entry["type"] = is_dir ? "directory" : "file"; + entries.push_back(file_entry); + } catch (const fs::filesystem_error &e) { + BOOST_LOG(debug) << "BrowseDirectory: skipping entry due to error: "sv << e.what(); + } + } + + if (iter_ec) { + BOOST_LOG(debug) << "BrowseDirectory: directory iteration error: "sv << iter_ec.message(); + } + + // Sort: directories first, then files; both case-insensitively alphabetical + std::sort(entries.begin(), entries.end(), [](const nlohmann::json &a, const nlohmann::json &b) { + const bool a_dir = (a["type"] == "directory"); + if (const bool b_dir = (b["type"] == "directory"); a_dir != b_dir) { + return a_dir && !b_dir; + } + auto a_name = a["name"].get(); + auto b_name = b["name"].get(); + boost::algorithm::to_lower(a_name); + boost::algorithm::to_lower(b_name); + return a_name < b_name; + }); + + return entries; + } + + /** + * @brief Browse the server filesystem. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @note On Windows, an empty or root path returns the list of available drive letters. + * @note On non-Windows, an empty path defaults to the filesystem root ("/"). + * + * @api_examples{/api/browse?path=/home/user&type=directory| GET| null} + */ + void browseDirectory(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + try { + const auto query_params = request->parse_query_string(); + + std::string path_str; + if (const auto path_it = query_params.find("path"); path_it != query_params.end()) { + path_str = path_it->second; + } + + std::string type_str = "any"; + if (const auto type_it = query_params.find("type"); type_it != query_params.end() && !type_it->second.empty()) { + type_str = type_it->second; + } + + nlohmann::json output_tree; + +#ifdef _WIN32 + // On Windows with an empty or root path, return the list of available drive letters + if (path_str.empty() || path_str == "/" || path_str == "\\") { + output_tree["path"] = ""; + output_tree["parent"] = ""; + output_tree["entries"] = get_windows_drives(); + send_response(response, output_tree); + return; + } +#else + // On non-Windows, default an empty path to the filesystem root + if (path_str.empty()) { + path_str = "/"; + } +#endif + + // Normalize the path + fs::path dir_path = fs::weakly_canonical(fs::path(path_str)); + + // If the path points to a file, use its parent directory + std::error_code ec; + if (fs::is_regular_file(dir_path, ec)) { + dir_path = dir_path.parent_path(); + } + + // If the path doesn't exist, try the parent + if (!fs::exists(dir_path, ec)) { + dir_path = dir_path.parent_path(); + } + + if (!fs::is_directory(dir_path, ec)) { + bad_request(response, request, "Path is not a directory"); + return; + } + + output_tree["path"] = dir_path.string(); + + // Determine the parent path for the "Up" navigation + const fs::path parent = dir_path.parent_path(); +#ifdef _WIN32 + // At a drive root (e.g., C:\) the parent equals itself; signal the drive list with an empty string + output_tree["parent"] = (parent == dir_path) ? "" : parent.string(); +#else + output_tree["parent"] = parent.string(); +#endif + + output_tree["entries"] = build_browse_entries(dir_path, type_str); + send_response(response, output_tree); + } catch (const fs::filesystem_error &e) { + BOOST_LOG(warning) << "BrowseDirectory: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + void start() { platf::set_thread_name("confighttp"); const auto shutdown_event = mail::man->event(mail::shutdown); @@ -1505,6 +1692,7 @@ namespace confighttp { server.resource["^/welcome/?$"]["GET"] = page_handler("welcome.html", false, true); // rest api + server.resource["^/api/browse$"]["GET"] = browseDirectory; server.resource["^/api/apps$"]["GET"] = getApps; server.resource["^/api/apps$"]["POST"] = saveApp; server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; diff --git a/src/confighttp.h b/src/confighttp.h index 39c3f5e69..e8c4ea003 100644 --- a/src/confighttp.h +++ b/src/confighttp.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include #include @@ -42,8 +43,34 @@ namespace confighttp { bool check_app_index(const resp_https_t &response, const req_https_t &request, int index); void getPage(const resp_https_t &response, const req_https_t &request, const char *html_file, bool require_auth = true, bool redirect_if_username = false); void getAsset(const resp_https_t &response, const req_https_t &request); + void browseDirectory(const resp_https_t &response, const req_https_t &request); void getLocale(const resp_https_t &response, const req_https_t &request); void getCSRFToken(const resp_https_t &response, const req_https_t &request); + + // Browse helper functions (also exposed for unit testing) + /** + * @brief Checks whether a directory entry qualifies as an executable file. + * @param entry The directory entry to check. + * @param status The cached file status for the entry. + * @return True if the file should be included in an executable-type listing. + */ + bool is_browsable_executable(const std::filesystem::directory_entry &entry, const std::filesystem::file_status &status); + + /** + * @brief Lists, filters, and sorts the entries of a directory for the browse API. + * @param dir_path The directory to list. + * @param type_str Filter type: "directory", "executable", "file", or "any". + * @return Sorted JSON array of entry objects with name/type/path fields. + */ + nlohmann::json build_browse_entries(const std::filesystem::path &dir_path, const std::string &type_str); + +#ifdef _WIN32 + /** + * @brief Builds a JSON array of available Windows drive letters. + * @return JSON array of drive-letter entries. + */ + nlohmann::json get_windows_drives(); +#endif } // namespace confighttp // mime types map diff --git a/src_assets/common/assets/web/apps.html b/src_assets/common/assets/web/apps.html index 61b81629a..c9d06f61f 100644 --- a/src_assets/common/assets/web/apps.html +++ b/src_assets/common/assets/web/apps.html @@ -73,8 +73,14 @@
- +
+ + +
{{ $t('apps.output_desc') }}
@@ -116,10 +122,20 @@ - +
+ + +
- +
+ + +
{{ $t('apps.detached_cmds') }}
+ @@ -166,8 +185,14 @@
- +
+ + +
{{ $t('apps.cmd_desc') }}
{{ $t('_common.note') }} {{ $t('apps.cmd_note') }} @@ -176,8 +201,14 @@
- +
+ + +
{{ $t('apps.working_dir_desc') }}
@@ -217,6 +248,10 @@
+
+ + +