#include "connector_service.hpp" #include "config.hpp" #include "encoding.hpp" #include "json.hpp" #include "platform.hpp" #include "workspace.hpp" #include "zip.hpp" #include #include #include #include #include #include #include namespace connector { namespace { constexpr const char* kVersion = "2.0.0"; constexpr unsigned short kPort = 27843; std::mutex pairing_mutex; struct Pairing { std::string origin; std::chrono::steady_clock::time_point expires; }; std::map pairing; std::string header(const HttpRequest& request, const std::string& name) { const auto found = request.headers.find(name); return found == request.headers.end() ? "" : found->second; } void add_cors(HttpResponse& response, const std::string& origin) { if (!origin.empty()) response.headers["Access-Control-Allow-Origin"] = origin; response.headers["Vary"] = "Origin"; response.headers["Access-Control-Allow-Private-Network"] = "true"; response.headers["Access-Control-Allow-Headers"] = "Content-Type"; response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; } HttpResponse json_response(int status, Json::Object object, const std::string& origin) { HttpResponse response; response.status = status; response.headers["Content-Type"] = "application/json; charset=utf-8"; response.body = Json(std::move(object)).dump(); add_cors(response, origin); return response; } HttpResponse error_response(int status, const std::string& code, const std::string& message, const std::string& origin) { return json_response(status, {{"code", Json(code)}, {"message", Json(message)}}, origin); } HttpResponse html_response(int status, const std::string& body) { HttpResponse response; response.status = status; response.headers["Content-Type"] = "text/html; charset=utf-8"; response.headers["Cache-Control"] = "no-store"; response.body = "Spine粒子连接器
" + body + "
"; return response; } std::string nonce() { std::random_device device; static const char* digits = "0123456789abcdef"; std::string value(48, '0'); for (char& character : value) character = digits[device() & 15]; return value; } std::string form_value(const std::string& body, const std::string& key) { std::size_t start = 0; while (start <= body.size()) { const auto end = body.find('&', start); const std::string part = body.substr(start, end - start); const auto equal = part.find('='); if (url_decode(part.substr(0, equal)) == key) return url_decode(equal == std::string::npos ? "" : part.substr(equal + 1)); if (end == std::string::npos) break; start = end + 1; } return ""; } std::string sanitize_process_error(std::string output) { output = std::regex_replace(output, std::regex(R"(^Licensed to:.*$)", std::regex::icase | std::regex::multiline), "Spine 授权信息已隐藏"); return trim(output); } std::pair, std::string> convert(const Json& payload, const std::string& executable) { ConversionWorkspace workspace(payload); const bool spine38 = workspace.spine_version == "3.8"; const std::string editor_version = workspace.spine_version + ".xx"; const std::string input = spine38 ? workspace.input_path.filename().u8string() : workspace.input_path.u8string(); const std::string output = spine38 ? workspace.output_path.filename().u8string() : workspace.output_path.u8string(); const ProcessResult result = run_process(executable, { "--update", editor_version, "--input", input, "--output", output, "--import", workspace.project_name }, workspace.root, 180); if (result.timed_out) throw std::runtime_error("Spine 转换超时"); if (result.exit_code != 0) { const std::string details = sanitize_process_error(result.output); throw std::runtime_error(details.empty() ? "Spine 命令执行失败" : details); } std::vector project = workspace.read_output(); if (project.empty()) throw std::runtime_error("Spine 未生成有效的工程文件"); std::vector files; files.push_back({workspace.project_name + ".spine", std::move(project)}); for (auto& image : workspace.archive_images) files.push_back(std::move(image)); std::smatch match; std::string actual = editor_version; if (std::regex_search(result.output, match, std::regex(R"(Starting:\s+Spine\s+([0-9.]+))", std::regex::icase))) actual = match[1].str(); return {create_zip(files), actual}; } } // namespace bool ConnectorService::start(std::string& error) { return server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error); } void ConnectorService::stop() { server_.stop(); } HttpResponse ConnectorService::handle(const HttpRequest& request) { const std::string origin = header(request, "origin"); if (request.method == "OPTIONS") { HttpResponse response; response.status = 204; add_cors(response, origin); return response; } try { if (request.method == "GET" && request.path == "/v1/health") { std::lock_guard lock(config_mutex_); Json::Array versions{Json("3.8"), Json("4.0"), Json("4.1"), Json("4.2"), Json("4.3")}; return json_response(200, { {"connectorVersion", Json(kVersion)}, {"authorized", Json(origin_allowed(origin))}, {"spineFound", Json(!find_spine_executable().empty())}, {"platform", Json(platform_name())}, {"supportedVersions", Json(std::move(versions))}, }, origin); } if (request.method == "GET" && request.path == "/pair") { const auto found = request.query.find("origin"); const std::string requested = found == request.query.end() ? "" : found->second; if (!valid_web_origin(requested)) return html_response(400, "

授权地址无效

"); const std::string token = nonce(); { std::lock_guard lock(pairing_mutex); pairing[token] = {requested, std::chrono::steady_clock::now() + std::chrono::minutes(5)}; } return html_response(200, "

授权 Spine粒子编辑器

是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 .spine 文件?

" + html_escape(requested) + "

"); } if (request.method == "POST" && request.path == "/pair") { const std::string token = form_value(request.body, "nonce"); Pairing pending; { std::lock_guard lock(pairing_mutex); const auto found = pairing.find(token); if (found == pairing.end()) return html_response(400, "

授权已过期,请返回编辑器重试

"); pending = found->second; pairing.erase(found); } if (pending.expires < std::chrono::steady_clock::now()) return html_response(400, "

授权已过期,请返回编辑器重试

"); { std::lock_guard lock(config_mutex_); allow_origin(pending.origin); } return html_response(200, "

授权成功

" + html_escape(pending.origin) + " 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。

"); } if (request.method == "POST" && request.path == "/v1/convert") { { std::lock_guard lock(config_mutex_); if (!origin_allowed(origin)) { HttpResponse response = error_response(403, "ORIGIN_NOT_ALLOWED", "需要先授权当前网站使用本地 Spine 连接器", origin); Json body = Json::parse(response.body); body["pairUrl"] = Json("http://127.0.0.1:27843/pair?origin=" + url_encode(origin)); response.body = body.dump(); return response; } } const std::string executable = find_spine_executable(); if (executable.empty()) return error_response(503, "SPINE_NOT_FOUND", "没有找到 Spine,请在连接器应用中选择 Spine 路径", origin); const Json payload = Json::parse(request.body); std::lock_guard lock(conversion_mutex_); auto [archive, editor_version] = convert(payload, executable); const std::string project_name = safe_project_name(payload.find("projectName") && payload.find("projectName")->is_string() ? payload.find("projectName")->string() : "SpineParticle"); const std::string file_name = project_name + ".zip"; HttpResponse response; response.headers["Content-Type"] = "application/zip"; response.headers["Content-Disposition"] = "attachment; filename=\"SpineParticle.zip\"; filename*=UTF-8''" + url_encode(file_name); response.headers["X-Spine-File-Name"] = url_encode(file_name); response.headers["X-Spine-Editor-Version"] = editor_version; response.headers["Access-Control-Expose-Headers"] = "X-Spine-File-Name, X-Spine-Editor-Version"; add_cors(response, origin); response.body.assign(reinterpret_cast(archive.data()), archive.size()); return response; } return error_response(404, "NOT_FOUND", "接口不存在", origin); } catch (const std::exception& error) { return error_response(500, "CONVERSION_FAILED", error.what(), origin); } } } // namespace connector