diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3a3a144..84fade63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: - master paths-ignore: - README.md + - ChangeLog.md - docs/** pull_request: @@ -14,6 +15,7 @@ on: - master paths-ignore: - README.md + - ChangeLog.md - docs/** jobs: diff --git a/ChangeLog.md b/ChangeLog.md index a16ef09c..36398866 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,7 +2,9 @@ - Add macOS support - Rework mobile layout: make elements larger and better positioned, support swiping between views - Rework column editor: add support for subrows and basic formatting, each layout is now configured separately -- Allow non-absolute paths in config (resolved relative to {player profile directory}/beefweb/) +- Allow non-absolute paths in config (resolved relative to `{player_profile_dir}/beefweb/`) +- Serve custom web content from `{player_profile_dir}/beefweb/webroot/` +- Replace `BEEFWEB_CONFIG_FILE` environment variable with `BEEFWEB_PROFILE_DIR` (fully overrides `{player_profile_dir}/beefweb/`) - Allow setting arbitrary UI scale - Preserve scroll position in file browser - Increase icon sizes in all layouts diff --git a/cpp/server/deadbeef/plugin.cpp b/cpp/server/deadbeef/plugin.cpp index d3225f57..847e2318 100644 --- a/cpp/server/deadbeef/plugin.cpp +++ b/cpp/server/deadbeef/plugin.cpp @@ -49,7 +49,7 @@ void Plugin::handlePluginsLoaded() pluginsLoaded_ = true; #ifndef MSRV_OS_MAC - SettingsData::migrate(MSRV_PLAYER_DEADBEEF, getProfileDir()); + migrateSettings(MSRV_PLAYER_DEADBEEF, getProfileDir()); #endif refreshSettings(); @@ -59,18 +59,19 @@ void Plugin::handlePluginsLoaded() void Plugin::reconfigure() { tryCatchLog([&] { - auto settings = std::make_shared(); - - settings->port = port_; - settings->allowRemote = allowRemote_; - settings->musicDirsOrig = parseValueList(musicDirs_, ';'); - settings->authRequired = authRequired_; - settings->authUser = authUser_; - settings->authPassword = authPassword_; - settings->permissions = permissions_; - - settings->initialize(getThisModuleDir(), getProfileDir()); - + SettingsBuilder builder; + + builder.resourceDir = getThisModuleDir(); + builder.profileDir = getProfileDir(); + builder.port = port_; + builder.allowRemote = allowRemote_; + builder.musicDirs = parseValueList(musicDirs_, ';'); + builder.authRequired = authRequired_; + builder.authUser = authUser_; + builder.authPassword = authPassword_; + builder.permissions = permissions_; + + auto settings = builder.build(); host_.reconfigure(std::move(settings)); }); } diff --git a/cpp/server/file_system.hpp b/cpp/server/file_system.hpp index 197f4feb..5be97f23 100644 --- a/cpp/server/file_system.hpp +++ b/cpp/server/file_system.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -16,6 +17,8 @@ namespace fs = boost::filesystem; using Path = fs::path; +using PathVectorPtr = std::shared_ptr>; + #ifdef MSRV_OS_POSIX #define MSRV_PATH_LITERAL(str) Path(str) #endif diff --git a/cpp/server/foobar2000/plugin.cpp b/cpp/server/foobar2000/plugin.cpp index 4d22b110..96a91b12 100644 --- a/cpp/server/foobar2000/plugin.cpp +++ b/cpp/server/foobar2000/plugin.cpp @@ -35,23 +35,24 @@ Path Plugin::getProfileDir() void Plugin::reconfigure() { tryCatchLog([&] { - auto settings = std::make_shared(); - - settings->port = settings_store::port; - settings->allowRemote = settings_store::allowRemote; - settings->musicDirsOrig = settings_store::getMusicDirs(); - settings->authRequired = settings_store::authRequired; - settings->authUser = settings_store::authUser.get(); - settings->authPassword = settings_store::authPassword.get(); - settings->permissions = settings_store::getPermissions(); + SettingsBuilder builder; #ifdef MSRV_OS_MAC - auto resourceDir = getThisModuleDir().parent_path() / Path("Resources"); + builder.resourceDir = getThisModuleDir().parent_path() / Path("Resources"); #else - const auto& resourceDir = getThisModuleDir(); + builder.resourceDir = getThisModuleDir(); #endif - settings->initialize(resourceDir, getProfileDir()); + builder.profileDir = getProfileDir(); + builder.port = settings_store::port; + builder.allowRemote = settings_store::allowRemote; + builder.musicDirs = settings_store::getMusicDirs(); + builder.authRequired = settings_store::authRequired; + builder.authUser = settings_store::authUser.get(); + builder.authPassword = settings_store::authPassword.get(); + builder.permissions = settings_store::getPermissions(); + + auto settings = builder.build(); host_.reconfigure(std::move(settings)); }); @@ -68,7 +69,7 @@ class InitQuit : public initquit { Logger::setCurrent(&logger_); #ifndef MSRV_OS_MAC - SettingsData::migrate(MSRV_PLAYER_FOOBAR2000, Plugin::getProfileDir()); + migrateSettings(MSRV_PLAYER_FOOBAR2000, Plugin::getProfileDir()); #endif tryCatchLog([this] { plugin_ = std::make_unique(); }); } diff --git a/cpp/server/project_info.hpp.in b/cpp/server/project_info.hpp.in index 11603dbb..fa94c005 100644 --- a/cpp/server/project_info.hpp.in +++ b/cpp/server/project_info.hpp.in @@ -16,10 +16,8 @@ #define MSRV_PROJECT_DESC "Provides web UI and HTTP API for controlling player remotely" #define MSRV_DEFAULT_PORT 8880 #define MSRV_DEFAULT_TEST_PORT 8882 -#define MSRV_CONFIG_FILE "config.json" #define MSRV_CONFIG_FILE_OLD "beefweb.config.json" -#define MSRV_CONFIG_FILE_ENV "BEEFWEB_CONFIG_FILE" -#define MSRV_CLIENT_CONFIG_DIR "clientconfig" +#define MSRV_PROFILE_DIR_ENV "BEEFWEB_PROFILE_DIR" #define MSRV_DONATE_URL "https://hyperblast.org/donate/" #define MSRV_API_DOCS_URL "https://hyperblast.org/beefweb/api/" #define MSRV_PLAYER_DEADBEEF "deadbeef" diff --git a/cpp/server/settings.cpp b/cpp/server/settings.cpp index a102d52d..f057f64a 100644 --- a/cpp/server/settings.cpp +++ b/cpp/server/settings.cpp @@ -4,6 +4,10 @@ #include +#define MSRV_CONFIG_FILE "config.json" +#define MSRV_CLIENT_CONFIG_DIR "clientconfig" +#define MSRV_ALT_WEB_ROOT "webroot" + namespace msrv { namespace { @@ -26,160 +30,132 @@ const PermissionDef permissionDefs[] = { {ApiPermissions::NONE, nullptr}, }; -template -void loadValue(const Json& json, T* value, const char* name) -{ - try - { - auto it = json.find(name); - if (it != json.end()) - *value = it->get(); - } - catch (std::exception& ex) - { - logError("failed to parse property '%s': %s", name, ex.what()); - } -} - -#ifndef MSRV_OS_MAC - -void tryCopyFile(const Path& from, const Path& to) +Path resolvePath(const Path& baseDir, const Path& path) { - boost::system::error_code ec; - - if (fs::is_regular_file(from, ec) && !fs::exists(to, ec)) - { - logInfo("migrating config file: %s -> %s", pathToUtf8(from).c_str(), pathToUtf8(to).c_str()); - - fs::copy_file(from, to, ec); - - if (ec.failed()) - logError("copying failed: %s", ec.message().c_str()); - } + return path.empty() || path.is_absolute() ? path : (baseDir / path).lexically_normal(); } -void tryCopyDirectory(const Path& from, const Path& to, const Path& ext) +std::vector resolveMusicDirs(const Path& baseDir, const std::vector& musicDirs) { - boost::system::error_code ec; - - if (!fs::is_directory(from, ec)) - return; + std::vector result; + result.reserve(musicDirs.size()); - for (auto& entry : fs::directory_iterator(from, ec)) + auto index = 0; + for (const auto& dir : musicDirs) { - if (entry.path().extension() == ext) + if (dir.empty()) { - tryCopyFile(entry.path(), to / entry.path().filename()); + logError("skipping empty music directory at index %d", index); + continue; } - } -} -#endif + result.emplace_back(resolvePath(baseDir, pathFromUtf8(dir))); + index++; + } + return result; } -SettingsData::SettingsData() = default; -SettingsData::~SettingsData() = default; - -#ifndef MSRV_OS_MAC - -void SettingsData::migrate(const char* appName, const Path& profileDir) +Json readJsonFile(const Path& path) { - tryCatchLog([&] { - boost::system::error_code ec; - - auto newConfigDir = profileDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID); - auto newConfigFile = newConfigDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE); - auto newClientConfigDir = newConfigDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + Json result; - if (fs::exists(newClientConfigDir, ec)) + tryCatchLog([&] { + auto file = file_io::open(path); + if (!file) return; - fs::create_directories(newClientConfigDir, ec); - - auto userConfigDir = getUserConfigDir(); - if (!userConfigDir.empty()) - { - auto oldConfigDir = userConfigDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID) / pathFromUtf8(appName); - auto oldConfigFile = oldConfigDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE_OLD); - auto oldClientConfigDir = oldConfigDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + logInfo("loading config file: %s", pathToUtf8(path).c_str()); + auto data = file_io::readToEnd(file.get()); + result = Json::parse(data); + }); - tryCopyFile(oldConfigFile, newConfigFile); - tryCopyDirectory(oldClientConfigDir, newClientConfigDir, MSRV_PATH_LITERAL(".json")); - } + if (!result.is_null() && !result.is_object()) + { + result = Json(); + logError("invalid config: expected json object"); + } - tryCopyFile(getThisModuleDir() / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE_OLD), newConfigFile); - }); + return result; } -#endif -bool SettingsData::isAllowedPath(const Path& path) const +template +bool parseValue(const Json& json, const char* name, T* result) { - for (const auto& root : musicDirs) + try { - if (isSubpath(root, path)) - return true; + auto it = json.find(name); + if (it == json.end()) + return false; + *result = it->get(); + return true; + } + catch (std::exception& ex) + { + logError("failed to parse property '%s': %s", name, ex.what()); + return false; } - - return false; } -void SettingsData::initialize(const Path& resourceDir, const Path& profileDir) +void parsePath(const Json& json, const char* name, const Path& baseDir, Path* result) { - logDebug("init settings: resourceDir = %s, profileDir = %s", resourceDir.c_str(), profileDir.c_str()); + std::string webRoot; - assert(!resourceDir.empty()); - assert(!profileDir.empty()); + if (parseValue(json, name, &webRoot)) + *result = resolvePath(baseDir, webRoot); +} - baseDir = profileDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID); +void parseMusicDirs(const Json& json, const Path& baseDir, std::vector* result) +{ + std::vector musicDirs; - loadFromFile(baseDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE)); + if (parseValue(json, "musicDirs", &musicDirs)) + *result = resolveMusicDirs(baseDir, musicDirs); +} - auto envConfigFile = getEnvAsPath(MSRV_CONFIG_FILE_ENV); - if (!envConfigFile.empty()) +void parsePermission(const Json& json, const char* name, ApiPermissions value, ApiPermissions* result) +{ + auto it = json.find(name); + if (it == json.end()) + return; + + if (!it->is_boolean()) { - if (envConfigFile.is_absolute()) - loadFromFile(envConfigFile); - else - logError("ignoring non-absolute config file path: %s", envConfigFile.c_str()); + logError("failed to parse permission '%s': expected boolean value", name); + return; } - webRoot = resolvePath(pathFromUtf8(webRootOrig)).lexically_normal(); + *result = setFlags(*result, value, it->get()); +} - if (webRoot.empty()) - { - webRoot = resourceDir / MSRV_PATH_LITERAL(MSRV_WEBUI_ROOT); - } +void parsePermissions(const Json& jsonRoot, ApiPermissions* result) +{ + auto it = jsonRoot.find("permissions"); + if (it == jsonRoot.end()) + return; - clientConfigDir = resolvePath(pathFromUtf8(clientConfigDirOrig)).lexically_normal(); + const Json& json = *it; - if (clientConfigDir.empty()) + if (!json.is_object()) { - clientConfigDir = baseDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + logError("failed to parse property 'permissions': expected json object"); + return; } - musicDirs.clear(); - musicDirs.reserve(musicDirsOrig.size()); - - auto index = 0; - for (const auto& dir : musicDirsOrig) - { - if (dir.empty()) - { - logError("skipping empty music directory at index %d", index); - continue; - } + for (int i = 0; permissionDefs[i]; i++) + parsePermission(json, permissionDefs[i].id, permissionDefs[i].value, result); +} - auto path = resolvePath(pathFromUtf8(dir)).lexically_normal(); - musicDirs.emplace_back(std::move(path)); - index++; - } +void parseUrlMappings(const Json& json, const Path& baseDir, std::unordered_map* result) +{ + std::unordered_map urlMappings; + if (!parseValue(json, "urlMappings", &urlMappings)) + return; - urlMappings.clear(); - urlMappings.reserve(urlMappingsOrig.size()); + std::unordered_map urlMappingsResult; - for (const auto& kv : urlMappingsOrig) + for (const auto& kv : urlMappings) { if (kv.first.find(':') != std::string::npos) { @@ -207,85 +183,167 @@ void SettingsData::initialize(const Path& resourceDir, const Path& profileDir) if (prefix.back() != '/') prefix.push_back('/'); - auto path = resolvePath(pathFromUtf8(kv.second)).lexically_normal(); + auto path = resolvePath(baseDir, pathFromUtf8(kv.second)); logInfo("using url mapping '%s' -> '%s'", kv.first.c_str(), kv.second.c_str()); - urlMappings[std::move(prefix)] = std::move(path); + urlMappingsResult[std::move(prefix)] = std::move(path); } + + *result = std::move(urlMappingsResult); } -void SettingsData::loadFromFile(const Path& path) +void processFile(const Path& baseDir, const Path& file, SettingsData* settings) { - tryCatchLog([&] { - auto file = file_io::open(path); - if (!file) - return; - - logInfo("loading config file: %s", pathToUtf8(path).c_str()); - auto data = file_io::readToEnd(file.get()); - loadFromJson(Json::parse(data)); - }); + const auto json = readJsonFile(file); + + parseValue(json, "port", &settings->port); + parseValue(json, "allowRemote", &settings->allowRemote); + parseValue(json, "authRequired", &settings->authRequired); + parseValue(json, "authUser", &settings->authUser); + parseValue(json, "authPassword", &settings->authPassword); + parseValue(json, "responseHeaders", &settings->responseHeaders); + parsePath(json, "webRoot", baseDir, &settings->webRoot); + parsePath(json, "altWebRoot", baseDir, &settings->altWebRoot); + parsePath(json, "clientConfigDir", baseDir, &settings->clientConfigDir); + parseMusicDirs(json, baseDir, &settings->musicDirs); + parseUrlMappings(json, baseDir, &settings->urlMappings); + parsePermissions(json, &settings->permissions); } -void SettingsData::loadFromJson(const Json& json) +#ifndef MSRV_OS_MAC + +void tryCopyFile(const Path& from, const Path& to) { - if (!json.is_object()) + boost::system::error_code ec; + + if (fs::is_regular_file(from, ec) && !fs::exists(to, ec)) { - logError("invalid config: expected json object"); - return; - } + logInfo("migrating config file: %s -> %s", pathToUtf8(from).c_str(), pathToUtf8(to).c_str()); + + fs::copy_file(from, to, ec); - loadValue(json, &port, "port"); - loadValue(json, &allowRemote, "allowRemote"); - loadValue(json, &musicDirsOrig, "musicDirs"); - loadValue(json, &webRootOrig, "webRoot"); - loadValue(json, &authRequired, "authRequired"); - loadValue(json, &authUser, "authUser"); - loadValue(json, &authPassword, "authPassword"); - loadValue(json, &responseHeaders, "responseHeaders"); - loadValue(json, &urlMappingsOrig, "urlMappings"); - loadValue(json, &clientConfigDirOrig, "clientConfigDir"); - loadPermissions(json); + if (ec.failed()) + logError("copying failed: %s", ec.message().c_str()); + } } -void SettingsData::loadPermissions(const Json& jsonRoot) +void tryCopyDirectory(const Path& from, const Path& to, const Path& ext) { - auto it = jsonRoot.find("permissions"); - if (it == jsonRoot.end()) - return; + boost::system::error_code ec; - const Json& json = *it; + if (!fs::is_directory(from, ec)) + return; - if (!json.is_object()) + for (auto& entry : fs::directory_iterator(from, ec)) { - logError("failed to parse property 'permissions': expected json object"); - return; + if (entry.path().extension() == ext) + { + tryCopyFile(entry.path(), to / entry.path().filename()); + } } +} + +#endif +} + +void to_json(Json& json, const ApiPermissions& value) +{ for (int i = 0; permissionDefs[i]; i++) - loadPermission(json, permissionDefs[i].id, permissionDefs[i].value); + json[permissionDefs[i].id] = hasFlags(value, permissionDefs[i].value); } -void SettingsData::loadPermission(const Json& json, const char* name, ApiPermissions value) +bool SettingsData::isAllowedPath(const Path& path) const { - auto it = json.find(name); - if (it == json.end()) - return; + for (const auto& root : musicDirs) + { + if (isSubpath(root, path)) + return true; + } - if (!it->is_boolean()) + return false; +} + +SettingsDataPtr SettingsBuilder::build() const +{ + logDebug( + "build settings: resourceDir = %s, profileDir = %s", + pathToUtf8(resourceDir).c_str(), + pathToUtf8(profileDir).c_str()); + + assert(!resourceDir.empty()); + assert(!profileDir.empty()); + + Path pluginProfileDir = getEnvAsPath(MSRV_PROFILE_DIR_ENV); + + if (!pluginProfileDir.empty()) { - logError("failed to parse permission '%s': expected boolean value", name); - return; + if (pluginProfileDir.is_absolute()) + { + logInfo("using custom profile dir: %s", pathToUtf8(pluginProfileDir).c_str()); + } + else + { + logError("ignoring non-absolute profile dir: %s", pathToUtf8(pluginProfileDir).c_str()); + pluginProfileDir = Path(); + } } - permissions = setFlags(permissions, value, it->get()); + if (pluginProfileDir.empty()) + pluginProfileDir = profileDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID); + + auto settings = std::make_shared(); + settings->port = port; + settings->allowRemote = allowRemote; + settings->permissions = permissions; + settings->authRequired = authRequired; + settings->authUser = authUser; + settings->authPassword = authPassword; + settings->webRoot = resourceDir / MSRV_PATH_LITERAL(MSRV_WEBUI_ROOT); + settings->altWebRoot = pluginProfileDir / MSRV_PATH_LITERAL(MSRV_ALT_WEB_ROOT); + settings->clientConfigDir = pluginProfileDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + settings->musicDirs = resolveMusicDirs(pluginProfileDir, musicDirs); + + processFile(pluginProfileDir, pluginProfileDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE), settings.get()); + + tryCatchLog([&] { fs::create_directories(settings->altWebRoot); }); + tryCatchLog([&] { fs::create_directories(settings->clientConfigDir); }); + + return settings; } -void to_json(Json& json, const ApiPermissions& value) +#ifndef MSRV_OS_MAC + +void migrateSettings(const char* appName, const Path& profileDir) { - for (int i = 0; permissionDefs[i]; i++) - json[permissionDefs[i].id] = hasFlags(value, permissionDefs[i].value); + tryCatchLog([&] { + boost::system::error_code ec; + + auto newConfigDir = profileDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID); + auto newConfigFile = newConfigDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE); + auto newClientConfigDir = newConfigDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + + if (fs::exists(newClientConfigDir, ec)) + return; + + fs::create_directories(newClientConfigDir, ec); + + auto userConfigDir = getUserConfigDir(); + if (!userConfigDir.empty()) + { + auto oldConfigDir = userConfigDir / MSRV_PATH_LITERAL(MSRV_PROJECT_ID) / pathFromUtf8(appName); + auto oldConfigFile = oldConfigDir / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE_OLD); + auto oldClientConfigDir = oldConfigDir / MSRV_PATH_LITERAL(MSRV_CLIENT_CONFIG_DIR); + + tryCopyFile(oldConfigFile, newConfigFile); + tryCopyDirectory(oldClientConfigDir, newClientConfigDir, MSRV_PATH_LITERAL(".json")); + } + + tryCopyFile(getThisModuleDir() / MSRV_PATH_LITERAL(MSRV_CONFIG_FILE_OLD), newConfigFile); + }); } +#endif + } diff --git a/cpp/server/settings.hpp b/cpp/server/settings.hpp index a018c60b..827e0517 100644 --- a/cpp/server/settings.hpp +++ b/cpp/server/settings.hpp @@ -24,33 +24,31 @@ enum class ApiPermissions : uint32_t MSRV_ENUM_FLAGS(ApiPermissions, uint32_t) +void to_json(Json& json, const ApiPermissions& permissions); + +#ifndef MSRV_OS_MAC +void migrateSettings(const char* appName, const Path& profileDir); +#endif + class SettingsData { public: - SettingsData(); - ~SettingsData(); + SettingsData() = default; + ~SettingsData() = default; - Path baseDir; int port = MSRV_DEFAULT_PORT; bool allowRemote = true; - std::vector musicDirsOrig; std::vector musicDirs; - Path webRoot; - std::string webRootOrig; - std::string clientConfigDirOrig; - Path clientConfigDir; - ApiPermissions permissions = ApiPermissions::ALL; - bool authRequired = false; std::string authUser; std::string authPassword; + ApiPermissions permissions = ApiPermissions::ALL; + + Path webRoot; + Path altWebRoot; + Path clientConfigDir; std::unordered_map responseHeaders; std::unordered_map urlMappings; - std::unordered_map urlMappingsOrig; - -#ifndef MSRV_OS_MAC - static void migrate(const char* appName, const Path& profileDir); -#endif void ensurePermissions(ApiPermissions p) const { @@ -59,23 +57,29 @@ class SettingsData } bool isAllowedPath(const Path& path) const; - void initialize(const Path& resourceDir, const Path& profileDir); +}; - Path resolvePath(const Path& path) const - { - return path.empty() || path.is_absolute() ? path : baseDir / path; - } +using SettingsDataPtr = std::shared_ptr; -private: - void loadFromJson(const Json& json); - void loadFromFile(const Path& path); +class SettingsBuilder +{ +public: + SettingsBuilder() = default; + ~SettingsBuilder() = default; - void loadPermissions(const Json& jsonRoot); - void loadPermission(const Json& json, const char* name, ApiPermissions value); -}; + SettingsDataPtr build() const; -using SettingsDataPtr = std::shared_ptr; + Path profileDir; + Path resourceDir; -void to_json(Json& json, const ApiPermissions& permissions); + int port = MSRV_DEFAULT_PORT; + bool allowRemote = true; + std::vector musicDirs; + ApiPermissions permissions = ApiPermissions::ALL; + + bool authRequired = false; + std::string authUser; + std::string authPassword; +}; } diff --git a/cpp/server/static_controller.cpp b/cpp/server/static_controller.cpp index 077ff277..411671dd 100644 --- a/cpp/server/static_controller.cpp +++ b/cpp/server/static_controller.cpp @@ -1,4 +1,7 @@ #include "static_controller.hpp" + +#include + #include "file_system.hpp" #include "content_type_map.hpp" #include "router.hpp" @@ -8,8 +11,12 @@ namespace msrv { StaticController::StaticController( - Request* request, const Path& targetDir, const ContentTypeMap& contentTypes) - : ControllerBase(request), targetDir_(targetDir), contentTypes_(contentTypes) + Request* request, + PathVectorPtr targetDirs, + const ContentTypeMap& contentTypes) + : ControllerBase(request), + targetDirs_(std::move(targetDirs)), + contentTypes_(contentTypes) { } @@ -25,20 +32,13 @@ std::string StaticController::getNormalizedPath() auto isDirectory = fullPath.back() == '/'; auto path = optionalParam("path"); - if (!path || path->empty()) - { - if (isDirectory) - return "index.html"; - else - return std::string(); - } - else - { - if (isDirectory) - return *path + "/index.html"; - else - return *path; - } + return path && !path->empty() + ? isDirectory // subpath of target dir + ? *path + "/index.html" + : *path + : isDirectory // exactly target dir + ? std::string("index.html") + : std::string(); } ResponsePtr StaticController::redirectToDirectory() @@ -52,33 +52,58 @@ ResponsePtr StaticController::getFile() if (requestPath.empty()) return redirectToDirectory(); - auto filePath = (targetDir_ / pathFromUtf8(requestPath)).lexically_normal(); + for (const auto& targetDir : *targetDirs_) + { + auto filePath = (targetDir / pathFromUtf8(requestPath)).lexically_normal(); - if (!isSubpath(targetDir_, filePath)) - return Response::notFound(); + if (!isSubpath(targetDir, filePath)) + throw InvalidRequestException("invalid request path"); - auto info = file_io::tryQueryInfo(filePath); - if (!info) - return Response::notFound(); +#ifdef MSRV_OS_WINDOWS + // Windows can't open directories as files - switch (info->type) - { - case FileType::REGULAR: - { + auto infoResult = file_io::tryQueryInfo(filePath); + if (!infoResult) + continue; + + auto info = *infoResult; + FileHandle handle; + + if (info.type == FileType::REGULAR) + { + handle = file_io::open(filePath); + if (!handle) + continue; + } +#else auto handle = file_io::open(filePath); if (!handle) - return Response::notFound(); - - const auto& contentType = contentTypes_.byFilePath(filePath); - return Response::file(std::move(filePath), std::move(handle), *info, contentType); + continue; + + auto info = file_io::queryInfo(handle.get()); +#endif + + switch (info.type) + { + case FileType::REGULAR: + { + auto contentType = contentTypes_.byFilePath(filePath); + return Response::file( + std::move(filePath), + std::move(handle), + info, + std::move(contentType)); + } + + case FileType::DIRECTORY: + return redirectToDirectory(); + + case FileType::UNKNOWN: + break; + } } - case FileType::DIRECTORY: - return redirectToDirectory(); - - default: - return Response::notFound(); - } + return Response::notFound(); } void StaticController::defineRoutes( @@ -89,23 +114,39 @@ void StaticController::defineRoutes( { for (auto& kv : settings->urlMappings) { - defineRoutes(router, workQueue, kv.first, kv.second, contentTypes); + auto dirs = std::make_shared>(1, kv.second); + defineRoutes(router, workQueue, kv.first, std::move(dirs), contentTypes); } + if (settings->webRoot.empty() && settings->altWebRoot.empty()) + return; + + auto targetDirs = std::make_shared>(); + if (!settings->webRoot.empty()) - defineRoutes(router, workQueue, "/", settings->webRoot, contentTypes); + targetDirs->emplace_back(settings->webRoot); + + if (!settings->altWebRoot.empty()) + targetDirs->emplace_back(settings->altWebRoot); + + defineRoutes(router, workQueue, "/", std::move(targetDirs), contentTypes); } void StaticController::defineRoutes( Router* router, WorkQueue* workQueue, const std::string& urlPrefix, - const Path& targetDir, + PathVectorPtr targetDirs, const ContentTypeMap& contentTypes) { + assert(!targetDirs->empty()); + auto routes = router->defineRoutes(); - routes.createWith([=](Request* request) { return new StaticController(request, targetDir, contentTypes); }); + routes.createWith([=](Request* request) { + return new StaticController(request, targetDirs, contentTypes); + }); + routes.useWorkQueue(workQueue); routes.get(urlPrefix, &StaticController::getFile); diff --git a/cpp/server/static_controller.hpp b/cpp/server/static_controller.hpp index ed4eebbd..fad2a8dd 100644 --- a/cpp/server/static_controller.hpp +++ b/cpp/server/static_controller.hpp @@ -16,7 +16,7 @@ class StaticController : public ControllerBase public: StaticController( Request* request, - const Path& targetDir, + PathVectorPtr targetDirs, const ContentTypeMap& contentTypes); ~StaticController(); @@ -28,18 +28,18 @@ class StaticController : public ControllerBase SettingsDataPtr settings, const ContentTypeMap& contentTypes); +private: static void defineRoutes( Router* router, WorkQueue* workQueue, const std::string& urlPrefix, - const Path& targetDir, + PathVectorPtr targetDirs, const ContentTypeMap& contentTypes); -private: std::string getNormalizedPath(); ResponsePtr redirectToDirectory(); - const Path& targetDir_; + PathVectorPtr targetDirs_; const ContentTypeMap& contentTypes_; }; diff --git a/docs/advanced-config.md b/docs/advanced-config.md index 3b74db42..73b4f96b 100644 --- a/docs/advanced-config.md +++ b/docs/advanced-config.md @@ -1,24 +1,36 @@ # Advanced configuration -Advanced configuration is performed by editing configuration file. +## Profile directory -The following configuration sources are considered when loading configuration (in the order of increase of preference): +Beefweb keeps all settings in `{player_profile_dir}/beefweb/` directory: +- foobar2000 on Windows: `%APPDATA%\foobar2000-v2\beefweb\` +- foobar2000 on macOS: `$HOME/Library/foobar2000-v2/beefweb/` +- DeaDBeeF on Linux/*BSD: `$XDG_CONFIG_HOME/deadbeef/beefweb/` (or `$HOME/.config/deadbeef/beefweb/`) +- DeaDBeeF on macOS: `$HOME/Library/Preferences/deadbeef/beefweb/` -* Settings in UI -* `{player_profile_dir}/beefweb/config.json` - - foobar2000 on Windows: `%APPDATA%\foobar2000-v2\beefweb\config.json` - - foobar2000 on macOS: `$HOME/Library/foobar2000-v2/beefweb/config.json` - - DeaDBeeF on Linux/*BSD: `$XDG_CONFIG_HOME/deadbeef/beefweb/config.json` or `$HOME/.config/deadbeef/beefweb/config.json` - - DeaDBeeF on macOS: `$HOME/Library/Preferences/deadbeef/beefweb/config.json` -* File specified by `BEEFWEB_CONFIG_FILE` environment variable (must be absolute) +### Overriding profile directory -If setting is specified in more preferred source it overrides values defined in less preferred. +If environment variable `BEEFWEB_PROFILE_DIR` is specified, it overrides default beefweb profile directory. + +This path must be absolute. + +### Serving custom web content + +It is possible to serve custom web content (e.g. custom UI) using built-in web server. + +If certain file does not exist in bundled web resources corresponding file inside `{beefweb_profile_dir}/webroot/` will be used. + +## Configuration file + +Advanced configuration is performed by editing configuration file stored in `{beefweb_profile_dir}/config.json`. + +If setting is specified in configuration file, it overrides setting in UI. All values are optional, you can specify only those you want to override. The following options are available: -```js +```json { "port": 8880, "allowRemote": true, @@ -26,18 +38,17 @@ The following options are available: "authRequired": false, "authUser": "", "authPassword": "", - "webRoot": ".../beefweb.root", // path inside installation directory, directory layout is different depending on OS/player + "webRoot": "{beefweb_binary_dir}/beefweb.root/", + "altWebRoot": "{beefweb_profile_dir}/webroot/", "urlMappings": {}, "responseHeaders": {}, - "clientConfigDir": "{player profile directory}/beefweb/clientconfig" + "clientConfigDir": "{beefweb_profile_dir}/clientconfig/" } ``` ### Non-absolute paths -Unless specified otherwise any path in configuration could be non-absolute. - -Such paths are resolved relative to `{player_profile_dir}/beefweb` directory. +Non-absolute paths in configuration file are resolved relative to Beefweb profile directory. ### Network settings @@ -61,6 +72,8 @@ Such paths are resolved relative to `{player_profile_dir}/beefweb` directory. `webRoot: string` - Root directory where static web content is located. +`altWebRoot: string` - Alternative web root directory, if file is not found in `webRoot` corresponding file in `altWebRoot` is also tried. + `urlMappings: {string: string}` - Alternative web directories defined by URL prefix The following configuration file uses `C:\MyWebPage` directory to serve requests starting with `/mywebpage`: diff --git a/js/api_tests/src/static_files_tests.js b/js/api_tests/src/static_files_tests.js index 38b5248c..63a1a74e 100644 --- a/js/api_tests/src/static_files_tests.js +++ b/js/api_tests/src/static_files_tests.js @@ -27,18 +27,18 @@ function assertRedirect(assert, result, location) assert.equal(result.headers["location"], location); } -describe('static files', () => { - setupPlayer({ pluginSettings, axiosConfig }); +function getFile(url, config) +{ + return client.handler.axios.get(url, config); +} - function getFile(url, config) - { - return client.handler.axios.get(url, config); - } +function getFileData(url) +{ + return readFile(path.join(config.webRootDir, url), 'utf8'); +} - function getFileData(url) - { - return readFile(path.join(config.webRootDir, url), 'utf8'); - } +describe('static files', () => { + setupPlayer({ pluginSettings, axiosConfig }); test('get index of root', async () => { const result = await getFile('/'); @@ -55,21 +55,16 @@ describe('static files', () => { assert.equal(result.data, 'index.html\n'); }); - test('redirect index of prefix', async () => { - const result = await getFile('/prefix'); - assert.equal(result.headers["location"], '/prefix/'); - }); - - test('redirect index of nested prefix', async () => { - const result = await getFile('/prefix/nested'); - assertRedirect(assert, result, '/prefix/nested/'); - }); - test('get file of root', async () => { const result = await getFile('/file.html'); assert.equal(result.data, 'file.html\n'); }); + test('get file of alt root', async () => { + const result = await getFile('/altfile.html'); + assert.equal(result.data, 'altfile.html\n'); + }); + test('get file of prefix', async () => { const result = await getFile('/prefix/file.html'); assert.equal(result.data, 'file.html\n'); @@ -85,6 +80,11 @@ describe('static files', () => { assert.equal(result.data, 'subdir/index.html\n'); }); + test('get subdir index of alt root', async () => { + const result = await getFile('/altsubdir/'); + assert.equal(result.data, 'altsubdir/index.html\n'); + }); + test('get subdir index of prefix', async () => { const result = await getFile('/prefix/subdir/'); assert.equal(result.data, 'subdir/index.html\n'); @@ -95,6 +95,31 @@ describe('static files', () => { assert.equal(result.data, 'subdir/index.html\n'); }); + test('get subdir file of root', async () => { + const result = await getFile('/subdir/file.html'); + assert.equal(result.data, 'subdir/file.html\n'); + }); + + test('get subdir file of alt root', async () => { + const result = await getFile('/altsubdir/file.html'); + assert.equal(result.data, 'altsubdir/file.html\n'); + }); + + test('get subdir file of prefix', async () => { + const result = await getFile('/prefix/subdir/file.html'); + assert.equal(result.data, 'subdir/file.html\n'); + }); + + test('get subdir file of nested prefix', async () => { + const result = await getFile('/prefix/nested/subdir/file.html'); + assert.equal(result.data, 'subdir/file.html\n'); + }); + + test('redirect subdir index of alt root', async () => { + const result = await getFile('/altsubdir'); + assertRedirect(assert, result, '/altsubdir/'); + }); + test('redirect subdir index of root', async () => { const result = await getFile('/subdir'); assertRedirect(assert, result, '/subdir/'); @@ -110,19 +135,14 @@ describe('static files', () => { assertRedirect(assert, result, '/prefix/nested/subdir/'); }); - test('get subdir file of root', async () => { - const result = await getFile('/subdir/file.html'); - assert.equal(result.data, 'subdir/file.html\n'); - }); - - test('get subdir file of prefix', async () => { - const result = await getFile('/prefix/subdir/file.html'); - assert.equal(result.data, 'subdir/file.html\n'); + test('redirect index of prefix', async () => { + const result = await getFile('/prefix'); + assert.equal(result.headers["location"], '/prefix/'); }); - test('get subdir file of nested prefix', async () => { - const result = await getFile('/prefix/nested/subdir/file.html'); - assert.equal(result.data, 'subdir/file.html\n'); + test('redirect index of nested prefix', async () => { + const result = await getFile('/prefix/nested'); + assertRedirect(assert, result, '/prefix/nested/'); }); test('provide content type', async () => { @@ -138,10 +158,11 @@ describe('static files', () => { 'file.txt': 'text/plain; charset=utf-8', }; - for (let file of Object.keys(contentTypes)) + for (let file in contentTypes) { const result = await getFile(file); - assert.equal(result.headers['content-type'], contentTypes[file]); + assert.equal(result.status, 200, 'invalid http status for ' + file); + assert.equal(result.headers['content-type'], contentTypes[file], 'invalid content type for ' + file); } }); @@ -204,22 +225,25 @@ describe('static files', () => { }); test('escape root dir', async () => { + const result0 = await getFile('/../../../../../../../etc/passwd', ignoreStatus); + assert.equal(result0.status, 400); + const result1 = await getFile('/../package.json', ignoreStatus); - assert.equal(result1.status, 404); + assert.equal(result1.status, 400); const result2 = await getFile('/%2E%2E/package.json', ignoreStatus); - assert.equal(result2.status, 404); + assert.equal(result2.status, 400); const result3 = await getFile('/prefix/../package.json', ignoreStatus); - assert.equal(result3.status, 404); + assert.equal(result3.status, 400); const result4 = await getFile('/prefix/%2E%2E/package.json', ignoreStatus); - assert.equal(result4.status, 404); + assert.equal(result4.status, 400); const result5 = await getFile('/prefix/nested/../package.json', ignoreStatus); - assert.equal(result5.status, 404); + assert.equal(result5.status, 400); const result6 = await getFile('/prefix/nested/%2E%2E/package.json', ignoreStatus); - assert.equal(result6.status, 404); + assert.equal(result6.status, 400); }); }); diff --git a/js/api_tests/src/test_context.js b/js/api_tests/src/test_context.js index b93e4c10..36521f89 100644 --- a/js/api_tests/src/test_context.js +++ b/js/api_tests/src/test_context.js @@ -155,6 +155,7 @@ export class TestContextFactory const serverUrl = `http://127.0.0.1:${port}`; const webRootDir = path.join(testsRootDir, 'webroot'); + const altWebRootDir = path.join(testsRootDir, 'webroot2'); const musicDir = path.join(testsRootDir, 'tracks'); const pluginSettings = { @@ -162,6 +163,7 @@ export class TestContextFactory allowRemote: false, musicDirs: [musicDir], webRoot: webRootDir, + altWebRoot: altWebRootDir, }; return { @@ -171,6 +173,7 @@ export class TestContextFactory serverUrl, pluginBuildDir, webRootDir, + altWebRootDir, musicDir, pluginSettings, }; diff --git a/js/api_tests/webroot2/altfile.html b/js/api_tests/webroot2/altfile.html new file mode 100644 index 00000000..79b42be9 --- /dev/null +++ b/js/api_tests/webroot2/altfile.html @@ -0,0 +1 @@ +altfile.html diff --git a/js/api_tests/webroot2/altsubdir/file.html b/js/api_tests/webroot2/altsubdir/file.html new file mode 100644 index 00000000..d03af182 --- /dev/null +++ b/js/api_tests/webroot2/altsubdir/file.html @@ -0,0 +1 @@ +altsubdir/file.html diff --git a/js/api_tests/webroot2/altsubdir/index.html b/js/api_tests/webroot2/altsubdir/index.html new file mode 100644 index 00000000..1ce42c51 --- /dev/null +++ b/js/api_tests/webroot2/altsubdir/index.html @@ -0,0 +1 @@ +altsubdir/index.html diff --git a/js/api_tests/webroot2/index.html b/js/api_tests/webroot2/index.html new file mode 100644 index 00000000..b9bc98cb --- /dev/null +++ b/js/api_tests/webroot2/index.html @@ -0,0 +1 @@ +Should not use this