Files
SpineParticlesWeb/connector/native/connector_service.cpp
T
2026-09-05 23:35:45 +08:00

204 lines
9.6 KiB
C++

#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) {
output = std::regex_replace(output, std::regex(R"(^Licensed to:.*$)", std::regex::icase | std::regex::multiline), "Spine 授权信息已隐藏");
return trim(output);
}
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