mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-08-07 10:20:46 +00:00
feat(web-ui): add browse feature to find directories/executables/files (#4848)
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#pragma once
|
||||
|
||||
// standard includes
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,8 +73,14 @@
|
||||
<!-- output -->
|
||||
<div class="mb-3">
|
||||
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
|
||||
v-model="editForm.output" />
|
||||
<button class="btn btn-secondary" type="button"
|
||||
@click="browseFor('any', 'file_browser.select_file', editForm.output, v => editForm.output = v)">
|
||||
<folder-open :size="18" class="icon"></folder-open>
|
||||
</button>
|
||||
</div>
|
||||
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
|
||||
</div>
|
||||
<!-- prep-cmd -->
|
||||
@@ -116,10 +122,20 @@
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in editForm['prep-cmd']">
|
||||
<td>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" v-model="c.do" />
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'do')">
|
||||
<folder-open :size="14" class="icon"></folder-open>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" v-model="c.undo" />
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'undo')">
|
||||
<folder-open :size="14" class="icon"></folder-open>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="platform === 'windows'" class="align-middle">
|
||||
<Checkbox :id="'prep-cmd-admin-' + i"
|
||||
@@ -145,6 +161,9 @@
|
||||
<label for="appName" class="form-label">{{ $t('apps.detached_cmds') }}</label>
|
||||
<div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between align-items-center my-2">
|
||||
<input type="text" v-model="editForm.detached[i]" class="form-control monospace">
|
||||
<button class="btn btn-secondary btn-sm ms-2" @click="browseDetached(i)">
|
||||
<folder-open :size="14" class="icon"></folder-open>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm ms-2" @click="editForm.detached.splice(i,1)">
|
||||
<trash-2 :size="16" class="icon"></trash-2>
|
||||
</button>
|
||||
@@ -166,8 +185,14 @@
|
||||
<!-- command -->
|
||||
<div class="mb-3">
|
||||
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
|
||||
v-model="editForm.cmd" />
|
||||
<button class="btn btn-secondary" type="button"
|
||||
@click="browseFor('executable', 'file_browser.select_executable', editForm.cmd, v => editForm.cmd = v)">
|
||||
<folder-open :size="18" class="icon"></folder-open>
|
||||
</button>
|
||||
</div>
|
||||
<div id="appCmdHelp" class="form-text">
|
||||
{{ $t('apps.cmd_desc') }}<br>
|
||||
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
|
||||
@@ -176,8 +201,14 @@
|
||||
<!-- working dir -->
|
||||
<div class="mb-3">
|
||||
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
|
||||
v-model="editForm['working-dir']" />
|
||||
<button class="btn btn-secondary" type="button"
|
||||
@click="browseFor('directory', 'file_browser.select_directory', editForm['working-dir'], v => editForm['working-dir'] = v)">
|
||||
<folder-open :size="18" class="icon"></folder-open>
|
||||
</button>
|
||||
</div>
|
||||
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
|
||||
</div>
|
||||
<!-- elevation -->
|
||||
@@ -217,6 +248,10 @@
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
|
||||
v-model="editForm['image-path']" />
|
||||
<button class="btn btn-secondary" type="button"
|
||||
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v)">
|
||||
<folder-open :size="18" class="icon"></folder-open>
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#coverFinderModal"
|
||||
@click="showCoverFinder">
|
||||
<search :size="18" class="icon"></search>
|
||||
@@ -371,6 +406,74 @@
|
||||
{{ $t('apps.add_new') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shared file browser modal -->
|
||||
<div class="modal fade" ref="fileBrowserModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-fullscreen-md-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ fileBrowserTitle || $t('file_browser.title') }}</h5>
|
||||
<button type="button" class="btn-close" @click="fileBrowserClose" :aria-label="$t('_common.close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Path input -->
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control monospace" v-model="fileBrowserTypedPath"
|
||||
@input="fileBrowserOnTypedInput" @keyup.enter="fileBrowserNavigate(fileBrowserTypedPath)" />
|
||||
<button class="btn btn-secondary" type="button" @click="fileBrowserNavigate(fileBrowserTypedPath)">
|
||||
<arrow-right :size="16" class="icon"></arrow-right>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Up button -->
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||
:disabled="fileBrowserLoading || fileBrowserParentPath === fileBrowserCurrentPath"
|
||||
@click="fileBrowserNavigateUp">
|
||||
<folder-up :size="16" class="icon me-1"></folder-up>
|
||||
{{ $t('file_browser.up') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div v-if="fileBrowserError" class="alert alert-danger py-2 small">{{ fileBrowserError }}</div>
|
||||
<!-- Loading -->
|
||||
<div v-if="fileBrowserLoading" class="text-center py-3">
|
||||
<output class="spinner-border spinner-border-sm">
|
||||
<span class="visually-hidden">{{ $t('_common.loading') }}</span>
|
||||
</output>
|
||||
</div>
|
||||
<!-- Entries -->
|
||||
<div v-else class="list-group" style="max-height: 400px; overflow-y: auto;">
|
||||
<div v-if="fileBrowserEntries.length === 0" class="list-group-item text-muted text-center">
|
||||
{{ $t('file_browser.empty') }}
|
||||
</div>
|
||||
<button v-for="entry in fileBrowserEntries" :key="entry.path" type="button"
|
||||
class="list-group-item list-group-item-action d-flex align-items-center py-1"
|
||||
:class="{ active: fileBrowserSelectedPath === entry.path }"
|
||||
@click="fileBrowserSelectEntry(entry)" @dblclick="fileBrowserActivateEntry(entry)">
|
||||
<hard-drive v-if="!fileBrowserCurrentPath && entry.type === 'directory'" :size="16" class="icon me-2 flex-shrink-0"></hard-drive>
|
||||
<folder v-else-if="entry.type === 'directory'" :size="16" class="icon me-2 flex-shrink-0 text-warning"></folder>
|
||||
<file-text v-else :size="16" class="icon me-2 flex-shrink-0"></file-text>
|
||||
<span class="text-truncate">{{ entry.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer flex-wrap gap-2">
|
||||
<div class="flex-grow-1 text-muted small text-truncate" v-if="fileBrowserSelectedPath">
|
||||
<code>{{ fileBrowserSelectedPath }}</code>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="fileBrowserClose">
|
||||
<x :size="16" class="icon me-1"></x>
|
||||
{{ $t('_common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="fileBrowserConfirm"
|
||||
:disabled="!fileBrowserSelectedPath && !fileBrowserTypedPath">
|
||||
<check :size="16" class="icon me-1"></check>
|
||||
{{ $t('file_browser.select') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script type="module">
|
||||
@@ -380,8 +483,14 @@
|
||||
import Checkbox from './Checkbox.vue'
|
||||
import { Modal } from 'bootstrap/dist/js/bootstrap'
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderUp,
|
||||
HardDrive,
|
||||
LayersPlus,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -398,8 +507,14 @@
|
||||
components: {
|
||||
Navbar,
|
||||
Checkbox,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderUp,
|
||||
HardDrive,
|
||||
LayersPlus,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -422,6 +537,16 @@
|
||||
coverCandidates: [],
|
||||
coverSearchQuery: "",
|
||||
platform: "",
|
||||
fileBrowserType: "any",
|
||||
fileBrowserTitle: "",
|
||||
fileBrowserCallback: null,
|
||||
fileBrowserCurrentPath: "",
|
||||
fileBrowserParentPath: "",
|
||||
fileBrowserEntries: [],
|
||||
fileBrowserLoading: false,
|
||||
fileBrowserError: "",
|
||||
fileBrowserSelectedPath: "",
|
||||
fileBrowserTypedPath: "",
|
||||
};
|
||||
},
|
||||
created() {
|
||||
@@ -605,6 +730,82 @@
|
||||
})
|
||||
.finally(() => this.coverFinderBusy = false);
|
||||
},
|
||||
browseFor(type, titleKey, startPath, callback) {
|
||||
this.fileBrowserType = type;
|
||||
this.fileBrowserTitle = this.$t(titleKey);
|
||||
this.fileBrowserCallback = callback;
|
||||
this.fileBrowserSelectedPath = startPath || '';
|
||||
this.fileBrowserTypedPath = startPath || '';
|
||||
this.fileBrowserError = '';
|
||||
this.fileBrowserNavigate(startPath || '');
|
||||
Modal.getOrCreateInstance(this.$refs.fileBrowserModal).show();
|
||||
},
|
||||
fileBrowserClose() {
|
||||
const modal = Modal.getInstance(this.$refs.fileBrowserModal);
|
||||
if (modal) modal.hide();
|
||||
},
|
||||
fileBrowserConfirm() {
|
||||
const path = this.fileBrowserSelectedPath || this.fileBrowserTypedPath;
|
||||
if (path) {
|
||||
if (this.fileBrowserCallback) {
|
||||
this.fileBrowserCallback(path);
|
||||
this.fileBrowserCallback = null;
|
||||
}
|
||||
this.fileBrowserClose();
|
||||
}
|
||||
},
|
||||
fileBrowserNavigate(path) {
|
||||
this.fileBrowserLoading = true;
|
||||
this.fileBrowserError = '';
|
||||
const params = new URLSearchParams({ type: this.fileBrowserType });
|
||||
if (path) params.set('path', path);
|
||||
fetch(`./api/browse?${params.toString()}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Browse failed'); }))
|
||||
.then(data => {
|
||||
this.fileBrowserCurrentPath = data.path ?? '';
|
||||
this.fileBrowserParentPath = data.parent ?? '';
|
||||
this.fileBrowserEntries = data.entries ?? [];
|
||||
this.fileBrowserTypedPath = data.path ?? '';
|
||||
this.fileBrowserSelectedPath = this.fileBrowserType === 'directory' ? (data.path ?? '') : '';
|
||||
})
|
||||
.catch(err => { this.fileBrowserError = err.message; })
|
||||
.finally(() => { this.fileBrowserLoading = false; });
|
||||
},
|
||||
fileBrowserNavigateUp() {
|
||||
this.fileBrowserNavigate(this.fileBrowserParentPath);
|
||||
},
|
||||
fileBrowserSelectEntry(entry) {
|
||||
if (entry.type === 'directory') {
|
||||
this.fileBrowserNavigate(entry.path);
|
||||
} else {
|
||||
this.fileBrowserSelectedPath = entry.path;
|
||||
this.fileBrowserTypedPath = entry.path;
|
||||
}
|
||||
},
|
||||
fileBrowserActivateEntry(entry) {
|
||||
if (entry.type === 'directory') {
|
||||
this.fileBrowserNavigate(entry.path);
|
||||
} else {
|
||||
this.fileBrowserSelectedPath = entry.path;
|
||||
this.fileBrowserTypedPath = entry.path;
|
||||
this.fileBrowserConfirm();
|
||||
}
|
||||
},
|
||||
fileBrowserOnTypedInput() {
|
||||
this.fileBrowserSelectedPath = this.fileBrowserTypedPath;
|
||||
},
|
||||
browsePrep(index, field) {
|
||||
const current = this.editForm['prep-cmd'][index][field] || '';
|
||||
this.browseFor('executable', 'file_browser.select_executable', current, (path) => {
|
||||
this.editForm['prep-cmd'][index][field] = path;
|
||||
});
|
||||
},
|
||||
browseDetached(index) {
|
||||
const current = this.editForm.detached[index] || '';
|
||||
this.browseFor('executable', 'file_browser.select_executable', current, (path) => {
|
||||
this.editForm.detached[index] = path;
|
||||
});
|
||||
},
|
||||
save() {
|
||||
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
|
||||
fetch("./api/apps", {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"auto": "Automatic",
|
||||
"autodetect": "Autodetect (recommended)",
|
||||
"beta": "(beta)",
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"disabled": "Disabled",
|
||||
@@ -510,6 +511,16 @@
|
||||
"title": "Featured Apps",
|
||||
"website": "Website"
|
||||
},
|
||||
"file_browser": {
|
||||
"empty": "No items to display",
|
||||
"root": "Root",
|
||||
"select": "Select",
|
||||
"select_directory": "Select Directory",
|
||||
"select_executable": "Select Executable",
|
||||
"select_file": "Select File",
|
||||
"title": "Browse",
|
||||
"up": "Up"
|
||||
},
|
||||
"welcome": {
|
||||
"confirm_password": "Confirm password",
|
||||
"create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.",
|
||||
|
||||
@@ -131,7 +131,7 @@ protected:
|
||||
};
|
||||
|
||||
// Create test web directory in temp
|
||||
test_web_dir = std::filesystem::temp_directory_path() / "sunshine_test_confighttp";
|
||||
test_web_dir = std::filesystem::temp_directory_path() / "sunshine_test_confighttp"; // NOSONAR(cpp:S5443) - safe for tests
|
||||
std::filesystem::create_directories(test_web_dir / "web");
|
||||
|
||||
// Create test HTML file in WEB_DIR, creating parent directories with proper permissions
|
||||
@@ -300,6 +300,14 @@ protected:
|
||||
confighttp::getLocale(response, request);
|
||||
};
|
||||
|
||||
// Add a route to test browseDirectory
|
||||
server->resource["^/browse-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
confighttp::browseDirectory(response, request);
|
||||
};
|
||||
|
||||
// Start server
|
||||
server_thread = std::thread([this]() { // NOSONAR(cpp:S6168) - jthread not available on FreeBSD 14.3 libc++
|
||||
server->start([this](const unsigned short assigned_port) {
|
||||
@@ -728,3 +736,668 @@ TEST_F(ConfigHttpTest, GetLocaleReturnsJson) {
|
||||
ASSERT_TRUE(body.find("\"status\":true") != std::string::npos || body.find("\"status\": true") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("\"locale\":\"en\"") != std::string::npos || body.find("\"locale\": \"en\"") != std::string::npos);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test fixture for confighttp::browseDirectory tests.
|
||||
*
|
||||
* Creates a known directory structure in the system temp directory so that
|
||||
* the browse endpoint can be exercised with predictable contents.
|
||||
*
|
||||
* Layout:
|
||||
* sunshine_browse_test/
|
||||
* ├── subdir_a/
|
||||
* ├── subdir_b/
|
||||
* ├── file_alpha.txt
|
||||
* ├── file_beta.txt
|
||||
* └── test_exec[.exe] (executable file)
|
||||
*/
|
||||
class BrowseDirectoryTest: public ConfigHttpTest { // NOSONAR(cpp:S3656) - protected members are intentional for test fixture subclassing
|
||||
protected:
|
||||
std::filesystem::path browse_test_dir;
|
||||
|
||||
void SetUp() override {
|
||||
ConfigHttpTest::SetUp();
|
||||
|
||||
browse_test_dir = std::filesystem::temp_directory_path() / "sunshine_browse_test"; // NOSONAR(cpp:S5443) - safe for tests
|
||||
|
||||
// Remove any leftover directory from a previous interrupted run
|
||||
if (std::filesystem::exists(browse_test_dir)) {
|
||||
std::filesystem::remove_all(browse_test_dir);
|
||||
}
|
||||
|
||||
std::filesystem::create_directories(browse_test_dir / "subdir_a");
|
||||
std::filesystem::create_directories(browse_test_dir / "subdir_b");
|
||||
std::ofstream(browse_test_dir / "file_alpha.txt") << "alpha";
|
||||
std::ofstream(browse_test_dir / "file_beta.txt") << "beta";
|
||||
|
||||
#ifdef _WIN32
|
||||
std::ofstream(browse_test_dir / "test_exec.exe") << "fake exe";
|
||||
#else
|
||||
const auto exec_file = browse_test_dir / "test_exec";
|
||||
std::ofstream(exec_file) << "#!/bin/sh\necho hello";
|
||||
std::filesystem::permissions(
|
||||
exec_file,
|
||||
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::owner_exec,
|
||||
std::filesystem::perm_options::replace
|
||||
);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (std::filesystem::exists(browse_test_dir)) {
|
||||
std::filesystem::remove_all(browse_test_dir);
|
||||
}
|
||||
ConfigHttpTest::TearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief URL-encodes a single query-parameter value.
|
||||
*
|
||||
* All characters except unreserved ones (RFC 3986) are percent-encoded so
|
||||
* that slashes, backslashes, colons, etc. in filesystem paths are
|
||||
* transmitted correctly.
|
||||
*/
|
||||
static std::string url_encode_param(const std::string &str) {
|
||||
std::string encoded;
|
||||
for (const unsigned char c : str) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
encoded += static_cast<char>(c);
|
||||
} else {
|
||||
encoded += std::format("%{:02X}", static_cast<int>(c));
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Builds the /browse-test URL with optional path and type query params.
|
||||
*/
|
||||
std::string browse_url(const std::string &path = "", const std::string &type = "") const {
|
||||
std::string url = "/browse-test";
|
||||
std::string sep = "?";
|
||||
if (!path.empty()) {
|
||||
url += sep + "path=" + url_encode_param(path);
|
||||
sep = "&";
|
||||
}
|
||||
if (!type.empty()) {
|
||||
url += sep + "type=" + type;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Helper: locate an entry by name in the JSON entries array.
|
||||
*/
|
||||
static nlohmann::json::const_iterator find_entry(const nlohmann::json &entries, const std::string &name) {
|
||||
return std::ranges::find_if(entries, [&name](const nlohmann::json &e) {
|
||||
return e.at("name").get<std::string>() == name;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Test: browseDirectory requires authentication
|
||||
TEST_F(BrowseDirectoryTest, BrowseRequiresAuthentication) {
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()));
|
||||
ASSERT_EQ(response->status_code, "401 Unauthorized");
|
||||
}
|
||||
|
||||
// Test: browseDirectory returns 200 with valid JSON for a real directory
|
||||
TEST_F(BrowseDirectoryTest, BrowseListsValidDirectory) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const auto content_type = response->header.find("Content-Type");
|
||||
ASSERT_NE(content_type, response->header.end());
|
||||
ASSERT_TRUE(content_type->second.find("application/json") != std::string::npos);
|
||||
|
||||
assert_security_headers(response);
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
ASSERT_TRUE(json.contains("path"));
|
||||
ASSERT_TRUE(json.contains("parent"));
|
||||
ASSERT_TRUE(json.contains("entries"));
|
||||
ASSERT_TRUE(json["entries"].is_array());
|
||||
}
|
||||
|
||||
// Test: returned 'path' field matches the requested directory
|
||||
TEST_F(BrowseDirectoryTest, BrowseResponsePathMatchesRequest) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
|
||||
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
|
||||
ASSERT_EQ(returned, expected);
|
||||
}
|
||||
|
||||
// Test: returned 'parent' field is the parent of 'path'
|
||||
TEST_F(BrowseDirectoryTest, BrowseResponseParentIsCorrect) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const std::filesystem::path returned_path(json["path"].get<std::string>());
|
||||
const std::filesystem::path returned_parent(json["parent"].get<std::string>());
|
||||
ASSERT_EQ(returned_parent, returned_path.parent_path());
|
||||
}
|
||||
|
||||
// Test: entries contain the expected subdirectories and files with correct types
|
||||
TEST_F(BrowseDirectoryTest, BrowseResponseContainsExpectedEntries) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
const auto subdir_a = find_entry(entries, "subdir_a");
|
||||
ASSERT_NE(subdir_a, entries.end());
|
||||
ASSERT_EQ((*subdir_a)["type"].get<std::string>(), "directory");
|
||||
|
||||
const auto subdir_b = find_entry(entries, "subdir_b");
|
||||
ASSERT_NE(subdir_b, entries.end());
|
||||
ASSERT_EQ((*subdir_b)["type"].get<std::string>(), "directory");
|
||||
|
||||
const auto file_alpha = find_entry(entries, "file_alpha.txt");
|
||||
ASSERT_NE(file_alpha, entries.end());
|
||||
ASSERT_EQ((*file_alpha)["type"].get<std::string>(), "file");
|
||||
|
||||
const auto file_beta = find_entry(entries, "file_beta.txt");
|
||||
ASSERT_NE(file_beta, entries.end());
|
||||
ASSERT_EQ((*file_beta)["type"].get<std::string>(), "file");
|
||||
}
|
||||
|
||||
// Test: every entry has non-empty 'name', 'type', and 'path' fields
|
||||
TEST_F(BrowseDirectoryTest, BrowseEntryFieldsArePresent) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
for (const auto &entry : json["entries"]) {
|
||||
ASSERT_TRUE(entry.contains("name"));
|
||||
ASSERT_TRUE(entry.contains("type"));
|
||||
ASSERT_TRUE(entry.contains("path"));
|
||||
ASSERT_FALSE(entry["name"].get<std::string>().empty());
|
||||
ASSERT_FALSE(entry["path"].get<std::string>().empty());
|
||||
const auto type = entry["type"].get<std::string>();
|
||||
ASSERT_TRUE(type == "directory" || type == "file");
|
||||
}
|
||||
}
|
||||
|
||||
// Test: entries are sorted – all directories appear before any file
|
||||
TEST_F(BrowseDirectoryTest, BrowseEntriesSortedDirsFirst) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
bool seen_file = false;
|
||||
for (const auto &entry : json["entries"]) {
|
||||
const std::string type = entry["type"].get<std::string>();
|
||||
if (type == "file") {
|
||||
seen_file = true;
|
||||
} else if (type == "directory") {
|
||||
ASSERT_FALSE(seen_file) << "Directory '" << entry["name"] << "' appears after a file in the listing";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: entries within each group (dirs / files) are sorted case-insensitively
|
||||
TEST_F(BrowseDirectoryTest, BrowseEntriesSortedAlphabeticallyWithinGroups) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
std::string prev_dir;
|
||||
std::string prev_file;
|
||||
for (const auto &entry : json["entries"]) {
|
||||
std::string name = entry["name"].get<std::string>();
|
||||
std::ranges::transform(name, name.begin(), ::tolower);
|
||||
|
||||
if (entry["type"] == "directory") {
|
||||
if (!prev_dir.empty()) {
|
||||
ASSERT_LE(prev_dir, name) << "Directories are not in alphabetical order";
|
||||
}
|
||||
prev_dir = name;
|
||||
} else {
|
||||
if (!prev_file.empty()) {
|
||||
ASSERT_LE(prev_file, name) << "Files are not in alphabetical order";
|
||||
}
|
||||
prev_file = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: type=directory filter excludes files from the listing
|
||||
TEST_F(BrowseDirectoryTest, BrowseTypeDirFilterExcludesFiles) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "directory"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
for (const auto &entry : entries) {
|
||||
ASSERT_EQ(entry["type"].get<std::string>(), "directory")
|
||||
<< "Non-directory entry '" << entry["name"] << "' found with type=directory filter";
|
||||
}
|
||||
|
||||
// Subdirectories must still be present
|
||||
ASSERT_NE(find_entry(entries, "subdir_a"), entries.end());
|
||||
ASSERT_NE(find_entry(entries, "subdir_b"), entries.end());
|
||||
}
|
||||
|
||||
// Test: type=file returns both files and directories
|
||||
TEST_F(BrowseDirectoryTest, BrowseTypeFileReturnsBoth) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "file"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
|
||||
return e["type"] == "directory";
|
||||
});
|
||||
const bool has_file = std::ranges::any_of(entries, [](const nlohmann::json &e) {
|
||||
return e["type"] == "file";
|
||||
});
|
||||
ASSERT_TRUE(has_dir);
|
||||
ASSERT_TRUE(has_file);
|
||||
}
|
||||
|
||||
// Test: type=executable still includes directories for navigation
|
||||
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableIncludesDirs) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
|
||||
return e["type"] == "directory";
|
||||
});
|
||||
ASSERT_TRUE(has_dir);
|
||||
}
|
||||
|
||||
// Test: type=executable excludes plain (non-executable) files
|
||||
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableExcludesNonExecutableFiles) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
// file_alpha.txt and file_beta.txt have no execute permission / wrong extension
|
||||
for (const auto &entry : entries) {
|
||||
if (entry["type"] == "file") {
|
||||
const std::string name = entry["name"].get<std::string>();
|
||||
ASSERT_NE(name, "file_alpha.txt") << "Non-executable file included in type=executable listing";
|
||||
ASSERT_NE(name, "file_beta.txt") << "Non-executable file included in type=executable listing";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: type=executable includes the known executable file
|
||||
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableIncludesExecutableFile) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const auto &entries = json["entries"];
|
||||
|
||||
#ifdef _WIN32
|
||||
const std::string exec_name = "test_exec.exe";
|
||||
#else
|
||||
const std::string exec_name = "test_exec";
|
||||
#endif
|
||||
|
||||
ASSERT_NE(find_entry(entries, exec_name), entries.end())
|
||||
<< "Expected executable file '" << exec_name << "' not found with type=executable filter";
|
||||
}
|
||||
|
||||
// Test: supplying a file path navigates to its parent directory
|
||||
TEST_F(BrowseDirectoryTest, BrowseFilepathNavigatesToParentDirectory) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const std::filesystem::path file_path = browse_test_dir / "file_alpha.txt";
|
||||
const auto response = client->request("GET", browse_url(file_path.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
|
||||
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
|
||||
ASSERT_EQ(returned, expected);
|
||||
}
|
||||
|
||||
// Test: a non-existent child path falls back to the existing parent directory
|
||||
TEST_F(BrowseDirectoryTest, BrowseNonexistentChildPathFallsBackToParent) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const std::filesystem::path nonexistent = browse_test_dir / "does_not_exist_xyz";
|
||||
const auto response = client->request("GET", browse_url(nonexistent.string()), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
|
||||
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
|
||||
ASSERT_EQ(returned, expected);
|
||||
}
|
||||
|
||||
// Test: a path where both the target and its parent don't exist returns 400
|
||||
TEST_F(BrowseDirectoryTest, BrowseTrulyNonexistentPathReturnsBadRequest) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
// Construct a deeply non-existent path (parent also doesn't exist)
|
||||
const std::string nonexistent = "/sunshine_nonexistent_xyz_54321/also_nonexistent";
|
||||
const auto response = client->request("GET", browse_url(nonexistent), "", headers);
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
}
|
||||
|
||||
// Test: omitting the path parameter returns a valid response (defaults to a browsable location)
|
||||
TEST_F(BrowseDirectoryTest, BrowseEmptyPathReturnsValidResponse) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/browse-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
ASSERT_TRUE(json.contains("path"));
|
||||
ASSERT_TRUE(json.contains("parent"));
|
||||
ASSERT_TRUE(json.contains("entries"));
|
||||
ASSERT_TRUE(json["entries"].is_array());
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Test (Windows): empty/root path returns the list of logical drive letters
|
||||
TEST_F(BrowseDirectoryTest, BrowseWindowsEmptyPathReturnsDriveList) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/browse-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
ASSERT_EQ(json["path"].get<std::string>(), "");
|
||||
ASSERT_EQ(json["parent"].get<std::string>(), "");
|
||||
ASSERT_GT(json["entries"].size(), 0u);
|
||||
|
||||
// Every entry must look like "X:\" – a drive letter root
|
||||
for (const auto &entry : json["entries"]) {
|
||||
ASSERT_EQ(entry["type"].get<std::string>(), "directory");
|
||||
const std::string name = entry["name"].get<std::string>();
|
||||
ASSERT_EQ(name.size(), 3u) << "Drive entry name should be 3 chars, e.g. 'C:\\'";
|
||||
ASSERT_TRUE(std::isalpha(static_cast<unsigned char>(name[0])));
|
||||
ASSERT_EQ(name[1], ':');
|
||||
ASSERT_EQ(name[2], '\\');
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Test (Unix): browsing "/" returns path == "/" and parent == "/" (at root, parent == self)
|
||||
TEST_F(BrowseDirectoryTest, BrowseUnixRootParentEqualsSelf) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", browse_url("/"), "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const nlohmann::json json = nlohmann::json::parse(response->content.string());
|
||||
const std::string path = json["path"].get<std::string>();
|
||||
const std::string parent = json["parent"].get<std::string>();
|
||||
|
||||
ASSERT_EQ(path, "/");
|
||||
ASSERT_EQ(parent, "/");
|
||||
}
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// Direct unit tests for browseDirectory helper functions
|
||||
// ============================================================
|
||||
|
||||
// Test: is_browsable_executable correctly identifies executable files
|
||||
#ifdef _WIN32
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsExeExtension_ReturnsTrue) {
|
||||
const std::filesystem::path exec_file = browse_test_dir / "test_exec.exe";
|
||||
const std::filesystem::directory_entry entry(exec_file);
|
||||
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(exec_file)));
|
||||
}
|
||||
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsBatExtension_ReturnsTrue) {
|
||||
const std::filesystem::path bat_file = browse_test_dir / "test_script.bat";
|
||||
std::ofstream(bat_file) << "@echo off";
|
||||
const std::filesystem::directory_entry entry(bat_file);
|
||||
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(bat_file)));
|
||||
std::filesystem::remove(bat_file);
|
||||
}
|
||||
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsTxtExtension_ReturnsFalse) {
|
||||
const std::filesystem::path txt_file = browse_test_dir / "file_alpha.txt";
|
||||
const std::filesystem::directory_entry entry(txt_file);
|
||||
ASSERT_FALSE(confighttp::is_browsable_executable(entry, std::filesystem::status(txt_file)));
|
||||
}
|
||||
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsCaseInsensitive_ReturnsTrue) {
|
||||
// .EXE uppercase should still be recognized
|
||||
const std::filesystem::path upper_exe = browse_test_dir / "UPPER.EXE";
|
||||
std::ofstream(upper_exe) << "fake";
|
||||
const std::filesystem::directory_entry entry(upper_exe);
|
||||
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(upper_exe)));
|
||||
std::filesystem::remove(upper_exe);
|
||||
}
|
||||
#else
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxExecBitSet_ReturnsTrue) {
|
||||
const std::filesystem::path exec_file = browse_test_dir / "test_exec";
|
||||
const std::filesystem::directory_entry entry(exec_file);
|
||||
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(exec_file)));
|
||||
}
|
||||
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxNoExecBit_ReturnsFalse) {
|
||||
const std::filesystem::path txt_file = browse_test_dir / "file_alpha.txt";
|
||||
const std::filesystem::directory_entry entry(txt_file);
|
||||
ASSERT_FALSE(confighttp::is_browsable_executable(entry, std::filesystem::status(txt_file)));
|
||||
}
|
||||
|
||||
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxGroupExecBit_ReturnsTrue) {
|
||||
const std::filesystem::path group_exec = browse_test_dir / "group_exec_file";
|
||||
std::ofstream(group_exec) << "#!/bin/sh";
|
||||
std::filesystem::permissions(
|
||||
group_exec,
|
||||
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::group_exec,
|
||||
std::filesystem::perm_options::replace
|
||||
);
|
||||
const std::filesystem::directory_entry entry(group_exec);
|
||||
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(group_exec)));
|
||||
std::filesystem::remove(group_exec);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Test: build_browse_entries returns all entries for "any" type
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeAny_ReturnsAllEntries) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
// subdir_a, subdir_b, file_alpha.txt, file_beta.txt, test_exec[.exe] = 5
|
||||
ASSERT_EQ(entries.size(), 5u);
|
||||
}
|
||||
|
||||
// Test: build_browse_entries returns only directories for "directory" type
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeDirectory_OnlyReturnsDirs) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "directory");
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
ASSERT_EQ(entries.size(), 2u); // subdir_a, subdir_b
|
||||
for (const auto &e : entries) {
|
||||
ASSERT_EQ(e["type"].get<std::string>(), "directory");
|
||||
}
|
||||
}
|
||||
|
||||
// Test: build_browse_entries returns dirs and files for "file" type
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeFile_ReturnsDirsAndFiles) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "file");
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
|
||||
return e["type"] == "directory";
|
||||
});
|
||||
const bool has_file = std::ranges::any_of(entries, [](const nlohmann::json &e) {
|
||||
return e["type"] == "file";
|
||||
});
|
||||
ASSERT_TRUE(has_dir);
|
||||
ASSERT_TRUE(has_file);
|
||||
}
|
||||
|
||||
// Test: build_browse_entries for "executable" includes dirs and only executable files
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeExecutable_IncludesDirsAndExecFiles) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "executable");
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
|
||||
// All directories must still be present for navigation
|
||||
ASSERT_NE(find_entry(entries, "subdir_a"), entries.end());
|
||||
ASSERT_NE(find_entry(entries, "subdir_b"), entries.end());
|
||||
|
||||
#ifdef _WIN32
|
||||
const std::string exec_name = "test_exec.exe";
|
||||
#else
|
||||
const std::string exec_name = "test_exec";
|
||||
#endif
|
||||
ASSERT_NE(find_entry(entries, exec_name), entries.end())
|
||||
<< "Expected executable '" << exec_name << "' not found";
|
||||
|
||||
// Non-executable text files must NOT appear
|
||||
ASSERT_EQ(find_entry(entries, "file_alpha.txt"), entries.end());
|
||||
ASSERT_EQ(find_entry(entries, "file_beta.txt"), entries.end());
|
||||
}
|
||||
|
||||
// Test: build_browse_entries sorts directories before files
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_SortsDirsBeforeFiles) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
|
||||
ASSERT_GE(entries.size(), 3u);
|
||||
|
||||
bool seen_file = false;
|
||||
for (const auto &e : entries) {
|
||||
if (e["type"] == "file") {
|
||||
seen_file = true;
|
||||
} else {
|
||||
// directory after a file means incorrect sort order
|
||||
ASSERT_FALSE(seen_file)
|
||||
<< "Directory '" << e["name"].get<std::string>() << "' appeared after a file entry";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: build_browse_entries sorts entries alphabetically within each group
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_SortsAlphabeticallyWithinGroups) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
|
||||
|
||||
// Collect names of dirs and files separately and check they are in order
|
||||
std::vector<std::string> dir_names;
|
||||
std::vector<std::string> file_names;
|
||||
for (const auto &e : entries) {
|
||||
auto name = e["name"].get<std::string>();
|
||||
std::ranges::transform(name, name.begin(), [](unsigned char c) {
|
||||
return std::tolower(c);
|
||||
});
|
||||
if (e["type"] == "directory") {
|
||||
dir_names.push_back(name);
|
||||
} else {
|
||||
file_names.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_TRUE(std::ranges::is_sorted(dir_names))
|
||||
<< "Directory names are not in alphabetical order";
|
||||
ASSERT_TRUE(std::ranges::is_sorted(file_names))
|
||||
<< "File names are not in alphabetical order";
|
||||
}
|
||||
|
||||
// Test: every entry returned by build_browse_entries has the required fields
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_EachEntryHasRequiredFields) {
|
||||
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
|
||||
ASSERT_FALSE(entries.empty());
|
||||
for (const auto &e : entries) {
|
||||
ASSERT_TRUE(e.contains("name")) << "Entry missing 'name' field";
|
||||
ASSERT_TRUE(e.contains("type")) << "Entry missing 'type' field";
|
||||
ASSERT_TRUE(e.contains("path")) << "Entry missing 'path' field";
|
||||
const std::string type = e["type"].get<std::string>();
|
||||
ASSERT_TRUE(type == "directory" || type == "file")
|
||||
<< "Unexpected entry type: " << type;
|
||||
}
|
||||
}
|
||||
|
||||
// Test: build_browse_entries on an empty directory returns an empty array
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_EmptyDirectory_ReturnsEmptyArray) {
|
||||
const std::filesystem::path empty_dir = browse_test_dir / "empty_subdir_for_test";
|
||||
std::filesystem::create_directory(empty_dir);
|
||||
|
||||
const auto entries = confighttp::build_browse_entries(empty_dir, "any");
|
||||
|
||||
std::filesystem::remove(empty_dir);
|
||||
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
ASSERT_TRUE(entries.empty());
|
||||
}
|
||||
|
||||
// Test: build_browse_entries on a non-existent path returns an empty array (does not throw)
|
||||
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_NonexistentDirectory_ReturnsEmptyArray) {
|
||||
const auto entries = confighttp::build_browse_entries("/sunshine_nonexistent_dir_xyz_99999", "any");
|
||||
ASSERT_TRUE(entries.is_array());
|
||||
ASSERT_TRUE(entries.empty());
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Test: get_windows_drives returns at least one drive
|
||||
TEST_F(BrowseDirectoryTest, GetWindowsDrives_ReturnsAtLeastOneDrive) {
|
||||
const auto drives = confighttp::get_windows_drives();
|
||||
ASSERT_TRUE(drives.is_array());
|
||||
ASSERT_GT(drives.size(), 0u);
|
||||
}
|
||||
|
||||
// Test: get_windows_drives entries have correct name/type/path fields
|
||||
TEST_F(BrowseDirectoryTest, GetWindowsDrives_EntriesHaveCorrectFormat) {
|
||||
for (const auto drives = confighttp::get_windows_drives(); const auto &drive : drives) {
|
||||
ASSERT_TRUE(drive.contains("name"));
|
||||
ASSERT_TRUE(drive.contains("type"));
|
||||
ASSERT_TRUE(drive.contains("path"));
|
||||
ASSERT_EQ(drive["type"].get<std::string>(), "directory");
|
||||
const std::string name = drive["name"].get<std::string>();
|
||||
ASSERT_EQ(name.size(), 3u) << "Drive name should be 3 chars, e.g. 'C:\\'";
|
||||
ASSERT_TRUE(std::isalpha(static_cast<unsigned char>(name[0])));
|
||||
ASSERT_EQ(name[1], ':');
|
||||
ASSERT_EQ(name[2], '\\');
|
||||
ASSERT_EQ(drive["path"].get<std::string>(), name);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProcessPNGTest: public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Create test directory
|
||||
test_dir = fs::temp_directory_path() / "sunshine_process_png_test";
|
||||
test_dir = fs::temp_directory_path() / "sunshine_process_png_test"; // NOSONAR(cpp:S5443) - safe for tests
|
||||
fs::create_directories(test_dir);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user