Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
433 lines
18 KiB
C++
433 lines
18 KiB
C++
#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 <algorithm>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <stdexcept>
|
|
#include <system_error>
|
|
#include <vector>
|
|
|
|
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<std::filesystem::path(std::string_view)> &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<std::streamsize>(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<std::filesystem::path> &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<std::streamoff>(offset), std::ios::beg);
|
|
response.body.resize(static_cast<size_t>(count));
|
|
file.read(response.body.data(), static_cast<std::streamsize>(response.body.size()));
|
|
response.body.resize(static_cast<size_t>(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<char>(file),
|
|
std::istreambuf_iterator<char>());
|
|
}
|
|
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<HttpResponse> 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<HttpResponse> resolve_ui_package_model_load(
|
|
const engine::io::json::Value &body,
|
|
const std::string &package_id,
|
|
const std::optional<engine::package_manager::PackageInfo> &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<std::string, std::string> &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<std::filesystem::path> 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<DirectoryEntry> 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
|