1234 lines
31 KiB
C++
1234 lines
31 KiB
C++
#include "pch.h"
|
||
#include "GitVer_upload.h"
|
||
#include "gitver_version.h"
|
||
|
||
#include "httplib.h"
|
||
|
||
#include <memory>
|
||
#include <string>
|
||
#include <vector>
|
||
#include <map>
|
||
|
||
extern std::string ToAnsiString(LPCTSTR lpText);
|
||
extern BOOL ReadTextFileAsAnsi(LPCTSTR lpFile, std::string& strContent, std::string& strBomPrefix);
|
||
extern TCHAR g_szCurModuleDir[MAX_PATH];
|
||
|
||
static const LPCTSTR DEFAULT_BASE_URL = _T("http://tools-center.mokadisplay.com/");
|
||
|
||
struct UploadInfoConfig
|
||
{
|
||
CString strBaseUrl;
|
||
CString strPackageFile;
|
||
CString strPackageName;
|
||
CString strPackageTypeId;
|
||
CString strProductVersion;
|
||
CString strFileVersion;
|
||
CString strFileChangeLog;
|
||
CString strProductChangeLog;
|
||
CString strDescription;
|
||
CString strWikiUrl;
|
||
};
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 字符串 / 编码工具
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 将宽字符(或 ANSI)字符串转换为 UTF-8 编码的 std::string。
|
||
/// </summary>
|
||
/// <param name="lpText">输入字符串。</param>
|
||
/// <returns>UTF-8 编码结果,空输入返回空字符串。</returns>
|
||
static std::string ToUtf8String(LPCTSTR lpText)
|
||
{
|
||
if (lpText == NULL || lpText[0] == _T('\0'))
|
||
{
|
||
return std::string();
|
||
}
|
||
|
||
#ifdef UNICODE
|
||
int nSize = WideCharToMultiByte(CP_UTF8, 0, lpText, -1, NULL, 0, NULL, NULL);
|
||
if (nSize <= 1)
|
||
{
|
||
return std::string();
|
||
}
|
||
|
||
std::string strValue;
|
||
strValue.resize(nSize - 1);
|
||
WideCharToMultiByte(CP_UTF8, 0, lpText, -1, &strValue[0], nSize, NULL, NULL);
|
||
return strValue;
|
||
#else
|
||
int nLen = (int)_tcslen(lpText);
|
||
int nWideSize = MultiByteToWideChar(CP_ACP, 0, lpText, nLen, NULL, 0);
|
||
if (nWideSize <= 0)
|
||
{
|
||
return std::string();
|
||
}
|
||
|
||
CStringW strWide;
|
||
MultiByteToWideChar(CP_ACP, 0, lpText, nLen, strWide.GetBuffer(nWideSize), nWideSize);
|
||
strWide.ReleaseBuffer(nWideSize);
|
||
|
||
int nUtf8Size = WideCharToMultiByte(CP_UTF8, 0, strWide, strWide.GetLength(), NULL, 0, NULL, NULL);
|
||
if (nUtf8Size <= 0)
|
||
{
|
||
return std::string();
|
||
}
|
||
|
||
std::string strValue;
|
||
strValue.resize(nUtf8Size);
|
||
WideCharToMultiByte(CP_UTF8, 0, strWide, strWide.GetLength(), &strValue[0], nUtf8Size, NULL, NULL);
|
||
return strValue;
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 UTF-8 编码的 std::string 转换为 CString。
|
||
/// </summary>
|
||
/// <param name="strUtf8">UTF-8 字符串。</param>
|
||
/// <returns>转换后的 CString。</returns>
|
||
static CString Utf8ToCString(const std::string& strUtf8)
|
||
{
|
||
if (strUtf8.empty())
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
#ifdef UNICODE
|
||
int nSize = MultiByteToWideChar(CP_UTF8, 0, strUtf8.c_str(), (int)strUtf8.size(), NULL, 0);
|
||
if (nSize <= 0)
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
CString strValue;
|
||
MultiByteToWideChar(CP_UTF8, 0, strUtf8.c_str(), (int)strUtf8.size(), strValue.GetBuffer(nSize), nSize);
|
||
strValue.ReleaseBuffer(nSize);
|
||
return strValue;
|
||
#else
|
||
int nSize = MultiByteToWideChar(CP_UTF8, 0, strUtf8.c_str(), (int)strUtf8.size(), NULL, 0);
|
||
if (nSize <= 0)
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
CStringW strWide;
|
||
MultiByteToWideChar(CP_UTF8, 0, strUtf8.c_str(), (int)strUtf8.size(), strWide.GetBuffer(nSize), nSize);
|
||
strWide.ReleaseBuffer(nSize);
|
||
|
||
nSize = WideCharToMultiByte(CP_ACP, 0, strWide, -1, NULL, 0, NULL, NULL);
|
||
if (nSize <= 1)
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
CString strValue;
|
||
WideCharToMultiByte(CP_ACP, 0, strWide, -1, strValue.GetBuffer(nSize), nSize, NULL, NULL);
|
||
strValue.ReleaseBuffer();
|
||
return strValue;
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 ANSI 编码的 std::string 转换为 CString。
|
||
/// </summary>
|
||
/// <param name="strAnsi">ANSI 字符串。</param>
|
||
/// <returns>转换后的 CString。</returns>
|
||
static CString AnsiToCString(const std::string& strAnsi)
|
||
{
|
||
if (strAnsi.empty())
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
#ifdef UNICODE
|
||
int nSize = MultiByteToWideChar(CP_ACP, 0, strAnsi.data(), (int)strAnsi.size(), NULL, 0);
|
||
if (nSize <= 0)
|
||
{
|
||
return CString();
|
||
}
|
||
|
||
CString strValue;
|
||
MultiByteToWideChar(CP_ACP, 0, strAnsi.data(), (int)strAnsi.size(), strValue.GetBuffer(nSize), nSize);
|
||
strValue.ReleaseBuffer(nSize);
|
||
return strValue;
|
||
#else
|
||
return CString(strAnsi.c_str(), (int)strAnsi.size());
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// 粗略检测字节序列是否为合法 UTF-8 编码。
|
||
/// </summary>
|
||
/// <param name="strValue">待检测的字节串。</param>
|
||
/// <returns>看起来像合法 UTF-8 则返回 TRUE。</returns>
|
||
static BOOL IsLikelyValidUtf8(const std::string& strValue)
|
||
{
|
||
size_t i = 0;
|
||
while (i < strValue.size())
|
||
{
|
||
unsigned char ch = (unsigned char)strValue[i];
|
||
if (ch <= 0x7F)
|
||
{
|
||
++i;
|
||
continue;
|
||
}
|
||
|
||
int nBytes = 0;
|
||
if ((ch & 0xE0) == 0xC0)
|
||
{
|
||
nBytes = 2;
|
||
}
|
||
else if ((ch & 0xF0) == 0xE0)
|
||
{
|
||
nBytes = 3;
|
||
}
|
||
else if ((ch & 0xF8) == 0xF0)
|
||
{
|
||
nBytes = 4;
|
||
}
|
||
else
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
if (i + (size_t)nBytes > strValue.size())
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
for (int j = 1; j < nBytes; ++j)
|
||
{
|
||
if ((strValue[i + j] & 0xC0) != 0x80)
|
||
{
|
||
return FALSE;
|
||
}
|
||
}
|
||
|
||
i += (size_t)nBytes;
|
||
}
|
||
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 info 文件中的原始字节值转为 CString,自动区分 UTF-8 与 ANSI。
|
||
/// </summary>
|
||
/// <param name="strValue">原始字节值。</param>
|
||
/// <returns>解码后的 CString。</returns>
|
||
static CString InfoValueToCString(const std::string& strValue)
|
||
{
|
||
if (IsLikelyValidUtf8(strValue))
|
||
{
|
||
return Utf8ToCString(strValue);
|
||
}
|
||
|
||
return AnsiToCString(strValue);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 对 UTF-8 字符串进行 application/x-www-form-urlencoded 风格 URL 编码。
|
||
/// </summary>
|
||
/// <param name="strValue">待编码的 UTF-8 字符串。</param>
|
||
/// <returns>编码后的字符串。</returns>
|
||
static std::string UrlEncodeUtf8(const std::string& strValue)
|
||
{
|
||
static const char* kHex = "0123456789ABCDEF";
|
||
std::string strEncoded;
|
||
strEncoded.reserve(strValue.size() * 3);
|
||
|
||
for (unsigned char ch : strValue)
|
||
{
|
||
if ((ch >= 'A' && ch <= 'Z') ||
|
||
(ch >= 'a' && ch <= 'z') ||
|
||
(ch >= '0' && ch <= '9') ||
|
||
ch == '-' || ch == '_' || ch == '.' || ch == '~')
|
||
{
|
||
strEncoded.push_back((char)ch);
|
||
}
|
||
else if (ch == ' ')
|
||
{
|
||
strEncoded.push_back('+');
|
||
}
|
||
else
|
||
{
|
||
strEncoded.push_back('%');
|
||
strEncoded.push_back(kHex[ch >> 4]);
|
||
strEncoded.push_back(kHex[ch & 0x0F]);
|
||
}
|
||
}
|
||
|
||
return strEncoded;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 反转义 info 文件值中的 \n 为换行符。
|
||
/// </summary>
|
||
/// <param name="strValue">含转义序列的原始值。</param>
|
||
/// <returns>反转义后的字符串。</returns>
|
||
static std::string UnescapeInfoValue(const std::string& strValue)
|
||
{
|
||
std::string strResult;
|
||
strResult.reserve(strValue.size());
|
||
|
||
for (size_t i = 0; i < strValue.size(); ++i)
|
||
{
|
||
if (strValue[i] == '\\' && i + 1 < strValue.size() && strValue[i + 1] == 'n')
|
||
{
|
||
strResult.push_back('\n');
|
||
++i;
|
||
}
|
||
else
|
||
{
|
||
strResult.push_back(strValue[i]);
|
||
}
|
||
}
|
||
|
||
return strResult;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将字符串转义后写入 info 文件(换行 → \n,反斜杠 → \\)。
|
||
/// </summary>
|
||
/// <param name="strValue">原始字符串。</param>
|
||
/// <returns>适合写入 info 文件的转义字符串。</returns>
|
||
static std::string EscapeInfoValueForFile(const std::string& strValue)
|
||
{
|
||
std::string strResult;
|
||
strResult.reserve(strValue.size());
|
||
|
||
for (size_t i = 0; i < strValue.size(); ++i)
|
||
{
|
||
char ch = strValue[i];
|
||
if (ch == '\r')
|
||
{
|
||
continue;
|
||
}
|
||
if (ch == '\n')
|
||
{
|
||
strResult += "\\n";
|
||
continue;
|
||
}
|
||
if (ch == '\\')
|
||
{
|
||
strResult += "\\\\";
|
||
continue;
|
||
}
|
||
strResult.push_back(ch);
|
||
}
|
||
|
||
return strResult;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 去除 ANSI 字符串首尾的空格、制表符和回车。
|
||
/// </summary>
|
||
/// <param name="strValue">待修剪的字符串(原地修改)。</param>
|
||
static void TrimAnsiInPlace(std::string& strValue)
|
||
{
|
||
while (!strValue.empty() && (strValue.front() == ' ' || strValue.front() == '\t' || strValue.front() == '\r'))
|
||
{
|
||
strValue.erase(strValue.begin());
|
||
}
|
||
while (!strValue.empty() && (strValue.back() == ' ' || strValue.back() == '\t' || strValue.back() == '\r'))
|
||
{
|
||
strValue.pop_back();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取二进制文件的全部内容到字节向量。
|
||
/// </summary>
|
||
/// <param name="lpFile">文件路径。</param>
|
||
/// <param name="data">输出:文件字节内容。</param>
|
||
/// <returns>读取成功返回 TRUE。</returns>
|
||
static BOOL ReadBinaryFile(LPCTSTR lpFile, std::vector<BYTE>& data)
|
||
{
|
||
data.clear();
|
||
CFile myFile;
|
||
CFileException fileExp;
|
||
if (!myFile.Open(lpFile, CFile::modeRead | CFile::shareDenyWrite, &fileExp))
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
ULONGLONG ullLength = myFile.GetLength();
|
||
if (ullLength == 0)
|
||
{
|
||
myFile.Close();
|
||
return TRUE;
|
||
}
|
||
|
||
data.resize((size_t)ullLength);
|
||
myFile.Read(data.data(), (UINT)ullLength);
|
||
myFile.Close();
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 ASCII 字母转为小写(仅 A-Z)。
|
||
/// </summary>
|
||
/// <param name="strValue">输入字符串。</param>
|
||
/// <returns>小写化后的新字符串。</returns>
|
||
static std::string ToLowerAscii(std::string strValue)
|
||
{
|
||
for (char& ch : strValue)
|
||
{
|
||
if (ch >= 'A' && ch <= 'Z')
|
||
{
|
||
ch = (char)(ch - 'A' + 'a');
|
||
}
|
||
}
|
||
return strValue;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// Cookie 管理(httplib 不自动维护 Session)
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 去除 std::string 首尾的空格和制表符。
|
||
/// </summary>
|
||
/// <param name="strValue">待修剪的字符串(原地修改)。</param>
|
||
static void TrimStdStringInPlace(std::string& strValue)
|
||
{
|
||
while (!strValue.empty() && (strValue.front() == ' ' || strValue.front() == '\t'))
|
||
{
|
||
strValue.erase(strValue.begin());
|
||
}
|
||
while (!strValue.empty() && (strValue.back() == ' ' || strValue.back() == '\t'))
|
||
{
|
||
strValue.pop_back();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 Set-Cookie 响应头中的 name=value 合并到 Cookie 字符串,同名则覆盖。
|
||
/// </summary>
|
||
/// <param name="strHeaderValue">Set-Cookie 头的值部分。</param>
|
||
/// <param name="strCookies">输入/输出:累积的 Cookie 字符串。</param>
|
||
static void MergeSetCookieValue(const std::string& strHeaderValue, std::string& strCookies)
|
||
{
|
||
std::string strLine = strHeaderValue;
|
||
TrimStdStringInPlace(strLine);
|
||
if (strLine.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
size_t nSemi = strLine.find(';');
|
||
std::string strPair = (nSemi != std::string::npos) ? strLine.substr(0, nSemi) : strLine;
|
||
TrimStdStringInPlace(strPair);
|
||
if (strPair.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
size_t nEq = strPair.find('=');
|
||
if (nEq == std::string::npos || nEq == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
std::string strName = strPair.substr(0, nEq);
|
||
std::string strValue = strPair.substr(nEq + 1);
|
||
TrimStdStringInPlace(strName);
|
||
TrimStdStringInPlace(strValue);
|
||
if (strName.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
std::string strNewPair = strName + "=" + strValue;
|
||
if (strCookies.empty())
|
||
{
|
||
strCookies = strNewPair;
|
||
return;
|
||
}
|
||
|
||
std::string strResult;
|
||
BOOL bReplaced = FALSE;
|
||
size_t nStart = 0;
|
||
while (nStart < strCookies.size())
|
||
{
|
||
size_t nSemiPos = strCookies.find(';', nStart);
|
||
std::string strItem = (nSemiPos != std::string::npos)
|
||
? strCookies.substr(nStart, nSemiPos - nStart)
|
||
: strCookies.substr(nStart);
|
||
TrimStdStringInPlace(strItem);
|
||
|
||
if (!strItem.empty())
|
||
{
|
||
size_t nItemEq = strItem.find('=');
|
||
std::string strItemName = (nItemEq != std::string::npos) ? strItem.substr(0, nItemEq) : strItem;
|
||
TrimStdStringInPlace(strItemName);
|
||
|
||
if (!strResult.empty())
|
||
{
|
||
strResult += "; ";
|
||
}
|
||
|
||
if (ToLowerAscii(strItemName) == ToLowerAscii(strName))
|
||
{
|
||
strResult += strNewPair;
|
||
bReplaced = TRUE;
|
||
}
|
||
else
|
||
{
|
||
strResult += strItem;
|
||
}
|
||
}
|
||
|
||
if (nSemiPos == std::string::npos)
|
||
{
|
||
break;
|
||
}
|
||
nStart = nSemiPos + 1;
|
||
}
|
||
|
||
if (!bReplaced)
|
||
{
|
||
if (!strResult.empty())
|
||
{
|
||
strResult += "; ";
|
||
}
|
||
strResult += strNewPair;
|
||
}
|
||
|
||
strCookies = strResult;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 HTTP 响应的所有 Set-Cookie 头中收集并合并 Cookie。
|
||
/// </summary>
|
||
/// <param name="response">HTTP 响应对象。</param>
|
||
/// <param name="strCookies">输出:合并后的 Cookie 字符串。</param>
|
||
static void CollectResponseCookies(const httplib::Response& response, std::string& strCookies)
|
||
{
|
||
for (const auto& header : response.headers)
|
||
{
|
||
if (ToLowerAscii(header.first) == "set-cookie")
|
||
{
|
||
MergeSetCookieValue(header.second, strCookies);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据 Cookie 字符串构造 httplib 请求头。
|
||
/// </summary>
|
||
/// <param name="strCookies">Cookie 字符串。</param>
|
||
/// <returns>含 Cookie 头的 Headers 对象。</returns>
|
||
static httplib::Headers MakeCookieHeaders(const std::string& strCookies)
|
||
{
|
||
httplib::Headers headers;
|
||
if (!strCookies.empty())
|
||
{
|
||
headers.emplace("Cookie", strCookies);
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建指向 PMC 服务的 httplib 客户端,配置超时且不自动跟随重定向。
|
||
/// </summary>
|
||
/// <param name="lpBaseUrl">PMC 基址 URL。</param>
|
||
/// <returns>有效客户端指针,无效 URL 时返回 nullptr。</returns>
|
||
static std::unique_ptr<httplib::Client> CreatePmcClient(LPCTSTR lpBaseUrl)
|
||
{
|
||
std::string strBaseUrl = ToUtf8String(lpBaseUrl);
|
||
auto cli = std::make_unique<httplib::Client>(strBaseUrl);
|
||
if (!cli->is_valid())
|
||
{
|
||
return nullptr;
|
||
}
|
||
|
||
cli->set_follow_location(false);
|
||
cli->set_connection_timeout(30, 0);
|
||
cli->set_read_timeout(300, 0);
|
||
cli->set_write_timeout(300, 0);
|
||
return cli;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// info 文件解析
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 根据键名将 info 文件中的一行键值对写入 UploadInfoConfig 结构体。
|
||
/// </summary>
|
||
/// <param name="config">输出配置对象。</param>
|
||
/// <param name="strKey">键名。</param>
|
||
/// <param name="strRawValue">原始值(含转义序列)。</param>
|
||
static void ApplyInfoKeyValue(UploadInfoConfig& config, const std::string& strKey, const std::string& strRawValue)
|
||
{
|
||
std::string strValue = UnescapeInfoValue(strRawValue);
|
||
CString strCStringValue = InfoValueToCString(strValue);
|
||
|
||
if (strKey == "url")
|
||
{
|
||
config.strBaseUrl = strCStringValue;
|
||
}
|
||
else if (strKey == "file")
|
||
{
|
||
config.strPackageFile = strCStringValue;
|
||
}
|
||
else if (strKey == "package_name")
|
||
{
|
||
config.strPackageName = strCStringValue;
|
||
}
|
||
else if (strKey == "package_type_id")
|
||
{
|
||
config.strPackageTypeId = strCStringValue;
|
||
}
|
||
else if (strKey == "product_version")
|
||
{
|
||
config.strProductVersion = strCStringValue;
|
||
}
|
||
else if (strKey == "file_version")
|
||
{
|
||
config.strFileVersion = strCStringValue;
|
||
}
|
||
else if (strKey == "file_change_log")
|
||
{
|
||
config.strFileChangeLog = strCStringValue;
|
||
}
|
||
else if (strKey == "product_change_log")
|
||
{
|
||
config.strProductChangeLog = strCStringValue;
|
||
}
|
||
else if (strKey == "description")
|
||
{
|
||
config.strDescription = strCStringValue;
|
||
}
|
||
else if (strKey == "wiki_url")
|
||
{
|
||
config.strWikiUrl = strCStringValue;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 upload_info.txt 等 info 配置文件加载上传参数。
|
||
/// </summary>
|
||
/// <param name="lpInfoFile">info 文件路径。</param>
|
||
/// <param name="config">输出:解析后的配置。</param>
|
||
/// <returns>文件读取成功返回 TRUE。</returns>
|
||
static BOOL LoadUploadInfoFile(LPCTSTR lpInfoFile, UploadInfoConfig& config)
|
||
{
|
||
std::string strContent;
|
||
std::string strBomPrefix;
|
||
if (!ReadTextFileAsAnsi(lpInfoFile, strContent, strBomPrefix))
|
||
{
|
||
_tprintf(_T("错误: 无法读取 info 配置文件 %s\n"), lpInfoFile);
|
||
return FALSE;
|
||
}
|
||
|
||
size_t nLineStart = 0;
|
||
while (nLineStart <= strContent.size())
|
||
{
|
||
size_t nLineEnd = strContent.find('\n', nLineStart);
|
||
if (nLineEnd == std::string::npos)
|
||
{
|
||
nLineEnd = strContent.size();
|
||
}
|
||
|
||
std::string strLine = strContent.substr(nLineStart, nLineEnd - nLineStart);
|
||
if (!strLine.empty() && strLine.back() == '\r')
|
||
{
|
||
strLine.pop_back();
|
||
}
|
||
TrimAnsiInPlace(strLine);
|
||
|
||
if (!strLine.empty() && strLine[0] != '#')
|
||
{
|
||
size_t nEq = strLine.find('=');
|
||
if (nEq != std::string::npos)
|
||
{
|
||
std::string strKey = strLine.substr(0, nEq);
|
||
std::string strValue = strLine.substr(nEq + 1);
|
||
TrimAnsiInPlace(strKey);
|
||
TrimAnsiInPlace(strValue);
|
||
if (!strKey.empty())
|
||
{
|
||
ApplyInfoKeyValue(config, strKey, strValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (nLineEnd >= strContent.size())
|
||
{
|
||
break;
|
||
}
|
||
nLineStart = nLineEnd + 1;
|
||
}
|
||
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验上传配置是否包含必填字段。
|
||
/// </summary>
|
||
/// <param name="config">待校验的配置。</param>
|
||
/// <returns>必填字段齐全返回 TRUE,缺失时打印错误并返回 FALSE。</returns>
|
||
static BOOL ValidateUploadConfig(const UploadInfoConfig& config)
|
||
{
|
||
if (config.strPackageName.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: info 文件缺少 package_name\n"));
|
||
return FALSE;
|
||
}
|
||
if (config.strPackageTypeId.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: info 文件缺少 package_type_id\n"));
|
||
return FALSE;
|
||
}
|
||
if (config.strProductVersion.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: info 文件缺少 product_version\n"));
|
||
return FALSE;
|
||
}
|
||
if (config.strFileVersion.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: info 文件缺少 file_version\n"));
|
||
return FALSE;
|
||
}
|
||
if (config.strFileChangeLog.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: info 文件缺少 file_change_log\n"));
|
||
return FALSE;
|
||
}
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从与用户名同名的环境变量中读取登录密码。
|
||
/// </summary>
|
||
/// <param name="lpUsername">PMC 登录用户名(亦作环境变量名)。</param>
|
||
/// <param name="strPassword">输出:读取到的密码。</param>
|
||
/// <returns>环境变量存在且非空返回 TRUE。</returns>
|
||
static BOOL ResolvePasswordFromEnv(LPCTSTR lpUsername, CString& strPassword)
|
||
{
|
||
if (lpUsername == NULL || lpUsername[0] == _T('\0'))
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
#ifdef UNICODE
|
||
LPWSTR lpEnvValue = _wgetenv(lpUsername);
|
||
if (lpEnvValue == NULL || lpEnvValue[0] == L'\0')
|
||
{
|
||
_tprintf(_T("错误: 未设置环境变量 %s(用于读取登录密码)\n"), lpUsername);
|
||
return FALSE;
|
||
}
|
||
strPassword = lpEnvValue;
|
||
#else
|
||
std::string strEnvName = ToAnsiString(lpUsername);
|
||
char* lpBuffer = NULL;
|
||
size_t nLen = 0;
|
||
if (_dupenv_s(&lpBuffer, &nLen, strEnvName.c_str()) != 0 || lpBuffer == NULL || lpBuffer[0] == '\0')
|
||
{
|
||
if (lpBuffer != NULL)
|
||
{
|
||
free(lpBuffer);
|
||
}
|
||
_tprintf(_T("错误: 未设置环境变量 %s(用于读取登录密码)\n"), lpUsername);
|
||
return FALSE;
|
||
}
|
||
strPassword = lpBuffer;
|
||
free(lpBuffer);
|
||
#endif
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新 upload_info.txt 中指定键的值,保留其他行和 BOM 前缀。
|
||
/// 文件中不存在的键会追加到末尾。
|
||
/// </summary>
|
||
/// <param name="lpInfoFile">info 文件路径。</param>
|
||
/// <param name="updates">要更新的键值对映射。</param>
|
||
/// <returns>写入成功返回 TRUE。</returns>
|
||
static BOOL UpdateUploadInfoKeys(LPCTSTR lpInfoFile, const std::map<std::string, std::string>& updates)
|
||
{
|
||
std::vector<std::string> outLines;
|
||
std::string strBomPrefix;
|
||
std::map<std::string, BOOL> foundKeys;
|
||
|
||
for (const auto& item : updates)
|
||
{
|
||
foundKeys[item.first] = FALSE;
|
||
}
|
||
|
||
if (PathFileExists(lpInfoFile))
|
||
{
|
||
std::string strContent;
|
||
if (!ReadTextFileAsAnsi(lpInfoFile, strContent, strBomPrefix))
|
||
{
|
||
_tprintf(_T("警告: 无法读取 upload_info.txt,将创建新文件\n"));
|
||
}
|
||
else
|
||
{
|
||
size_t nLineStart = 0;
|
||
while (nLineStart <= strContent.size())
|
||
{
|
||
size_t nLineEnd = strContent.find('\n', nLineStart);
|
||
if (nLineEnd == std::string::npos)
|
||
{
|
||
nLineEnd = strContent.size();
|
||
}
|
||
|
||
std::string strLine = strContent.substr(nLineStart, nLineEnd - nLineStart);
|
||
if (!strLine.empty() && strLine.back() == '\r')
|
||
{
|
||
strLine.pop_back();
|
||
}
|
||
|
||
std::string strOutLine = strLine;
|
||
if (!strLine.empty() && strLine[0] != '#')
|
||
{
|
||
size_t nEq = strLine.find('=');
|
||
if (nEq != std::string::npos)
|
||
{
|
||
std::string strKey = strLine.substr(0, nEq);
|
||
TrimAnsiInPlace(strKey);
|
||
std::map<std::string, std::string>::const_iterator it = updates.find(strKey);
|
||
if (it != updates.end())
|
||
{
|
||
strOutLine = strKey + "=" + it->second;
|
||
foundKeys[strKey] = TRUE;
|
||
}
|
||
}
|
||
}
|
||
|
||
outLines.push_back(strOutLine);
|
||
if (nLineEnd >= strContent.size())
|
||
{
|
||
break;
|
||
}
|
||
nLineStart = nLineEnd + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const auto& item : updates)
|
||
{
|
||
if (!foundKeys[item.first])
|
||
{
|
||
outLines.push_back(item.first + "=" + item.second);
|
||
}
|
||
}
|
||
|
||
CFile myFile;
|
||
CFileException fileExp;
|
||
if (!myFile.Open(lpInfoFile, CFile::modeCreate | CFile::modeWrite, &fileExp))
|
||
{
|
||
_tprintf(_T("错误: 无法写入 upload_info.txt %s\n"), lpInfoFile);
|
||
return FALSE;
|
||
}
|
||
|
||
if (!strBomPrefix.empty())
|
||
{
|
||
myFile.Write(strBomPrefix.c_str(), (UINT)strBomPrefix.size());
|
||
}
|
||
|
||
for (size_t i = 0; i < outLines.size(); ++i)
|
||
{
|
||
myFile.Write(outLines[i].c_str(), (UINT)outLines[i].size());
|
||
myFile.Write("\n", 1);
|
||
}
|
||
myFile.Close();
|
||
|
||
_tprintf(_T("已更新 upload_info.txt: %s\n"), lpInfoFile);
|
||
return TRUE;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新 upload_info.txt 中的版本号与变更日志字段。
|
||
/// </summary>
|
||
BOOL WriteUploadInfoVersions(LPCTSTR lpInfoFile, LPCTSTR lpProductVersion, LPCTSTR lpFileVersion, LPCTSTR lpFileChangeLog)
|
||
{
|
||
std::map<std::string, std::string> updates;
|
||
updates["product_version"] = lpProductVersion;
|
||
updates["file_version"] = lpFileVersion;
|
||
updates["file_change_log"] = EscapeInfoValueForFile(lpFileChangeLog);
|
||
return UpdateUploadInfoKeys(lpInfoFile, updates);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新 upload_info.txt 中的 file 字段(安装包路径)。
|
||
/// </summary>
|
||
BOOL WriteUploadInfoPackageFile(LPCTSTR lpInfoFile, LPCTSTR lpPackageFile)
|
||
{
|
||
std::map<std::string, std::string> updates;
|
||
updates["file"] = ToUtf8String(lpPackageFile);
|
||
return UpdateUploadInfoKeys(lpInfoFile, updates);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 打印 upload 命令的用法说明。
|
||
/// </summary>
|
||
static void PrintUploadUsage()
|
||
{
|
||
_tprintf(_T("用法:gitver upload username=<用户名> [file=<上传的文件>] [info=<配置.txt>] [url=<PMC基址>] [-f]\n"));
|
||
_tprintf(_T("说明:info= 缺省为 upload_info.txt;file= 可省略,从 info 的 file 字段读取\n"));
|
||
_tprintf(_T("说明:登录密码从与 username 同名的环境变量读取\n"));
|
||
_tprintf(_T("说明:默认要求当前在分支上且 HEAD 指向 Tag;加 -f 可忽略 Tag 校验\n"));
|
||
_tprintf(_T("示例:gitver upload username=admin\n"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验当前 Git 仓库是否处于可上传状态(在分支上且 HEAD 指向 Tag)。
|
||
/// </summary>
|
||
/// <param name="lpRepoPath">仓库根目录。</param>
|
||
/// <returns>0 表示通过;54 表示 HEAD 游离或未指向 Tag。</returns>
|
||
static int ValidateUploadGitTagState(LPCTSTR lpRepoPath)
|
||
{
|
||
CString strStatus;
|
||
if (!TryGetGitStatus(lpRepoPath, strStatus))
|
||
{
|
||
CString strTag;
|
||
if (TryGetDetectedTag(lpRepoPath, strTag))
|
||
{
|
||
_tprintf(_T("错误: 当前处于 HEAD 游离状态(Tag: %s),不允许上传\n"), strTag.GetString());
|
||
}
|
||
else
|
||
{
|
||
_tprintf(_T("错误: 当前处于 HEAD 游离状态,不允许上传\n"));
|
||
}
|
||
_tprintf(_T("提示: 使用 upload ... -f 可强制上传\n"));
|
||
return 54;
|
||
}
|
||
|
||
CString strTag;
|
||
if (!TryGetDetectedTag(lpRepoPath, strTag))
|
||
{
|
||
_tprintf(_T("错误: 当前 commit 未指向 Tag 标签,不允许上传\n"));
|
||
_tprintf(_T("提示: 使用 upload ... -f 可强制上传\n"));
|
||
return 54;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// PMC 交互(cpp-httplib)
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 向 PMC 发送登录请求,获取 Session Cookie。
|
||
/// </summary>
|
||
/// <param name="cli">httplib 客户端。</param>
|
||
/// <param name="lpUsername">登录用户名。</param>
|
||
/// <param name="lpPassword">登录密码。</param>
|
||
/// <param name="strCookies">输出:登录成功后的 Cookie 字符串。</param>
|
||
/// <returns>0 成功;50 登录失败;53 网络错误。</returns>
|
||
static int PmcLogin(httplib::Client& cli, LPCTSTR lpUsername, LPCTSTR lpPassword, std::string& strCookies)
|
||
{
|
||
strCookies.clear();
|
||
httplib::Params params;
|
||
params.emplace("username", ToUtf8String(lpUsername));
|
||
params.emplace("password", ToUtf8String(lpPassword));
|
||
|
||
httplib::Result result = cli.Post("/auth/login", params);
|
||
if (!result)
|
||
{
|
||
_tprintf(_T("错误: 登录请求失败(网络错误)\n"));
|
||
return 53;
|
||
}
|
||
|
||
if (result->status == 401 || result->status == 403)
|
||
{
|
||
_tprintf(_T("错误: 登录被拒绝(HTTP %d)\n"), result->status);
|
||
return 50;
|
||
}
|
||
|
||
if (result->status != 303)
|
||
{
|
||
_tprintf(_T("错误: 登录返回意外状态码 HTTP %d\n"), result->status);
|
||
return 50;
|
||
}
|
||
|
||
std::string strLocation = ToLowerAscii(result->get_header_value("Location"));
|
||
if (strLocation.find("/login") != std::string::npos)
|
||
{
|
||
_tprintf(_T("错误: 登录失败,用户名或密码错误\n"));
|
||
return 50;
|
||
}
|
||
|
||
CollectResponseCookies(*result, strCookies);
|
||
if (strCookies.empty())
|
||
{
|
||
_tprintf(_T("错误: 登录成功但未收到 Session Cookie\n"));
|
||
return 50;
|
||
}
|
||
|
||
_tprintf(_T("已登录 PMC 账号 %s\n"), lpUsername);
|
||
return 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 以 multipart/form-data 方式上传安装包到 PMC。
|
||
/// </summary>
|
||
/// <param name="cli">httplib 客户端。</param>
|
||
/// <param name="strCookies">登录 Session Cookie。</param>
|
||
/// <param name="config">上传配置(包名、版本、变更日志等)。</param>
|
||
/// <param name="lpFilePath">待上传的文件路径。</param>
|
||
/// <returns>0 成功;48 文件读取失败;51 上传 HTTP 失败;53 网络错误。</returns>
|
||
static int PmcUploadPackage(httplib::Client& cli, const std::string& strCookies, const UploadInfoConfig& config, LPCTSTR lpFilePath)
|
||
{
|
||
std::vector<BYTE> fileData;
|
||
if (!ReadBinaryFile(lpFilePath, fileData))
|
||
{
|
||
_tprintf(_T("错误: 读取上传文件失败 %s\n"), lpFilePath);
|
||
return 48;
|
||
}
|
||
|
||
std::string strFileContent((const char*)fileData.data(), fileData.size());
|
||
std::string strFileName = ToUtf8String(PathFindFileName(lpFilePath));
|
||
|
||
httplib::UploadFormDataItems items;
|
||
items.push_back({ "package_name", ToUtf8String(config.strPackageName), "", "" });
|
||
items.push_back({ "package_type_id", ToUtf8String(config.strPackageTypeId), "", "" });
|
||
items.push_back({ "description", ToUtf8String(config.strDescription), "", "" });
|
||
items.push_back({ "wiki_url", ToUtf8String(config.strWikiUrl), "", "" });
|
||
items.push_back({ "product_version", ToUtf8String(config.strProductVersion), "", "" });
|
||
items.push_back({ "file_version", ToUtf8String(config.strFileVersion), "", "" });
|
||
items.push_back({ "product_change_log", ToUtf8String(config.strProductChangeLog), "", "" });
|
||
items.push_back({ "file_change_log", ToUtf8String(config.strFileChangeLog), "", "" });
|
||
items.push_back({ "package_file", strFileContent, strFileName, "application/octet-stream" });
|
||
|
||
httplib::Result result = cli.Post("/admin/packages/upload", MakeCookieHeaders(strCookies), items);
|
||
if (!result)
|
||
{
|
||
_tprintf(_T("错误: 上传请求失败(网络错误)\n"));
|
||
return 53;
|
||
}
|
||
|
||
if (result->status == 401)
|
||
{
|
||
_tprintf(_T("错误: 上传时未登录(HTTP 401)\n"));
|
||
return 51;
|
||
}
|
||
if (result->status == 403)
|
||
{
|
||
_tprintf(_T("错误: 上传需要管理员权限(HTTP 403)\n"));
|
||
return 51;
|
||
}
|
||
if (result->status != 303)
|
||
{
|
||
_tprintf(_T("错误: 上传返回意外状态码 HTTP %d\n"), result->status);
|
||
return 51;
|
||
}
|
||
|
||
CString strLocation = Utf8ToCString(result->get_header_value("Location"));
|
||
_tprintf(_T("已提交上传请求,HTTP 303 -> %s\n"), strLocation.GetString());
|
||
return 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在 JSON 响应体中查找 "field":"value" 形式的字段值(简单字符串匹配)。
|
||
/// </summary>
|
||
/// <param name="strBody">JSON 响应正文。</param>
|
||
/// <param name="lpField">字段名。</param>
|
||
/// <param name="lpValue">期望的字段值。</param>
|
||
/// <returns>找到匹配则返回 TRUE。</returns>
|
||
static BOOL JsonContainsFieldValue(const std::string& strBody, LPCSTR lpField, LPCSTR lpValue)
|
||
{
|
||
if (lpField == NULL || lpValue == NULL)
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
std::string strNeedle = std::string("\"") + lpField + "\":\"" + lpValue + "\"";
|
||
return strBody.find(strNeedle) != std::string::npos;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 调用 PMC API 验证上传后是否存在对应包名和版本记录。
|
||
/// </summary>
|
||
/// <param name="cli">httplib 客户端。</param>
|
||
/// <param name="strCookies">登录 Session Cookie。</param>
|
||
/// <param name="config">含包名和版本信息的配置。</param>
|
||
/// <returns>0 验证通过;52 未找到版本;53 网络错误。</returns>
|
||
static int VerifyUploadResult(httplib::Client& cli, const std::string& strCookies, const UploadInfoConfig& config)
|
||
{
|
||
std::string strPath = "/api/packages/latest?keyword=" +
|
||
UrlEncodeUtf8(ToUtf8String(config.strPackageName)) + "&page_size=50";
|
||
|
||
httplib::Result result = cli.Get(strPath, MakeCookieHeaders(strCookies));
|
||
if (!result)
|
||
{
|
||
_tprintf(_T("错误: 验证上传结果时网络请求失败\n"));
|
||
return 53;
|
||
}
|
||
|
||
if (result->status != 200)
|
||
{
|
||
_tprintf(_T("错误: 验证上传结果返回 HTTP %d\n"), result->status);
|
||
return 52;
|
||
}
|
||
|
||
std::string strPackageName = ToUtf8String(config.strPackageName);
|
||
std::string strProductVersion = ToUtf8String(config.strProductVersion);
|
||
std::string strFileVersion = ToUtf8String(config.strFileVersion);
|
||
|
||
if (JsonContainsFieldValue(result->body, "package_name", strPackageName.c_str()) &&
|
||
JsonContainsFieldValue(result->body, "product_version", strProductVersion.c_str()) &&
|
||
JsonContainsFieldValue(result->body, "file_version", strFileVersion.c_str()))
|
||
{
|
||
_tprintf(_T("upload ok: %s product_version="), config.strPackageName.GetString());
|
||
_tprintf(_T("%s file_version="), config.strProductVersion.GetString());
|
||
_tprintf(_T("%s\n"), config.strFileVersion.GetString());
|
||
return 0;
|
||
}
|
||
|
||
_tprintf(_T("错误: 上传后未在 PMC 中找到对应版本,可能表单校验失败或版本冲突\n"));
|
||
return 52;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 命令入口
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 处理 upload 命令:解析参数、登录 PMC、上传包并验证结果。
|
||
/// </summary>
|
||
/// <param name="argc">参数个数。</param>
|
||
/// <param name="argv">参数数组。</param>
|
||
/// <returns>0 成功;47-54 各类错误,详见头文件说明。</returns>
|
||
int HandleUploadCommand(int argc, TCHAR* argv[])
|
||
{
|
||
CString strUsername;
|
||
CString strFilePath;
|
||
CString strInfoPath;
|
||
CString strUrlOverride;
|
||
BOOL bForceUpload = FALSE;
|
||
|
||
for (int i = 2; i < argc; ++i)
|
||
{
|
||
CString strArg = argv[i];
|
||
if (strArg.CompareNoCase(_T("-f")) == 0)
|
||
{
|
||
bForceUpload = TRUE;
|
||
continue;
|
||
}
|
||
if (strArg.GetLength() > 9 && strArg.Left(9).CompareNoCase(_T("username=")) == 0)
|
||
{
|
||
strUsername = strArg.Mid(9);
|
||
continue;
|
||
}
|
||
if (strArg.GetLength() > 5 && strArg.Left(5).CompareNoCase(_T("file=")) == 0)
|
||
{
|
||
strFilePath = strArg.Mid(5);
|
||
continue;
|
||
}
|
||
if (strArg.GetLength() > 5 && strArg.Left(5).CompareNoCase(_T("info=")) == 0)
|
||
{
|
||
strInfoPath = strArg.Mid(5);
|
||
continue;
|
||
}
|
||
if (strArg.GetLength() > 4 && strArg.Left(4).CompareNoCase(_T("url=")) == 0)
|
||
{
|
||
strUrlOverride = strArg.Mid(4);
|
||
continue;
|
||
}
|
||
|
||
_tprintf(_T("错误: 多余的参数 %s\n"), argv[i]);
|
||
PrintUploadUsage();
|
||
return 3;
|
||
}
|
||
|
||
if (strUsername.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: upload 缺少必填参数 username=\n"));
|
||
PrintUploadUsage();
|
||
return 47;
|
||
}
|
||
|
||
if (strInfoPath.IsEmpty())
|
||
{
|
||
strInfoPath = _T("upload_info.txt");
|
||
}
|
||
|
||
if (!PathFileExists(strInfoPath))
|
||
{
|
||
_tprintf(_T("错误: info 配置文件不存在 %s\n"), strInfoPath.GetString());
|
||
return 49;
|
||
}
|
||
|
||
UploadInfoConfig config;
|
||
config.strBaseUrl = DEFAULT_BASE_URL;
|
||
|
||
if (!LoadUploadInfoFile(strInfoPath, config))
|
||
{
|
||
return 49;
|
||
}
|
||
|
||
if (strFilePath.IsEmpty())
|
||
{
|
||
strFilePath = config.strPackageFile;
|
||
}
|
||
|
||
if (strFilePath.IsEmpty())
|
||
{
|
||
_tprintf(_T("错误: 未指定 file=,且 info 文件中缺少 file 字段\n"));
|
||
PrintUploadUsage();
|
||
return 47;
|
||
}
|
||
|
||
if (!PathFileExists(strFilePath))
|
||
{
|
||
_tprintf(_T("错误: 上传文件不存在 %s\n"), strFilePath.GetString());
|
||
return 48;
|
||
}
|
||
|
||
if (!strUrlOverride.IsEmpty())
|
||
{
|
||
config.strBaseUrl = strUrlOverride;
|
||
}
|
||
if (config.strBaseUrl.IsEmpty())
|
||
{
|
||
config.strBaseUrl = DEFAULT_BASE_URL;
|
||
}
|
||
|
||
if (!ValidateUploadConfig(config))
|
||
{
|
||
return 49;
|
||
}
|
||
|
||
if (!bForceUpload)
|
||
{
|
||
int nTagRet = ValidateUploadGitTagState(g_szCurModuleDir);
|
||
if (nTagRet != 0)
|
||
{
|
||
return nTagRet;
|
||
}
|
||
}
|
||
|
||
CString strPassword;
|
||
if (!ResolvePasswordFromEnv(strUsername, strPassword))
|
||
{
|
||
return 49;
|
||
}
|
||
|
||
std::unique_ptr<httplib::Client> cli = CreatePmcClient(config.strBaseUrl);
|
||
if (!cli)
|
||
{
|
||
_tprintf(_T("错误: 无法连接 PMC 服务 %s\n"), config.strBaseUrl.GetString());
|
||
return 53;
|
||
}
|
||
|
||
std::string strCookies;
|
||
int nLoginRet = PmcLogin(*cli, strUsername, strPassword, strCookies);
|
||
if (nLoginRet != 0)
|
||
{
|
||
return nLoginRet;
|
||
}
|
||
|
||
int nUploadRet = PmcUploadPackage(*cli, strCookies, config, strFilePath);
|
||
if (nUploadRet != 0)
|
||
{
|
||
return nUploadRet;
|
||
}
|
||
|
||
return VerifyUploadResult(*cli, strCookies, config);
|
||
}
|