#include "server_ui.h" #include "server_protocol.h" #include "ui_assets.h" #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) #include "model_installer.h" #include "engine/framework/runtime/registry.h" #endif #include "engine/framework/io/json.h" #include #include #include #include #include #include namespace minitts::server { std::filesystem::path resolve_ui_model_path( const std::filesystem::path &path, const std::filesystem::path &request_base, const std::filesystem::path &repository_root) { if (path.is_absolute()) { return path; } const auto relative = path.lexically_normal(); const auto generic = relative.generic_string(); if (!repository_root.empty() && (generic == "assets/framework" || generic.rfind("assets/framework/", 0) == 0)) { return (repository_root / relative).lexically_normal(); } return (request_base / relative).lexically_normal(); } HttpResponse ui_path_status_response( const std::string &body_text, bool ui_management, const std::filesystem::path &request_base, const std::filesystem::path &repository_root) { if (!ui_management) { return error_response(403, "UI path inspection is disabled", "forbidden"); } const auto body = engine::io::json::parse(body_text); const auto path = resolve_ui_model_path( engine::io::json::require_string(body, "path"), request_base, repository_root); std::error_code ec; const bool exists = std::filesystem::exists(path, ec); const bool directory = exists && std::filesystem::is_directory(path, ec); const bool regular_file = exists && std::filesystem::is_regular_file(path, ec); return json_response( "{\"path\":" + json_quote(path.string()) + ",\"exists\":" + (exists ? "true" : "false") + ",\"directory\":" + (directory ? "true" : "false") + ",\"file\":" + (regular_file ? "true" : "false") + "}"); } HttpResponse ui_upload_response( const HttpRequest &request, bool enabled, const std::function &make_path) { if (!enabled) { return error_response(403, "UI uploads are disabled", "forbidden"); } if (request.body.empty()) { return error_response(400, "upload body is empty", "invalid_request_error"); } constexpr size_t kMaxUploadBytes = size_t{2} * 1024 * 1024 * 1024; if (request.body.size() > kMaxUploadBytes) { return error_response(413, "upload exceeds the 2 GiB limit", "invalid_request_error"); } std::string filename = "audio.wav"; if (const auto it = request.headers.find("x-audiocpp-filename"); it != request.headers.end()) { filename = safe_upload_name(it->second); } const auto path = make_path(filename); std::ofstream out(path, std::ios::binary); if (!out) { throw std::runtime_error("could not create temporary upload: " + path.string()); } out.write(request.body.data(), static_cast(request.body.size())); if (!out) { throw std::runtime_error("could not write temporary upload: " + path.string()); } return json_response( "{\"path\":" + json_quote(path.string()) + ",\"bytes\":" + std::to_string(request.body.size()) + "}"); } HttpResponse ui_asset_response(bool ui_enabled) { if (!ui_enabled) { return error_response(404, "WebUI is disabled", "not_found"); } HttpResponse response; response.status = 200; response.content_type = "text/html; charset=utf-8"; const auto html = embedded_ui_html(); response.body.assign(html.data(), html.size()); // The WebUI shares the server origin (usually localhost:8080) with any app // that previously occupied that port. Never let an old shell survive a // server upgrade, and ask the browser to discard only cached resources. // Deliberately omit the Clear-Site-Data "storage" directive: saved voices, // the selected models folder, and UI preferences live in local storage. response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"; response.headers["Clear-Site-Data"] = "\"cache\""; response.headers["Expires"] = "0"; response.headers["Pragma"] = "no-cache"; response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' blob: data:; connect-src 'self'; media-src 'self' blob: data:"; response.headers["X-Content-Type-Options"] = "nosniff"; return response; } HttpResponse ui_voice_preview_response( const HttpRequest &request, bool ui_enabled, const std::optional &voice_dir) { if (!ui_enabled) { return error_response(404, "WebUI is disabled", "not_found"); } if (!voice_dir.has_value()) { return error_response(404, "voice library is not configured", "not_found"); } const std::string voice = decoded_query_param(request.query, "voice"); const auto wav = resolve_voice_library_wav(*voice_dir, voice); if (!wav.has_value()) { return error_response(404, "voice preview is not available", "not_found"); } const auto file_size = std::filesystem::file_size(*wav); std::ifstream file(*wav, std::ios::binary); if (!file) { return error_response(404, "voice preview is not available", "not_found"); } std::uintmax_t offset = 0; std::uintmax_t count = file_size; bool partial = false; if (const auto range = request.headers.find("range"); range != request.headers.end() && range->second.rfind("bytes=", 0) == 0) { const std::string spec = range->second.substr(6); const size_t dash = spec.find('-'); if (dash != std::string::npos && dash > 0) { const std::uintmax_t begin = std::stoull(spec.substr(0, dash)); const std::uintmax_t end = dash + 1 < spec.size() ? std::stoull(spec.substr(dash + 1)) : file_size - 1; if (begin >= file_size || end < begin) { HttpResponse response; response.status = 416; response.content_type = "text/plain"; response.headers["Content-Range"] = "bytes */" + std::to_string(file_size); response.headers["Accept-Ranges"] = "bytes"; return response; } offset = begin; count = std::min(end, file_size - 1) - begin + 1; partial = true; } } HttpResponse response; response.status = partial ? 206 : 200; response.content_type = "audio/wav"; if (partial) { file.seekg(static_cast(offset), std::ios::beg); response.body.resize(static_cast(count)); file.read(response.body.data(), static_cast(response.body.size())); response.body.resize(static_cast(file.gcount())); response.headers["Content-Range"] = "bytes " + std::to_string(offset) + "-" + std::to_string(offset + response.body.size() - 1) + "/" + std::to_string(file_size); } else { response.body.assign( std::istreambuf_iterator(file), std::istreambuf_iterator()); } response.headers["Accept-Ranges"] = "bytes"; response.headers["Cache-Control"] = "no-store"; response.headers["X-Content-Type-Options"] = "nosniff"; return response; } #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) UiModelInstallRequest parse_ui_model_install_request(const std::string &body_text) { const auto body = engine::io::json::parse(body_text); UiModelInstallRequest request; request.package_id = engine::io::json::require_string(body, "id"); request.source_directory = engine::io::json::optional_string(body, "source_directory", ""); request.source_file = engine::io::json::optional_string(body, "source_file", ""); request.output_file = engine::io::json::optional_string(body, "output_file", ""); request.variant = engine::io::json::optional_string(body, "variant", ""); request.overwrite = engine::io::json::optional_bool(body, "overwrite", false); return request; } std::string parse_ui_model_id(const std::string &body_text) { return engine::io::json::require_string(engine::io::json::parse(body_text), "id"); } HttpResponse ui_model_install_response( const UiModelInstallRequest &request, ModelInstaller &installer) { return json_response(installer.start( request.package_id, request.source_file, request.output_file, request.source_directory, request.variant, request.overwrite)); } HttpResponse ui_model_remove_response( const std::string &package_id, ModelInstaller &installer) { return json_response(installer.remove(package_id)); } HttpResponse ui_model_install_stop_response( const std::string &package_id, ModelInstaller &installer) { return json_response(installer.stop(package_id)); } HttpResponse ui_model_clean_partial_response( const std::string &package_id, ModelInstaller &installer) { return json_response(installer.clean_partial(package_id)); } HttpResponse ui_model_install_status_response( const HttpRequest &request, const ModelInstaller &installer) { return json_response(installer.status(query_param(request.query, "id"))); } HttpResponse ui_model_package_sizes_response(ModelInstaller &installer) { namespace json = engine::io::json; auto inventory = json::parse(installer.package_sizes()).as_object(); const auto registry = engine::runtime::make_default_registry(); auto rows = inventory.at("data").as_array(); for (auto & value : rows) { auto row = value.as_object(); const auto & metadata = row.at("package"); engine::assets::ModelPackage package; package.root = json::require_string(metadata, "root"); package.format = json::require_string(metadata, "format"); for (const auto & file : metadata.require("files").as_array()) { package.files.emplace_back(file.as_string()); } try { const auto target = registry.resolve_package_load(json::require_string(metadata, "family"), package); json::Value::Object load_options, session_options; for (const auto & option : target.load_options) { load_options[option.first] = json::Value::make_string(option.second); } for (const auto & option : target.session_options) { session_options[option.first] = json::Value::make_string(option.second); } json::Value::Object resolved; resolved["family"] = json::Value::make_string(target.family); resolved["model_path"] = json::Value::make_string(target.model_path.generic_string()); resolved["load_options"] = json::Value::make_object(std::move(load_options)); resolved["session_options"] = json::Value::make_object(std::move(session_options)); row["load_target"] = json::Value::make_object(std::move(resolved)); } catch (const std::exception & error) { row["load_target_error"] = json::Value::make_string(error.what()); } value = json::Value::make_object(std::move(row)); } inventory["data"] = json::Value::make_array(std::move(rows)); return json_response(json::stringify(json::Value::make_object(std::move(inventory)))); } std::optional ui_model_load_package_id( const engine::io::json::Value &body, std::string &package_id) { package_id.clear(); const auto *package_value = body.find("package_id"); if (package_value == nullptr) { return std::nullopt; } if (!package_value->is_string() || package_value->as_string().empty()) { return error_response(400, "package_id must be a non-empty string", "invalid_request_error"); } if (body.find("path") != nullptr) { return error_response(400, "send either path or package_id, not both", "invalid_request_error"); } package_id = package_value->as_string(); return std::nullopt; } std::optional resolve_ui_package_model_load( const engine::io::json::Value &body, const std::string &package_id, const std::optional &info, std::string &resolved_body) { namespace json = engine::io::json; if (!info) { return error_response(404, "unknown package id: " + package_id, "not_found"); } if (!info->installed) { return error_response(404, "package is not installed: " + package_id, "not_found"); } engine::runtime::PackageLoadTarget target; try { target = engine::runtime::make_default_registry().resolve_package_load(info->family, info->package); } catch (const std::exception &error) { return error_response(400, "package " + package_id + " cannot be loaded: " + error.what(), "invalid_request_error"); } auto fields = body.as_object(); if (const auto found = fields.find("family"); found == fields.end()) { fields["family"] = json::Value::make_string(target.family); } else if (!found->second.is_string() || found->second.as_string() != target.family) { return error_response(400, "package " + package_id + " is for family " + target.family, "invalid_request_error"); } for (const char *key : {"load_options", "session_options"}) { const auto *options = body.find(key); if (options && !options->is_object() && !options->is_null()) { return error_response(400, std::string(key) + " must be an object or null", "invalid_request_error"); } } // Resolved options fill in what the request leaves out; keys it sends win. const auto merge_options = [&](const char *key, const std::unordered_map &resolved) { if (resolved.empty()) return; json::Value::Object options; if (const auto found = fields.find(key); found != fields.end() && found->second.is_object()) { options = found->second.as_object(); } for (const auto &option : resolved) { options.emplace(option.first, json::Value::make_string(option.second)); } fields[key] = json::Value::make_object(std::move(options)); }; merge_options("load_options", target.load_options); merge_options("session_options", target.session_options); fields.erase("package_id"); fields["path"] = json::Value::make_string(target.model_path.string()); resolved_body = json::stringify(json::Value::make_object(std::move(fields))); return std::nullopt; } HttpResponse ui_models_root_response( const std::filesystem::path &models_root, const std::filesystem::path &default_models_root) { return json_response( "{\"models_root\":" + json_quote(models_root.string()) + ",\"default_models_root\":" + json_quote(default_models_root.string()) + ",\"is_default\":" + (models_root == default_models_root ? "true" : "false") + "}"); } HttpResponse ui_directory_browser_response( const std::string &requested_path, const std::filesystem::path &models_root, const std::filesystem::path &request_base) { auto current = requested_path.empty() ? models_root : std::filesystem::path(requested_path); if (current.is_relative()) { current = request_base / current; } current = std::filesystem::absolute(current).lexically_normal(); std::error_code error; if (!std::filesystem::is_directory(current, error)) { return error_response( 400, "folder is not accessible: " + current.string(), "invalid_request_error"); } std::vector roots; #ifdef _WIN32 for (char drive = 'A'; drive <= 'Z'; ++drive) { const auto root = std::filesystem::path(std::string(1, drive) + ":\\"); error.clear(); if (std::filesystem::is_directory(root, error)) { roots.push_back(root); } } #else roots.emplace_back("/"); #endif struct DirectoryEntry { std::string name; std::filesystem::path path; }; std::vector directories; error.clear(); std::filesystem::directory_iterator iterator( current, std::filesystem::directory_options::skip_permission_denied, error); const std::filesystem::directory_iterator end; while (!error && iterator != end) { std::error_code entry_error; if (iterator->is_directory(entry_error)) { directories.push_back(DirectoryEntry{ iterator->path().filename().string(), iterator->path().lexically_normal(), }); } iterator.increment(error); } std::sort(directories.begin(), directories.end(), [](const auto &left, const auto &right) { return left.name < right.name; }); auto parent = current.parent_path(); if (parent == current) { parent.clear(); } std::string response = "{\"current\":" + json_quote(current.string()) + ",\"parent\":" + json_quote(parent.string()) + ",\"roots\":["; for (size_t index = 0; index < roots.size(); ++index) { if (index > 0) response += ","; response += json_quote(roots[index].string()); } response += "],\"directories\":["; for (size_t index = 0; index < directories.size(); ++index) { if (index > 0) response += ","; response += "{\"name\":" + json_quote(directories[index].name) + ",\"path\":" + json_quote(directories[index].path.string()) + "}"; } return json_response(response + "]}"); } #endif } // namespace minitts::server