Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
188 changes: 188 additions & 0 deletions src/confighttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<char>('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<std::string>();
auto b_name = b["name"].get<std::string>();
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<bool>(mail::shutdown);
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/confighttp.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma once

// standard includes
#include <filesystem>
#include <memory>
#include <string>

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading