Files
SpineParticlesWeb/connector/native/connector_service.cpp
T
2026-09-06 00:24:19 +08:00

216 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "connector_service.hpp"
#include "config.hpp"
#include "encoding.hpp"
#include "json.hpp"
#include "platform.hpp"
#include "workspace.hpp"
#include "zip.hpp"
#include <chrono>
#include <filesystem>
#include <map>
#include <mutex>
#include <random>
#include <regex>
#include <stdexcept>
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<std::string, Pairing> 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 = "<!doctype html><meta charset=\"utf-8\"><title>Spine粒子连接器</title><style>body{font:16px system-ui;max-width:620px;margin:60px auto;padding:24px;color:#e9ebff;background:#10131e}main{padding:28px;border:1px solid #38415d;border-radius:12px;background:#181e2c}button{padding:10px 18px;border:0;border-radius:7px;color:white;background:#5b5ce2;cursor:pointer}code{word-break:break-all;color:#aeb8e8}</style><main>" + body + "</main>";
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) {
// 逐行替换,避免依赖 std::regex::multilineMSVC 的 <regex> 未实现该标志)。
const std::regex licensed(R"(^Licensed to:.*$)", std::regex::icase);
std::string result;
std::size_t start = 0;
while (start <= output.size()) {
const auto end = output.find('\n', start);
std::string line = output.substr(start, end - start);
if (!line.empty() && line.back() == '\r') line.pop_back();
result += std::regex_replace(line, licensed, "Spine 授权信息已隐藏");
if (end == std::string::npos) break;
result += '\n';
start = end + 1;
}
return trim(result);
}
std::pair<std::vector<std::uint8_t>, 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<std::uint8_t> project = workspace.read_output();
if (project.empty()) throw std::runtime_error("Spine 未生成有效的工程文件");
std::vector<ArchiveFile> 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<std::mutex> 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, "<h2>授权地址无效</h2>");
const std::string token = nonce();
{
std::lock_guard<std::mutex> lock(pairing_mutex);
pairing[token] = {requested, std::chrono::steady_clock::now() + std::chrono::minutes(5)};
}
return html_response(200, "<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>" + html_escape(requested) + "</code></p><form method=\"post\" action=\"/pair\"><input type=\"hidden\" name=\"nonce\" value=\"" + token + "\"><button type=\"submit\">允许此网站</button></form>");
}
if (request.method == "POST" && request.path == "/pair") {
const std::string token = form_value(request.body, "nonce");
Pairing pending;
{
std::lock_guard<std::mutex> lock(pairing_mutex);
const auto found = pairing.find(token);
if (found == pairing.end()) return html_response(400, "<h2>授权已过期,请返回编辑器重试</h2>");
pending = found->second;
pairing.erase(found);
}
if (pending.expires < std::chrono::steady_clock::now()) return html_response(400, "<h2>授权已过期,请返回编辑器重试</h2>");
{
std::lock_guard<std::mutex> lock(config_mutex_);
allow_origin(pending.origin);
}
return html_response(200, "<h2>授权成功</h2><p><code>" + html_escape(pending.origin) + "</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>");
}
if (request.method == "POST" && request.path == "/v1/convert") {
{
std::lock_guard<std::mutex> 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<std::mutex> 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<const char*>(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