204 lines
9.6 KiB
C++
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
|