This commit is contained in:
seb
2026-07-13 07:34:17 +02:00
parent d471f2411d
commit b7b76c9d39
46 changed files with 39025 additions and 7 deletions

64
jtlsrv-cpp/src/config.hpp Normal file
View File

@@ -0,0 +1,64 @@
#pragma once
#include <cstdlib>
#include <fstream>
#include <string>
#include <unordered_map>
namespace config {
// Loads a .env file into the process environment (and returns the map).
// Lines starting with '#' and blank lines are skipped.
// No shell expansion, no quoting -- matches Node's dotenv behavior.
inline std::unordered_map<std::string, std::string> load(const std::string& path = ".env") {
std::unordered_map<std::string, std::string> vars;
std::ifstream file(path);
if (!file.is_open()) return vars;
std::string line;
while (std::getline(file, line)) {
// Trim leading whitespace
size_t start = line.find_first_not_of(" \t\r\n");
if (start == std::string::npos) continue;
line = line.substr(start);
if (line.empty() || line[0] == '#') continue;
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
// Trim trailing whitespace from value
size_t end = val.find_last_not_of(" \t\r\n");
if (end != std::string::npos) val = val.substr(0, end + 1);
// Strip surrounding quotes
if (val.size() >= 2 &&
((val.front() == '"' && val.back() == '"') ||
(val.front() == '\'' && val.back() == '\''))) {
val = val.substr(1, val.size() - 2);
}
setenv(key.c_str(), val.c_str(), 0); // don't overwrite existing
vars[key] = val;
}
return vars;
}
// Read a string env var with a fallback default.
inline std::string get(const char* key, const char* def = "") {
const char* val = std::getenv(key);
return val ? val : def;
}
// Read an integer env var with a fallback default.
inline int get_int(const char* key, int def = 0) {
const char* val = std::getenv(key);
if (!val) return def;
try { return std::stoi(val); }
catch (...) { return def; }
}
} // namespace config

276
jtlsrv-cpp/src/db/pool.cpp Normal file
View File

@@ -0,0 +1,276 @@
#include "pool.hpp"
#include "../config.hpp"
#include "../log.hpp"
#include <cstring>
#include <cstdlib>
static OdbcPool g_pool;
OdbcPool& get_pool() { return g_pool; }
OdbcPool::~OdbcPool() { disconnect(); }
int OdbcPool::connect() {
SQLRETURN rc;
rc = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &henv_);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) return -1;
SQLSetEnvAttr(henv_, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0);
std::string server = config::get("MSSQL_SERVER", "localhost");
int port = config::get_int("MSSQL_PORT", 1433);
std::string database = config::get("MSSQL_DATABASE", "eazybusiness");
std::string user = config::get("MSSQL_USER");
std::string password = config::get("MSSQL_PASSWORD");
bool encrypt = (config::get("MSSQL_ENCRYPT", "true") != "false");
bool trust_cert = (config::get("MSSQL_TRUST_SERVER_CERTIFICATE", "true") != "false");
if (user.empty()) {
logc::warn("MSSQL_USER not set, skipping DB connection");
return -1;
}
// Connection string
std::string conn_str =
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=" + server + "," + std::to_string(port) + ";"
"DATABASE=" + database + ";"
"UID=" + user + ";"
"PWD=" + password + ";"
"Encrypt=" + (encrypt ? std::string("yes") : std::string("Optional")) + ";"
"TrustServerCertificate=" + (trust_cert ? std::string("yes") : std::string("no")) + ";";
logc::info("ODBC connecting to %s:%d/%s as %s", server.c_str(), port, database.c_str(), user.c_str());
// Create pool of 4 connections
const int POOL_SIZE = 4;
conns_.resize(POOL_SIZE);
int connected = 0;
for (int i = 0; i < POOL_SIZE; i++) {
rc = SQLAllocHandle(SQL_HANDLE_DBC, henv_, &conns_[i].hdbc);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) continue;
rc = SQLDriverConnect(conns_[i].hdbc, nullptr,
(SQLCHAR*)conn_str.c_str(), SQL_NTS,
nullptr, 0, nullptr, SQL_DRIVER_COMPLETE);
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLAllocHandle(SQL_HANDLE_STMT, conns_[i].hdbc, &conns_[i].hstmt);
connected++;
} else {
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH];
SQLINTEGER native;
SQLSMALLINT msg_len;
SQLGetDiagRec(SQL_HANDLE_DBC, conns_[i].hdbc, 1,
state, &native, msg, sizeof(msg), &msg_len);
logc::warn("ODBC connect[%d] failed: %s - %s", i, state, msg);
}
}
if (connected == 0) {
logc::error("no ODBC connections established");
return -1;
}
logc::success("ODBC pool: %d connections to %s/%s", connected, server.c_str(), database.c_str());
return 0;
}
void OdbcPool::disconnect() {
for (auto& c : conns_) {
if (c.hstmt != SQL_NULL_HSTMT) SQLFreeHandle(SQL_HANDLE_STMT, c.hstmt);
if (c.hdbc != SQL_NULL_HDBC) { SQLDisconnect(c.hdbc); SQLFreeHandle(SQL_HANDLE_DBC, c.hdbc); }
}
conns_.clear();
if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; }
}
OdbcPool::Connection* OdbcPool::checkout() {
std::lock_guard<std::mutex> lock(mutex_);
for (auto& c : conns_) {
if (!c.in_use) { c.in_use = true; return &c; }
}
return nullptr;
}
void OdbcPool::release(Connection* c) {
std::lock_guard<std::mutex> lock(mutex_);
c->in_use = false;
}
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
Connection* c = checkout();
if (!c) return false;
SQLRETURN rc;
// Prepare statement
rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false;
}
// Wide buffer storage for NVarChar parameters (must outlive SQLExecute)
std::vector<std::vector<SQLWCHAR>> wbufs;
// Bind parameters
for (size_t i = 0; i < params.size(); i++) {
const auto& p = params[i];
SQLUSMALLINT param_num = static_cast<SQLUSMALLINT>(i + 1);
SQLPOINTER val_ptr = nullptr;
SQLLEN buf_len = 0;
SQLLEN indicator = 0;
SQLSMALLINT c_type = SQL_C_CHAR;
SQLSMALLINT sql_type = SQL_VARCHAR;
switch (p.type) {
case ParamType::Int:
c_type = SQL_C_SLONG;
sql_type = SQL_INTEGER;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLINTEGER);
break;
case ParamType::BigInt:
c_type = SQL_C_SBIGINT;
sql_type = SQL_BIGINT;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLBIGINT);
break;
case ParamType::Float:
case ParamType::Double:
c_type = SQL_C_DOUBLE;
sql_type = SQL_DOUBLE;
val_ptr = (SQLPOINTER)&p.dbl_val;
buf_len = sizeof(SQLDOUBLE);
break;
case ParamType::NVarChar: {
// Convert narrow string to SQLWCHAR (unsigned short = UTF-16)
// SQLWCHAR is unsigned short (2 bytes) on this platform
std::vector<SQLWCHAR> wbuf(p.str_val.size());
for (size_t k = 0; k < p.str_val.size(); k++)
wbuf[k] = static_cast<SQLWCHAR>((unsigned char)p.str_val[k]);
wbufs.push_back(std::move(wbuf));
auto& wb = wbufs.back();
c_type = SQL_C_WCHAR;
sql_type = SQL_WVARCHAR;
val_ptr = (SQLPOINTER)wb.data();
buf_len = (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
indicator = wb.empty() ? SQL_NULL_DATA : (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
break;
}
case ParamType::Bit:
c_type = SQL_C_BIT;
sql_type = SQL_BIT;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = 1;
break;
}
rc = SQLBindParameter(c->hstmt, param_num, SQL_PARAM_INPUT,
c_type, sql_type, p.str_val.size() + 1, 0, val_ptr, buf_len, &indicator);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false;
}
}
// Execute
rc = SQLExecute(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) {
release(c);
return false;
}
// Fetch results if it's a SELECT
out.clear();
SQLSMALLINT col_count = 0;
SQLNumResultCols(c->hstmt, &col_count);
if (col_count > 0) {
// Discover column types
std::vector<SQLSMALLINT> col_types(col_count);
bool has_blobs = false;
for (SQLSMALLINT col = 0; col < col_count; col++) {
SQLSMALLINT data_type;
SQLDescribeCol(c->hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
col_types[col] = data_type;
if (data_type == SQL_BINARY || data_type == SQL_VARBINARY || data_type == SQL_LONGVARBINARY)
has_blobs = true;
}
while (true) {
rc = SQLFetch(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
Row row;
for (SQLSMALLINT col = 0; col < col_count; col++) {
Cell cell;
SQLLEN ind;
SQLSMALLINT sql_type = col_types[col];
// Binary columns: read as binary
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
sql_type == SQL_LONGVARBINARY) {
std::vector<uint8_t> blob_data;
unsigned char chunk[8192];
int chunk_count = 0;
while (true) {
rc = SQLGetData(c->hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
if (ind == SQL_NULL_DATA) { break; }
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk));
if (has_blobs && chunk_count == 0)
logc::info(" blob col=%d rc=%d ind=%d copy=%d", col, rc, (int)ind, (int)copy_len);
blob_data.insert(blob_data.end(), chunk, chunk + copy_len);
chunk_count++;
}
if (rc == SQL_SUCCESS) break;
if (rc != SQL_SUCCESS_WITH_INFO) break;
}
if (chunk_count > 0) {
cell.type = CellType::Blob;
cell.blob = std::move(blob_data);
if (has_blobs)
logc::info(" blob col=%d total=%zu chunks=%d", col, cell.blob.size(), chunk_count);
}
} else {
// String/numeric columns: read as char
char buf[4096];
rc = SQLGetData(c->hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
if (ind == SQL_NULL_DATA) {
cell.type = CellType::Null;
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
cell.type = CellType::String;
cell.str = std::string(buf, std::min((SQLLEN)(sizeof(buf)-1), ind));
}
}
row.push_back(std::move(cell));
}
out.push_back(std::move(row));
}
}
// Reset statement for reuse
SQLFreeStmt(c->hstmt, SQL_UNBIND);
SQLFreeStmt(c->hstmt, SQL_CLOSE);
release(c);
return true;
}
bool OdbcPool::execute(const std::string& sql, ResultSet& out) {
return execute(sql, {}, out);
}
int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param>& params, int64_t fallback) {
ResultSet rs;
if (!execute(sql, params, rs) || rs.empty() || rs[0].empty()) return fallback;
const auto& cell = rs[0][0];
if (cell.type == CellType::Int64) return cell.i64;
if (cell.type == CellType::String) {
try { return std::stoll(cell.str); } catch (...) {}
}
return fallback;
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <string>
#include <vector>
#include <mutex>
#include <cstdint>
#include <sql.h>
#include <sqlext.h>
#include <uv.h>
enum class CellType { Null, Int64, Double, String, Blob };
struct Cell {
CellType type = CellType::Null;
int64_t i64 = 0;
double dbl = 0.0;
std::string str;
std::vector<uint8_t> blob;
};
using Row = std::vector<Cell>;
using ResultSet = std::vector<Row>;
enum class ParamType { Int, BigInt, Float, Double, NVarChar, Bit };
struct Param {
ParamType type;
std::string str_val;
int64_t int_val = 0;
double dbl_val = 0.0;
};
class OdbcPool {
public:
~OdbcPool();
int connect();
void disconnect();
bool execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(const std::string& sql, ResultSet& out);
int64_t execute_scalar(const std::string& sql, const std::vector<Param>& params = {}, int64_t fallback = 0);
private:
struct Connection {
SQLHDBC hdbc = SQL_NULL_HDBC;
SQLHSTMT hstmt = SQL_NULL_HSTMT;
bool in_use = false;
};
SQLHENV henv_ = SQL_NULL_HENV;
std::vector<Connection> conns_;
std::mutex mutex_;
Connection* checkout();
void release(Connection* c);
};
OdbcPool& get_pool();

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/category_list.hpp"
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
int limit = std::stoi(req.get_query_param("limit", "20"));
auto categories = get_category_list(cursor, limit);
resp.send_json(200, categories);
}

View File

@@ -0,0 +1,22 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../queries/image.hpp"
void handle_cimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
std::string path = req.get_query_param("path");
if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});
return;
}
std::string size = req.get_query_param("size", "200");
ImageResult image = get_image_by_hash(path, size);
if (image.buffer.empty()) {
resp.send_json(404, {{"Message", "No image was found for path '" + path + "'."}});
return;
}
resp.send_binary(200, image.buffer, image.content_type);
}

View File

@@ -0,0 +1,61 @@
// GET /v1/client — pairing handshake (no DB)
// Port of src/endpoints/client.js
#include "../http.hpp"
#include "../pairing.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
static json build_client_step1(const json& config) {
return {
{"authCode", nullptr},
{"authToken", config["authToken"]},
{"certificateFingerprint", config["certificateFingerprint"]},
{"certificateSerialNumber", config["certificateSerialNumber"]},
{"mandantId", config["mandantId"]},
{"mandantName", nullptr},
{"mandantDatabase", nullptr},
{"serverFingerprint", config["serverFingerprint"]},
{"name", nullptr},
{"serverTimestamp", server_timestamp()},
};
}
static json build_client_step2(const std::string& auth_code, const json& config) {
return {
{"authCode", auth_code},
{"authToken", config["authToken"]},
{"certificateFingerprint", config["certificateFingerprint"]},
{"certificateSerialNumber", config["certificateSerialNumber"]},
{"mandantId", config["mandantId"]},
{"mandantName", config["mandantName"]},
{"mandantDatabase", config["mandantDatabase"]},
{"serverFingerprint", nullptr},
{"name", nullptr},
{"serverTimestamp", server_timestamp()},
};
}
void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
std::string auth_code = req.get_query_param("authCode");
std::string name = req.get_query_param("name", "JTL-POS");
if (auth_code.size() <= 4 && !auth_code.empty()) {
return resp.send_json(200, build_client_step1(ctx.config));
}
if (auth_code.size() == 6) {
if (ctx.pairing_store->has_pairing_code(auth_code)) {
ctx.pairing_store->revoke_pairing_code(auth_code);
ctx.pairing_store->register_device(ctx.config["authToken"].get<std::string>(), name);
return resp.send_json(200, build_client_step2(auth_code, ctx.config));
}
return resp.send_json(400, {
{"Message", "Der Authentifizierungscode ist falsch."}
});
}
return resp.send_json(400, {
{"Message", "Keinen passenden Authentifizierungscode gefunden."}
});
}

View File

@@ -0,0 +1,10 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/customer_groups.hpp"
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
auto groups = get_customer_group_list(cursor);
resp.send_json(200, groups);
}

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/deleted_entity_list.hpp"
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
int limit = std::stoi(req.get_query_param("limit", "600"));
auto deleted = get_deleted_entity_list(cursor, limit);
resp.send_json(200, deleted);
}

View File

@@ -0,0 +1,41 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/counts.hpp"
#include "../queries/customer_groups.hpp"
#include "../queries/shop.hpp"
#include "../config.hpp"
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t product_cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
int64_t category_cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
int64_t cg_cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
int64_t composite_cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
int64_t deleted_cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
int root = config::get_int("ROOT_CATEGORY_ID", 1);
int shop = get_active_shop_id();
int64_t product_count = 0, category_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0;
if (get_pool().execute_scalar("SELECT 1") != 0) {
product_count = get_product_count(root, shop, product_cursor);
category_count = get_category_count(root, shop, category_cursor);
cg_count = get_customer_group_count(cg_cursor);
composite_count = get_composite_count(shop, composite_cursor);
deleted_count = get_deleted_count(deleted_cursor);
}
resp.send_json(200, {
{"version", "1.10.12.0"},
{"product_count", std::to_string(product_count)},
{"category_count", std::to_string(category_count)},
{"customer_count", "0"},
{"customerGroup_count", std::to_string(cg_count)},
{"compositeProduct_count", std::to_string(composite_count)},
{"configurationGroup_count", "0"},
{"configurationItem_count", "0"},
{"deletedEntity_count", std::to_string(deleted_count)},
{"max_orderId_count", "0"}
});
}

View File

@@ -0,0 +1,28 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
// TODO: full order creation (Milestone 5/6)
// For now, parse the JSON body and acknowledge
nlohmann::json body;
try {
body = nlohmann::json::parse(req.body);
} catch (...) {
return resp.send_json(500, nlohmann::json::array());
}
nlohmann::json results = nlohmann::json::array();
if (body.contains("orders") && body["orders"].is_array()) {
for (auto& order : body["orders"]) {
std::string ext_id = order.value("externalId", "");
results.push_back({
{"status", "OK"},
{"externalOrderId", ext_id},
{"message", ""}
});
}
}
resp.send_json(200, results);
}

View File

@@ -0,0 +1,22 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../queries/image.hpp"
void handle_pimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
std::string path = req.get_query_param("path");
if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});
return;
}
std::string size = req.get_query_param("size", "200");
ImageResult image = get_image_by_hash(path, size);
if (image.buffer.empty()) {
resp.send_json(404, {{"Message", "No image was found for path '" + path + "'."}});
return;
}
resp.send_binary(200, image.buffer, image.content_type);
}

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/product_list.hpp"
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
int limit = std::stoi(req.get_query_param("limit", "20"));
auto products = get_product_list(cursor, limit);
resp.send_json(200, products);
}

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/composite_product_list.hpp"
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
int limit = std::stoi(req.get_query_param("limit", "100"));
auto composites = get_composite_product_list(cursor, limit);
resp.send_json(200, composites);
}

121
jtlsrv-cpp/src/http.cpp Normal file
View File

@@ -0,0 +1,121 @@
#include "http.hpp"
#include "tls_server.hpp"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <ctime>
// ---------------------------------------------------------------------------
// HttpRequest
// ---------------------------------------------------------------------------
std::string HttpRequest::get_query_param(const std::string& key, const std::string& def) const {
std::string needle = key + "=";
size_t pos = query_string.find(needle);
if (pos == std::string::npos) return def;
size_t val_start = pos + needle.size();
size_t val_end = query_string.find('&', val_start);
if (val_end == std::string::npos) val_end = query_string.size();
std::string raw = query_string.substr(val_start, val_end - val_start);
// Simple URL decode
std::string decoded;
decoded.reserve(raw.size());
for (size_t i = 0; i < raw.size(); ++i) {
if (raw[i] == '%' && i + 2 < raw.size()) {
char hex[3] = { raw[i+1], raw[i+2], 0 };
decoded += static_cast<char>(std::strtol(hex, nullptr, 16));
i += 2;
} else if (raw[i] == '+') {
decoded += ' ';
} else {
decoded += raw[i];
}
}
return decoded;
}
// ---------------------------------------------------------------------------
// HttpResponse
// ---------------------------------------------------------------------------
void HttpResponse::send_json(int code, const json& body) {
if (headers_sent) return;
status_code = code;
std::string body_str = body.dump();
body_for_log = body_str;
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"Content-Type: application/json; charset=utf-8\r\n"
"Content-Length: " + std::to_string(body_str.size()) + "\r\n"
"Connection: keep-alive\r\n"
"\r\n"
+ body_str;
session_write(session, resp);
headers_sent = true;
}
void HttpResponse::send_binary(int code, const std::vector<uint8_t>& data, const std::string& content_type) {
if (headers_sent) return;
status_code = code;
std::string header = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"Content-Type: " + content_type + "\r\n"
"Content-Length: " + std::to_string(data.size()) + "\r\n"
"Connection: keep-alive\r\n"
"\r\n";
session_write_binary(session, header, data);
headers_sent = true;
}
void HttpResponse::send_empty(int code) {
if (headers_sent) return;
status_code = code;
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"Content-Length: 0\r\n"
"Connection: keep-alive\r\n"
"\r\n";
session_write(session, resp);
headers_sent = true;
}
void HttpResponse::finish() {
// Connection keep-alive: don't close after each request.
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
std::string normalize_path(const std::string& pathname) {
// /api/v1/... -> /v1/...
const std::string prefix = "/api";
if (pathname.size() >= prefix.size() &&
pathname.compare(0, prefix.size(), prefix) == 0) {
if (pathname.size() > prefix.size() && pathname[prefix.size()] == '/' &&
pathname.size() > prefix.size() + 1 &&
pathname.compare(prefix.size() + 1, 2, "v1") == 0) {
return pathname.substr(prefix.size());
}
}
return pathname;
}
std::string server_timestamp() {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf);
return buf;
}

76
jtlsrv-cpp/src/http.hpp Normal file
View File

@@ -0,0 +1,76 @@
#pragma once
// HTTP request / response types + llhttp glue, and send_json/send_binary.
// Modeled after the Node.js src/http.js.
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
#include <uv.h>
#include <nlohmann/json.hpp>
// Forward-declared; defined in tls_server.
struct tls_session;
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// Parsed HTTP request
// ---------------------------------------------------------------------------
struct HttpRequest {
std::string method; // "GET", "POST", ...
std::string path; // "/v1/client?authCode=..."
std::string version; // "1.1"
std::unordered_map<std::string, std::string> headers;
std::vector<uint8_t> body;
// Parsed query string
std::string query_string; // "authCode=xxx&name=yyy"
std::string get_query_param(const std::string& key, const std::string& def = "") const;
};
// ---------------------------------------------------------------------------
// Response writer (wraps a tls_session*)
// ---------------------------------------------------------------------------
struct HttpResponse {
tls_session* session = nullptr;
int status_code = 200;
bool headers_sent = false;
std::string body_for_log; // captured for request logging
void send_json(int code, const json& body);
void send_binary(int code, const std::vector<uint8_t>& data, const std::string& content_type);
void send_empty(int code);
// After response is fully written, close the connection.
void finish();
};
// ---------------------------------------------------------------------------
// Route handler signature
// ---------------------------------------------------------------------------
struct RouteContext {
std::string query_path; // path without query string
std::string full_url; // raw path + query
class PairingStore* pairing_store = nullptr;
json config;
};
using RouteHandler = std::function<void(HttpRequest&, HttpResponse&, RouteContext&)>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Normalize /api/v1/... -> /v1/... (matches src/http.js normalizePath)
std::string normalize_path(const std::string& pathname);
// Format "YYYY-MM-DD HH:MM:SS" from current time (matches serverTimestamp)
std::string server_timestamp();

45
jtlsrv-cpp/src/log.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include "log.hpp"
#include <chrono>
#include <cstdio>
#include <cstdarg>
#include <ctime>
namespace logc {
static const char* level_label(Level l) {
switch (l) {
case Level::Info: return "\033[36mINFO\033[0m";
case Level::Success: return "\033[32mOK \033[0m";
case Level::Warn: return "\033[33mWARN\033[0m";
case Level::Error: return "\033[31mERROR\033[0m";
}
return "????";
}
static FILE* log_stream(Level l) {
return (l == Level::Warn || l == Level::Error) ? stderr : stdout;
}
void write(Level level, const char* fmt, ...) {
// ISO-8601 timestamp
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
FILE* out = log_stream(level);
fprintf(out, "\033[90m%s\033[0m %s ", ts, level_label(level));
va_list args;
va_start(args, fmt);
vfprintf(out, fmt, args);
va_end(args);
fprintf(out, "\n");
fflush(out);
}
} // namespace logc

35
jtlsrv-cpp/src/log.hpp Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <utility>
namespace logc {
enum class Level { Info, Success, Warn, Error };
void write(Level level, const char* fmt, ...);
// Convenience wrappers
template <typename... Args>
void info(const char* fmt, Args&&... args) {
write(Level::Info, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void success(const char* fmt, Args&&... args) {
write(Level::Success, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void warn(const char* fmt, Args&&... args) {
write(Level::Warn, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void error(const char* fmt, Args&&... args) {
write(Level::Error, fmt, std::forward<Args>(args)...);
}
} // namespace logc

173
jtlsrv-cpp/src/main.cpp Normal file
View File

@@ -0,0 +1,173 @@
// jtlsrv-cpp — main.cpp (Milestone 3: Router + pairing + endpoints)
#include <cstdio>
#include <string>
#include <chrono>
#include <uv.h>
#include "config.hpp"
#include "log.hpp"
#include "tls_server.hpp"
#include "http.hpp"
#include "router.hpp"
#include "pairing.hpp"
#include "db/pool.hpp"
#include <vips/vips.h>
#include "queries/shop.hpp"
#include "request_log.hpp"
#include "order_log.hpp"
// ---------------------------------------------------------------------------
// Globals
// ---------------------------------------------------------------------------
static uv_loop_t* loop = nullptr;
static Router router;
static PairingStore pairing_store;
static RequestLog request_log;
static OrderLog order_log;
static json build_config() {
return {
{"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")},
{"certificateFingerprint", config::get("CERTIFICATE_FINGERPRINT", "BC2114CF407A42724BEEF417960F76DCBF9DE879")},
{"certificateSerialNumber", config::get("CERTIFICATE_SERIAL_NUMBER", "00BFC8BEACDB981B165210EF111CB9D3")},
{"serverFingerprint", config::get("SERVER_FINGERPRINT", "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE")},
{"mandantId", config::get("MANDANT_ID", "1")},
{"mandantName", config::get("MANDANT_NAME", "eB-Standard")},
{"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")},
};
}
static json server_config;
// ---------------------------------------------------------------------------
// Endpoint declarations (defined in src/endpoints/*.cpp)
// ---------------------------------------------------------------------------
extern void handle_client(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_init(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_category(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_product(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_productcomposite(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_deleted_entity(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_customergroup(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_order(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_pimage(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_cimage(HttpRequest&, HttpResponse&, RouteContext&);
// ---------------------------------------------------------------------------
// Request handler — dispatches via Router
// ---------------------------------------------------------------------------
static void handle_request(tls_session* sess) {
auto start = std::chrono::steady_clock::now();
router.dispatch(sess, pairing_store, server_config);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
auto& req = sess->current_request;
auto& resp = sess->current_response;
std::string url = req.path;
if (!req.query_string.empty()) url += "?" + req.query_string;
// Build response body for logging
std::string resp_body;
if (resp.status_code == 200) {
// Re-serialize to get size (body already sent, but we can dump from status)
// We need to capture the body before sending - patch: store it in resp
resp_body = resp.body_for_log;
}
size_t resp_size = resp_body.size();
// Console log with response size and truncated body
if (resp_size > 0) {
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)200));
logc::info("127.0.0.1 %s %s %d %dms [%zu bytes] %s",
req.method.c_str(), url.c_str(), resp.status_code,
(int)elapsed, resp_size, preview.c_str());
} else {
logc::info("127.0.0.1 %s %s %d %dms",
req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
}
// Request log
request_log.log("127.0.0.1", req.method, url, resp.status_code, (int)elapsed, resp_body);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
int main(int argc, char* argv[]) {
config::load(".env");
if (VIPS_INIT(argv[0])) {
vips_error_exit("unable to init libvips");
}
int port = config::get_int("PORT", 4443);
std::string cert_path = "certs/cert.pem";
std::string key_path = "certs/key.pem";
loop = uv_default_loop();
server_config = build_config();
// Register routes
router.add_route("GET", "/v1/client", handle_client);
router.add_route("GET", "/v1/init", handle_init);
router.add_route("GET", "/v1/category", handle_category);
router.add_route("GET", "/v1/product", handle_product);
router.add_route("GET", "/v1/productcomposite", handle_productcomposite);
router.add_route("GET", "/v1/deletedentity", handle_deleted_entity);
router.add_route("GET", "/v1/customergroup", handle_customergroup);
router.add_route("POST", "/v1/order", handle_order);
router.add_route("GET", "/v1/pimage", handle_pimage);
router.add_route("GET", "/v1/cimage", handle_cimage);
// Initialize pairing store
pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS");
pairing_store.register_device(server_config["authToken"].get<std::string>(), "JTL-POS");
// Connect to MSSQL
if (get_pool().connect() == 0) {
logc::success("MSSQL connected: %s/%s",
config::get("MSSQL_SERVER").c_str(),
config::get("MSSQL_DATABASE").c_str());
if (fetch_active_shop()) {
logc::info("Active shop ID: %d", get_active_shop_id());
}
} else {
logc::warn("MSSQL connection skipped");
logc::warn("POS handshake will still work; sync from database is not available yet.");
}
tls_server_set_handler(handle_request);
// Open log files
request_log.open(config::get("LOG_FILE", "logs/requests.log"));
order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log"));
int r = tls_server_init(loop, "0.0.0.0", port,
cert_path.c_str(), key_path.c_str());
if (r != 0) {
logc::error("failed to start TLS server");
return 1;
}
tls_server_install_signals(loop);
logc::info("pairing code: %s", config::get("PAIRING_CODE", "307018").c_str());
uv_run(loop, UV_RUN_DEFAULT);
request_log.close();
order_log.close();
get_pool().disconnect();
vips_shutdown();
logc::info("shutdown complete.");
return 0;
}

View File

@@ -0,0 +1,44 @@
#include "order_log.hpp"
#include <chrono>
#include <ctime>
#include <sys/stat.h>
static void ensure_parent_dir(const std::string& path) {
size_t pos = path.rfind('/');
if (pos != std::string::npos) {
mkdir(path.substr(0, pos).c_str(), 0755);
}
}
void OrderLog::open(const std::string& path) {
ensure_parent_dir(path);
fp_ = std::fopen(path.c_str(), "a");
}
void OrderLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; }
}
int OrderLog::log_order(const std::string& order_json) {
std::lock_guard<std::mutex> lock(mutex_);
sequence_++;
max_external_id_++;
if (fp_) {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::fprintf(fp_, "%s #%d externalId=%d %s\n",
ts, sequence_, max_external_id_, order_json.c_str());
std::fflush(fp_);
}
return max_external_id_;
}
std::string OrderLog::get_max_external_id() const {
return std::to_string(max_external_id_);
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include <cstdio>
#include <string>
#include <mutex>
class OrderLog {
public:
void open(const std::string& path);
void close();
int log_order(const std::string& order_json);
std::string get_max_external_id() const;
private:
FILE* fp_ = nullptr;
std::mutex mutex_;
int sequence_ = 0;
int max_external_id_ = 0;
};

View File

@@ -0,0 +1,27 @@
#include "pairing.hpp"
#include <chrono>
static uint64_t now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
void PairingStore::set_pairing_code(const std::string& code, const std::string& name) {
auth_codes_[code] = {code, name, now_ms()};
}
void PairingStore::revoke_pairing_code(const std::string& code) {
auth_codes_.erase(code);
}
bool PairingStore::has_pairing_code(const std::string& code) const {
return auth_codes_.count(code) > 0;
}
void PairingStore::register_device(const std::string& token, const std::string& name) {
paired_devices_[token] = {name, token, now_ms()};
}
const std::unordered_map<std::string, DeviceEntry>& PairingStore::get_paired_devices() const {
return paired_devices_;
}

View File

@@ -0,0 +1,33 @@
#pragma once
// In-memory pairing store - trivial port of src/pairing.js
#include <string>
#include <unordered_map>
#include <cstdint>
struct PairingEntry {
std::string code;
std::string name;
uint64_t created_at;
};
struct DeviceEntry {
std::string name;
std::string token;
uint64_t created_at;
};
class PairingStore {
public:
void set_pairing_code(const std::string& code, const std::string& name = "JTL-POS");
void revoke_pairing_code(const std::string& code);
bool has_pairing_code(const std::string& code) const;
void register_device(const std::string& token, const std::string& name = "JTL-POS");
const std::unordered_map<std::string, DeviceEntry>& get_paired_devices() const;
private:
std::unordered_map<std::string, PairingEntry> auth_codes_;
std::unordered_map<std::string, DeviceEntry> paired_devices_;
};

View File

@@ -0,0 +1,59 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
#include "../http.hpp"
#include "../config.hpp"
inline nlohmann::json get_category_list(int64_t cursor, int limit) {
int root = config::get_int("ROOT_CATEGORY_ID", 1);
int lang = config::get_int("LANGUAGE_ID", 1);
int shop = get_active_shop_id();
std::string sql =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT TOP (?) k.kKategorie AS id, k.kOberKategorie AS pid, "
"k.nSort AS sort, ks.cName AS name, b.cHash AS imgHash, "
"CONVERT(BIGINT, k.bRowversion) AS lastChanged "
"FROM dbo.tKategorie k "
"INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = ? "
"LEFT JOIN dbo.tKategoriebildPlattform kbp ON kbp.kKategorie = k.kKategorie "
"LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild "
"WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> ?) "
"AND k.cAktiv = 'Y' "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 "
"WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = ?)) "
"AND CONVERT(BIGINT, k.bRowversion) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::BigInt,"",root},{ParamType::Int,"",limit},
{ParamType::Int,"",lang},{ParamType::BigInt,"",root},
{ParamType::Int,"",shop},{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
auto ts = server_timestamp();
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
std::string pid = (std::stoll(row[1].str) == root) ? "0" : row[1].str;
result.push_back({
{"_id", row[0].str},
{"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},
{"imgsrc", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},
{"name", row[3].str},
{"pid", pid},
{"discounts", nlohmann::json::array()},
{"sort", row[2].str},
{"lastChanged", row[5].str},
{"updated_at", ts},
{"created_at", ts}
});
}
return result;
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
inline nlohmann::json get_composite_product_list(int64_t cursor, int limit) {
int shop = get_active_shop_id();
std::string sql =
"SELECT TOP (?) s.kVaterArtikel AS productId, "
"s.kArtikel AS productIdComponent, "
"CONVERT(VARCHAR(20), s.fAnzahl, 2) AS quantity, "
"CONVERT(BIGINT, a.bRowversion) AS lastChanged "
"FROM dbo.tStueckliste s "
"INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel "
"WHERE a.kStueckliste <> 0 "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie "
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
"AND CONVERT(BIGINT, a.bRowversion) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::Int,"",limit},
{ParamType::Int,"",shop},
{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"productId", row[0].str},
{"productIdComponent", row[1].str},
{"quantity", row[2].str},
{"lastChanged", row[3].str}
});
}
return result;
}

View File

@@ -0,0 +1,65 @@
#pragma once
#include "../db/pool.hpp"
static const char* CATEGORY_COUNT_SQL =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT COUNT(*) AS cnt FROM dbo.tKategorie k "
"WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree) "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks "
"WHERE ks.kKategorie = k.kKategorie AND ks.kShop = ?)) "
"AND CONVERT(BIGINT, k.bRowversion) > ?";
static const char* PRODUCT_COUNT_SQL =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT COUNT(DISTINCT a.kArtikel) AS cnt FROM dbo.tArtikel a "
"INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel "
"WHERE a.cAktiv = 'Y' "
"AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree) "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks "
"WHERE ks.kKategorie = ka.kKategorie AND ks.kShop = ?)) "
"AND (CONVERT(BIGINT, a.bRowversion) > ? "
"OR EXISTS (SELECT 1 FROM dbo.tArtikelbildPlattform abp "
"WHERE abp.kArtikel = a.kArtikel AND abp.kShop = ? "
"AND CONVERT(BIGINT, abp.bRowversion) > ?))";
static const char* COMPOSITE_PRODUCT_COUNT_SQL =
"SELECT COUNT(DISTINCT a.kArtikel) AS cnt FROM dbo.tArtikel a "
"INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste "
"WHERE a.kStueckliste <> 0 "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie "
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
"AND CONVERT(BIGINT, a.bRowversion) > ?";
static const char* DELETED_ENTITY_COUNT_SQL =
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
inline int64_t get_category_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(CATEGORY_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
}
inline int64_t get_product_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
}
inline int64_t get_composite_count(int k_shop, int64_t cursor) {
return get_pool().execute_scalar(COMPOSITE_PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",k_shop},
{ParamType::BigInt,"",cursor}});
}
inline int64_t get_deleted_count(int64_t cursor) {
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
{{ParamType::BigInt,"",cursor}});
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
static const char* CUSTOMER_GROUP_IDS_SQL =
"SELECT kKundenGruppe FROM dbo.tKundenGruppe ORDER BY kKundenGruppe";
static const char* CUSTOMER_GROUP_LIST_SQL =
"SELECT kKundenGruppe AS id, cName AS name, nStandard AS standard, "
"fRabatt AS discountPercent, CONVERT(BIGINT, bRowversion) AS lastChanged "
"FROM dbo.tKundenGruppe WHERE CONVERT(BIGINT, bRowversion) > ? "
"ORDER BY lastChanged ASC";
static const char* CUSTOMER_GROUP_COUNT_SQL =
"SELECT COUNT(*) AS cnt FROM dbo.tKundenGruppe "
"WHERE CONVERT(BIGINT, bRowversion) > ?";
inline std::vector<int64_t> get_customer_group_ids() {
ResultSet rs;
get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs);
std::vector<int64_t> ids;
for (auto& row : rs) {
ids.push_back(std::stoll(row[0].str));
}
return ids;
}
inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
Param p; p.type = ParamType::BigInt; p.int_val = cursor;
ResultSet rs;
get_pool().execute(CUSTOMER_GROUP_LIST_SQL, {p}, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"customerGroupId", row[0].str},
{"name", row[1].str},
{"standard", row[2].str},
{"discountPercent", std::to_string(std::stod(row[3].str))},
{"lastChanged", row[4].str}
});
}
return result;
}
inline int64_t get_customer_group_count(int64_t cursor = 0) {
Param p; p.type = ParamType::BigInt; p.int_val = cursor;
return get_pool().execute_scalar(CUSTOMER_GROUP_COUNT_SQL, {p});
}

View File

@@ -0,0 +1,28 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
inline nlohmann::json get_deleted_entity_list(int64_t cursor, int limit) {
std::string sql =
"SELECT TOP (?) vDeletedEntity.kEntityId, "
"vDeletedEntity.nEntityType, "
"CONVERT(BIGINT, vDeletedEntity.bLastChanged) AS lastChanged "
"FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::Int,"",limit},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"entityId", row[0].str},
{"entityType", row[1].str},
{"lastChanged", row[2].str}
});
}
return result;
}

View File

@@ -0,0 +1,132 @@
#pragma once
#include "../db/pool.hpp"
#include "../log.hpp"
#include <string>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <vips/vips.h>
struct ImageResult {
std::vector<uint8_t> buffer;
std::string content_type;
};
static std::string content_type_for(const std::string& quelle) {
size_t dot = quelle.rfind('.');
std::string ext = (dot != std::string::npos) ? quelle.substr(dot + 1) : "";
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
if (ext == "png") return "image/png";
if (ext == "gif") return "image/gif";
if (ext == "webp") return "image/webp";
return "image/jpeg";
}
static std::string ext_for_content_type(const std::string& ct) {
if (ct == "image/png") return ".png";
if (ct == "image/gif") return ".gif";
if (ct == "image/webp") return ".webp";
return ".jpg";
}
static ImageResult resize_image(const uint8_t* data, size_t len,
int target_size, const std::string& content_type) {
std::string fmt = ext_for_content_type(content_type);
// Use vips_thumbnail_buffer for fast shrink-on-load
VipsImage* out = nullptr;
if (vips_thumbnail_buffer((void*)data, len, &out, target_size,
"height", target_size,
"no_rotate", TRUE,
nullptr)) {
logc::warn("vips_thumbnail_buffer failed (%d): %s",
vips_error_buffer(), vips_error_buffer());
vips_error_clear();
return {};
}
// Write to memory buffer
void* buf = nullptr;
size_t buf_len = 0;
if (vips_image_write_to_buffer(out, fmt.c_str(), &buf, &buf_len, nullptr)) {
logc::warn("vips: failed to write resized image");
g_object_unref(out);
return {};
}
ImageResult result;
result.buffer.assign((uint8_t*)buf, (uint8_t*)buf + buf_len);
result.content_type = content_type;
g_free(buf);
g_object_unref(out);
return result;
}
inline ImageResult get_image_by_hash(const std::string& hash, const std::string& size) {
std::string sql =
"SELECT bBild, bVorschauBild, nBreite, nHoehe, "
"nVorschauBreite, nVorschauHoehe, cQuelle "
"FROM dbo.tBild WHERE cHash = ?";
Param p; p.type = ParamType::NVarChar; p.str_val = hash;
ResultSet rs;
bool ok = get_pool().execute(sql, {p}, rs);
if (!ok) {
logc::warn("image query failed for hash=%s", hash.c_str());
return {};
}
if (rs.empty()) {
logc::warn("image not found for hash=%s (query ok, 0 rows)", hash.c_str());
return {};
}
auto& row = rs[0];
int target = size.empty() ? 200 : std::stoi(size);
std::string ct = content_type_for(row[6].str);
int preview_w = row[4].str.empty() ? 0 : std::stoi(row[4].str);
int preview_h = row[5].str.empty() ? 0 : std::stoi(row[5].str);
int preview_max = std::max(preview_w, preview_h);
bool has_full = !row[0].blob.empty();
bool has_preview = !row[1].blob.empty();
if (target <= 0) {
if (!has_full) return {};
ImageResult r;
r.buffer = std::move(row[0].blob);
r.content_type = ct;
return r;
}
// If preview exists and target fits within preview, resize from preview
if (has_preview && preview_max > 0 && target <= preview_max) {
auto r = resize_image(row[1].blob.data(), row[1].blob.size(), target, ct);
if (!r.buffer.empty()) return r;
// Fallback: raw preview
r.buffer = std::move(row[1].blob);
r.content_type = ct;
return r;
}
// Otherwise resize from full image
if (has_full) {
auto r = resize_image(row[0].blob.data(), row[0].blob.size(), target, ct);
if (!r.buffer.empty()) return r;
// Fallback: raw full
r.buffer = std::move(row[0].blob);
r.content_type = ct;
return r;
}
// Last resort: raw preview
if (has_preview) {
ImageResult r;
r.buffer = std::move(row[1].blob);
r.content_type = ct;
return r;
}
return {};
}

View File

@@ -0,0 +1,167 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
#include "customer_groups.hpp"
#include "../config.hpp"
#include "../http.hpp"
#include <cmath>
static const char* PRODUCT_LIST_SQL =
"WITH TaxRates AS ("
" SELECT kSteuerklasse, fSteuersatz FROM dbo.tSteuersatz"
" WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = ?)"
"), ImageRV AS ("
" SELECT kArtikel, MAX(CONVERT(BIGINT, bRowversion)) AS maxImageRV"
" FROM dbo.tArtikelbildPlattform WHERE kShop = ? GROUP BY kArtikel"
") SELECT TOP (?) "
"a.kArtikel AS id, a.cArtNr AS sku, ab.cName AS name, "
"a.fVKNetto AS netPrice, tr.fSteuersatz AS taxRate, "
"a.dErstelldatum AS createdAt, "
"CASE WHEN ir.maxImageRV IS NOT NULL AND ir.maxImageRV > CONVERT(BIGINT, a.bRowversion) "
"THEN ir.maxImageRV ELSE CONVERT(BIGINT, a.bRowversion) END AS lastChanged, "
"(SELECT TOP 1 img.cHash FROM dbo.tArtikelbildPlattform abp "
"INNER JOIN dbo.tBild img ON img.kBild = abp.kBild "
"WHERE abp.kArtikel = a.kArtikel ORDER BY abp.nNr) AS imgHash, "
"(SELECT STRING_AGG(CAST(ka.kKategorie AS varchar(20)), ',') "
"FROM dbo.tkategorieartikel ka WHERE ka.kArtikel = a.kArtikel) AS categoryIds, "
"a.nIstVater AS isParent, a.kVaterArtikel AS parentArticleId, "
"CASE WHEN a.kStueckliste <> 0 THEN '1' ELSE '0' END AS isCompositeProduct, "
"(SELECT TOP 1 pv.cVariantName FROM Pos.vProductVariant pv "
"WHERE pv.kProduct = a.kArtikel) AS variantName "
"FROM dbo.tArtikel a "
"INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = ? "
"LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse "
"LEFT JOIN ImageRV ir ON ir.kArtikel = a.kArtikel "
"WHERE a.cAktiv = 'Y' "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = ? "
"WHERE ka.kArtikel = a.kArtikel)) "
"AND (CONVERT(BIGINT, a.bRowversion) > ? "
"OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) "
"ORDER BY lastChanged ASC";
static const char* PRICE_OVERRIDES_SQL =
"SELECT p.kArtikel AS articleId, p.kKundenGruppe AS customerGroupId, "
"MIN(pd.fNettoPreis) AS netPrice FROM dbo.tPreis p "
"INNER JOIN dbo.tPreisDetail pd ON pd.kPreis = p.kPreis "
"WHERE p.kArtikel IN (%s) AND p.kShop = 0 AND pd.nAnzahlAb = 0 "
"GROUP BY p.kArtikel, p.kKundenGruppe";
static std::string gross_price(const std::string& net, const std::string& tax) {
double n = std::stod(net.empty() ? "0" : net);
double t = std::stod(tax.empty() ? "0" : tax);
char buf[32];
std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0));
return buf;
}
inline nlohmann::json get_product_list(int64_t cursor, int limit) {
std::string tax_zone = config::get("TAX_ZONE_NAME", "Zone-EU");
int lang = config::get_int("LANGUAGE_ID", 1);
int shop = get_active_shop_id();
std::vector<Param> ps = {
{ParamType::NVarChar, tax_zone, 0},
{ParamType::Int,"",shop},
{ParamType::Int,"",limit},
{ParamType::Int,"",lang},
{ParamType::Int,"",shop},
{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(PRODUCT_LIST_SQL, ps, rs);
auto cg_ids = get_customer_group_ids();
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
std::string base_price = gross_price(row[3].str, row[4].str);
std::string cats_raw = row[8].str;
std::vector<std::string> cat_ids;
if (!cats_raw.empty()) {
size_t pos = 0;
while ((pos = cats_raw.find(',')) != std::string::npos) {
cat_ids.push_back(cats_raw.substr(0, pos));
cats_raw.erase(0, pos + 1);
}
cat_ids.push_back(cats_raw);
}
nlohmann::json cats = nlohmann::json::array();
for (auto& c : cat_ids) cats.push_back({{"categoryId", c}});
nlohmann::json prices = nlohmann::json::array();
for (auto& cgid : cg_ids) {
prices.push_back({
{"customerGroupId", std::to_string(cgid)},
{"customerId", "0"},
{"price", base_price},
{"quantity", "0"}
});
}
nlohmann::json product = {
{"_id", row[0].str},
{"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
{"imgsrc", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
{"sku", row[1].str},
{"name", row[2].str},
{"tax_rate", std::to_string((int)std::round(std::stod(row[4].str.empty()?"0":row[4].str)))},
{"price", base_price},
{"created_at", row[5].str},
{"lastChanged", row[6].str},
{"categories_id", cat_ids.empty() ? "0" : cat_ids[0]},
{"categories", cats},
{"prices", prices},
{"is_parent", row[9].str == "1" ? "1" : "0"},
{"parent", std::stoll(row[10].str) > 0 ? row[10].str : "0"},
{"variants", row[12].str},
{"isCompositeProduct", row[11].str},
{"attributes", nlohmann::json::array()},
{"sort", "0"},
{"p_price", "0.00"},
{"discountable", "0"},
{"deposit", "0"},
{"discount", ""},
{"d_price", "0.0"},
{"tax_rate2", ""},
{"use_in_out_tax", "0"},
{"barcode", nullptr},
{"use_stock", "0"},
{"q_div", "0"},
{"quantity", "0"},
{"unit", nullptr},
{"single_bookable", "0"},
{"annotation", ""},
{"status", "0"},
{"tags", ""},
{"variants", row[12].str},
{"print_kitchen_receipt", "0"},
{"deposit_name", ""},
{"updated_at", "0001-01-01 00:00:00"},
{"configurationGroups", ""},
{"options", nullptr},
{"hasBestBeforeDate", "0"},
{"hasLotNumber", "0"},
{"hasSerialNumber", "0"},
{"PLU", ""},
{"short_description", ""},
{"minStock", "0"},
{"container", nlohmann::json::array()},
{"reservedQuantity", "0.00"},
{"deliveryDetails", nlohmann::json::array()},
{"isbn", ""},
{"manufacturerName", nullptr},
{"han", nullptr},
{"productType", "0"},
{"voucherData", nullptr},
{"inputPrice", "0"},
{"inputQuantity", "0"}
};
result.push_back(std::move(product));
}
return result;
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "../db/pool.hpp"
#include "../config.hpp"
static int g_active_shop_id = 0;
static int g_active_shop_subshop_id = 0;
inline int get_active_shop_id() { return g_active_shop_id; }
inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; }
inline bool fetch_active_shop() {
ResultSet rs;
if (!get_pool().execute(
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
"WHERE nGesperrt = 0 ORDER BY kShop", rs) || rs.empty()) {
return false;
}
g_active_shop_id = std::stoi(rs[0][0].str);
g_active_shop_subshop_id = rs[0].size() > 1 ? std::stoi(rs[0][1].str) : 0;
return true;
}

View File

@@ -0,0 +1,40 @@
#include "request_log.hpp"
#include "log.hpp"
#include <chrono>
#include <ctime>
#include <sys/stat.h>
static void ensure_parent_dir(const std::string& path) {
size_t pos = path.rfind('/');
if (pos != std::string::npos) {
mkdir(path.substr(0, pos).c_str(), 0755);
}
}
void RequestLog::open(const std::string& path) {
ensure_parent_dir(path);
fp_ = std::fopen(path.c_str(), "a");
if (!fp_) logc::warn("cannot open request log: %s", path.c_str());
}
void RequestLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; }
}
void RequestLog::log(const std::string& remote, const std::string& method,
const std::string& url, int status, int duration_ms,
const std::string& response) {
if (!fp_) return;
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::lock_guard<std::mutex> lock(mutex_);
std::fprintf(fp_, "%s %s %s %s %d %dms %s\n",
ts, remote.c_str(), method.c_str(), url.c_str(),
status, duration_ms, response.c_str());
std::fflush(fp_);
}

View File

@@ -0,0 +1,16 @@
#pragma once
#include <cstdio>
#include <string>
#include <mutex>
class RequestLog {
public:
void open(const std::string& path);
void close();
void log(const std::string& remote, const std::string& method,
const std::string& url, int status, int duration_ms,
const std::string& response);
private:
FILE* fp_ = nullptr;
std::mutex mutex_;
};

68
jtlsrv-cpp/src/router.cpp Normal file
View File

@@ -0,0 +1,68 @@
#include "router.hpp"
#include "log.hpp"
#include <chrono>
#include <algorithm>
void Router::add_route(const std::string& method, const std::string& path, Handler handler) {
std::string key = method + " " + path;
routes_[key] = std::move(handler);
}
void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& config) {
auto& req = sess->current_request;
auto& resp = sess->current_response;
// Build the full URL path (without host) for query param access
std::string full_url = req.path;
if (!req.query_string.empty()) {
full_url += "?" + req.query_string;
}
std::string route_key = req.method + " " + req.path;
auto it = routes_.find(route_key);
if (it != routes_.end()) {
RouteContext ctx;
ctx.query_path = req.path;
ctx.full_url = full_url;
ctx.pairing_store = &pairing;
ctx.config = config;
it->second(req, resp, ctx);
return;
}
// 404
resp.send_json(404, {
{"Message", "No HTTP resource was found that matches the request URI '" + full_url + "'."}
});
}
// Init suppression: log only if non-200, slow, or first/different request
// within 30s window. Port of server.js shouldLogInit().
bool Router::should_log_init(const std::string& url, int status, int duration_ms,
const std::string& response) {
// Report suppressed count hourly
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (suppressed_count_ > 0 && (now - suppressed_report_ts_) > 3'600'000) {
logc::info("Suppressed %d init log(s) in the last hour", suppressed_count_);
suppressed_count_ = 0;
suppressed_report_ts_ = now;
}
if (status != 200) return true;
if (duration_ms > 400) return true;
int64_t time_since_last = now - last_init_timestamp_;
if (time_since_last < 30'000 && url == last_init_url_ && response == last_init_result_) {
last_init_timestamp_ = now;
suppressed_count_++;
return false;
}
last_init_url_ = url;
last_init_result_ = response;
last_init_timestamp_ = now;
return true;
}

35
jtlsrv-cpp/src/router.hpp Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
// Router: maps "METHOD /path" -> handler. Port of src/jtl-server.js.
#include "http.hpp"
#include "pairing.hpp"
#include "tls_server.hpp"
#include <functional>
#include <string>
#include <unordered_map>
class Router {
public:
using Handler = RouteHandler;
void add_route(const std::string& method, const std::string& path, Handler handler);
// Dispatch a request. Called from the TLS on_request callback.
void dispatch(tls_session* sess, PairingStore& pairing, const json& config);
// Suppress init logs for repeated identical requests within 30s
bool should_log_init(const std::string& url, int status, int duration_ms,
const std::string& response);
private:
std::unordered_map<std::string, Handler> routes_;
// Init suppression state
std::string last_init_url_;
std::string last_init_result_;
int64_t last_init_timestamp_ = 0;
int suppressed_count_ = 0;
int64_t suppressed_report_ts_ = 0;
};

View File

@@ -0,0 +1,383 @@
// tls_server.cpp — OpenSSL memory-BIOs pumped over uv_tcp_t + llhttp.
//
// This is the genuinely new piece compared to the Node.js original (~300 lines).
// Pattern: accept TCP -> SSL_new with mem BIOs -> uv_read_start feeds encrypted
// bytes into rbio -> SSL_read drains plaintext into llhttp -> SSL_write puts
// response plaintext into wbio -> flush wbio to socket.
#include "tls_server.hpp"
#include "log.hpp"
#include <cstring>
// ---------------------------------------------------------------------------
// Globals
// ---------------------------------------------------------------------------
static uv_loop_t* g_loop = nullptr;
static SSL_CTX* g_ssl_ctx = nullptr;
static uv_tcp_t g_server{};
static uv_signal_t g_sigint{};
static uv_signal_t g_sigterm{};
static void (*g_on_request)(tls_session*) = nullptr;
// ---------------------------------------------------------------------------
// Forward declarations for signal shutdown
// ---------------------------------------------------------------------------
static void tls_server_shutdown();
// ---------------------------------------------------------------------------
// SSL error logging helper
// ---------------------------------------------------------------------------
static void ssl_log_errors(const char* ctx) {
unsigned long e;
while ((e = ERR_get_error()) != 0) {
char buf[256];
ERR_error_string_n(e, buf, sizeof(buf));
logc::warn("[%s] SSL: %s", ctx, buf);
}
}
// ---------------------------------------------------------------------------
// Encrypted data flush: wbio -> socket
// ---------------------------------------------------------------------------
static void flush_encrypted(tls_session* sess) {
char buf[16384];
for (;;) {
int n = BIO_read(sess->wbio, buf, sizeof(buf));
if (n <= 0) break;
auto* req = new uv_write_t{};
char* data = new char[n];
std::memcpy(data, buf, n);
uv_buf_t wbuf = uv_buf_init(data, n);
req->data = data;
uv_write(req, reinterpret_cast<uv_stream_t*>(&sess->tcp_handle),
&wbuf, 1, [](uv_write_t* r, int) {
delete[] static_cast<char*>(r->data);
delete r;
});
}
}
// ---------------------------------------------------------------------------
// Drain SSL plaintext -> llhttp
// ---------------------------------------------------------------------------
static void drain_ssl_to_llhttp(tls_session* sess) {
char buf[16384];
for (;;) {
int n = SSL_read(sess->ssl, buf, sizeof(buf));
if (n <= 0) break;
llhttp_errno_t err = llhttp_execute(&sess->parser, buf, n);
if (err != HPE_OK && err != HPE_PAUSED) {
logc::warn("llhttp: %s", llhttp_errno_name(err));
session_close(sess);
return;
}
}
}
// ---------------------------------------------------------------------------
// llhttp callbacks (store parsed data into the tls_session's HttpRequest)
// ---------------------------------------------------------------------------
static int cb_begin(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request = HttpRequest{};
return 0;
}
static int cb_method(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request.method.assign(at, len);
return 0;
}
static int cb_url(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
std::string raw(at, len);
size_t q = raw.find('?');
if (q != std::string::npos) {
s->current_request.query_string = raw.substr(q + 1);
s->current_request.path = raw.substr(0, q);
} else {
s->current_request.path = raw;
}
return 0;
}
static int cb_header_field(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_header_field.assign(at, len);
return 0;
}
static int cb_header_value(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request.headers[s->current_header_field].assign(at, len);
return 0;
}
static int cb_headers_complete(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
auto it = s->current_request.headers.find("Content-Length");
if (it != s->current_request.headers.end()) {
s->body_length = static_cast<uint32_t>(std::stoul(it->second));
}
return 0;
}
static int cb_body(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
auto& body = s->current_request.body;
body.insert(body.end(),
reinterpret_cast<const uint8_t*>(at),
reinterpret_cast<const uint8_t*>(at + len));
return 0;
}
static int cb_message_complete(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
s->current_response = HttpResponse{};
s->current_response.session = s;
// Normalize /api/v1 -> /v1
s->current_request.path = normalize_path(s->current_request.path);
// Reset idle timer
uv_timer_stop(&s->timer_handle);
uv_timer_start(&s->timer_handle,
[](uv_timer_t* h) {
session_close(static_cast<tls_session*>(h->data));
}, 300'000, 0);
if (g_on_request) g_on_request(s);
return 0;
}
static void init_parser(tls_session* s) {
llhttp_settings_init(&s->parser_settings);
s->parser_settings.on_message_begin = cb_begin;
s->parser_settings.on_method = cb_method;
s->parser_settings.on_url = cb_url;
s->parser_settings.on_header_field = cb_header_field;
s->parser_settings.on_header_value = cb_header_value;
s->parser_settings.on_headers_complete = cb_headers_complete;
s->parser_settings.on_body = cb_body;
s->parser_settings.on_message_complete = cb_message_complete;
llhttp_init(&s->parser, HTTP_REQUEST, &s->parser_settings);
s->parser.data = s;
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
static void on_alloc(uv_handle_t*, size_t suggested, uv_buf_t* buf) {
buf->base = new char[suggested];
buf->len = suggested;
}
static void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
auto* sess = static_cast<tls_session*>(stream->data);
if (nread > 0 && sess->ssl) {
// Feed encrypted bytes into the read BIO
BIO_write(sess->rbio, buf->base, nread);
// TLS handshake (may need multiple rounds)
if (!sess->handshake_done) {
int ret = SSL_do_handshake(sess->ssl);
if (ret == 1) {
sess->handshake_done = true;
logc::info("TLS handshake complete");
flush_encrypted(sess);
} else {
int err = SSL_get_error(sess->ssl, ret);
if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) {
flush_encrypted(sess);
delete[] buf->base;
return;
}
ssl_log_errors("handshake");
delete[] buf->base;
session_close(sess);
return;
}
}
// Read plaintext and pump through llhttp
drain_ssl_to_llhttp(sess);
}
if (nread < 0 && nread != UV_EOF) {
logc::info("client disconnected: %s", uv_strerror(static_cast<int>(nread)));
}
if (nread < 0) {
delete[] buf->base;
session_close(sess);
return;
}
delete[] buf->base;
}
static void on_close(uv_handle_t* handle) {
auto* sess = static_cast<tls_session*>(handle->data);
if (sess->ssl) { SSL_free(sess->ssl); sess->ssl = nullptr; }
delete sess;
}
// ---------------------------------------------------------------------------
// New connection
// ---------------------------------------------------------------------------
static void on_connection(uv_stream_t* server, int status) {
if (status < 0) {
logc::error("accept: %s", uv_strerror(status));
return;
}
auto* sess = new tls_session{};
sess->tcp_handle.data = sess;
uv_tcp_init(g_loop, &sess->tcp_handle);
if (uv_accept(server, reinterpret_cast<uv_stream_t*>(&sess->tcp_handle)) != 0) {
uv_close(reinterpret_cast<uv_handle_t*>(&sess->tcp_handle), on_close);
return;
}
uv_tcp_nodelay(&sess->tcp_handle, 1);
// OpenSSL
sess->ssl = SSL_new(g_ssl_ctx);
sess->rbio = BIO_new(BIO_s_mem());
sess->wbio = BIO_new(BIO_s_mem());
SSL_set_bio(sess->ssl, sess->rbio, sess->wbio);
SSL_set_accept_state(sess->ssl);
// llhttp
init_parser(sess);
// Peer address
struct sockaddr_storage saddr;
int slen = sizeof(saddr);
uv_tcp_getpeername(&sess->tcp_handle, reinterpret_cast<struct sockaddr*>(&saddr), &slen);
char addr_buf[INET6_ADDRSTRLEN] = {};
if (saddr.ss_family == AF_INET)
uv_ip4_name(reinterpret_cast<struct sockaddr_in*>(&saddr), addr_buf, sizeof(addr_buf));
else
uv_ip6_name(reinterpret_cast<struct sockaddr_in6*>(&saddr), addr_buf, sizeof(addr_buf));
logc::info("TLS connection from %s", addr_buf);
// Read encrypted data
uv_read_start(reinterpret_cast<uv_stream_t*>(&sess->tcp_handle), on_alloc, on_read);
// Idle timer
uv_timer_init(g_loop, &sess->timer_handle);
sess->timer_handle.data = sess;
uv_timer_start(&sess->timer_handle,
[](uv_timer_t* h) {
logc::info("idle timeout");
session_close(static_cast<tls_session*>(h->data));
}, 300'000, 0);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void tls_server_set_handler(void (*handler)(tls_session*)) {
g_on_request = handler;
}
int tls_server_init(uv_loop_t* loop, const char* host, int port,
const char* cert_path, const char* key_path) {
g_loop = loop;
g_ssl_ctx = SSL_CTX_new(TLS_server_method());
if (!g_ssl_ctx) { ssl_log_errors("SSL_CTX_new"); return -1; }
SSL_CTX_set_min_proto_version(g_ssl_ctx, TLS1_2_VERSION);
if (SSL_CTX_use_certificate_chain_file(g_ssl_ctx, cert_path) != 1) {
ssl_log_errors("cert"); return -1;
}
if (SSL_CTX_use_PrivateKey_file(g_ssl_ctx, key_path, SSL_FILETYPE_PEM) != 1) {
ssl_log_errors("key"); return -1;
}
if (SSL_CTX_check_private_key(g_ssl_ctx) != 1) {
ssl_log_errors("check_key"); return -1;
}
uv_tcp_init(loop, &g_server);
g_server.data = nullptr;
struct sockaddr_in addr;
uv_ip4_addr(host, port, &addr);
int r = uv_tcp_bind(&g_server, reinterpret_cast<struct sockaddr*>(&addr), 0);
if (r) { logc::error("bind: %s", uv_strerror(r)); return r; }
r = uv_listen(reinterpret_cast<uv_stream_t*>(&g_server), 128, on_connection);
if (r) { logc::error("listen: %s", uv_strerror(r)); return r; }
logc::success("HTTPS server listening on %s:%d", host, port);
return 0;
}
static void tls_server_shutdown() {
logc::info("shutting down...");
uv_signal_stop(&g_sigint);
uv_signal_stop(&g_sigterm);
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigint), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigterm), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_server), nullptr);
}
void tls_server_install_signals(uv_loop_t* loop) {
uv_signal_init(loop, &g_sigint);
uv_signal_start(&g_sigint, [](uv_signal_t*, int) { tls_server_shutdown(); }, SIGINT);
uv_signal_init(loop, &g_sigterm);
uv_signal_start(&g_sigterm, [](uv_signal_t*, int) { tls_server_shutdown(); }, SIGTERM);
}
void session_write(tls_session* sess, const std::string& data) {
if (!sess->ssl || sess->tcp_handle.type != UV_TCP) return;
SSL_write(sess->ssl, data.data(), data.size());
flush_encrypted(sess);
}
void session_write_binary(tls_session* sess, const std::string& header,
const std::vector<uint8_t>& data) {
if (!sess->ssl || sess->tcp_handle.type != UV_TCP) return;
SSL_write(sess->ssl, header.data(), header.size());
if (!data.empty())
SSL_write(sess->ssl, data.data(), data.size());
flush_encrypted(sess);
}
void session_close(tls_session* sess) {
if (sess->tcp_handle.type != UV_TCP) return;
uv_timer_stop(&sess->timer_handle);
uv_close(reinterpret_cast<uv_handle_t*>(&sess->timer_handle), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&sess->tcp_handle), on_close);
}
const char* reason_phrase(int code) {
switch (code) {
case 200: return "OK";
case 400: return "Bad Request";
case 404: return "Not Found";
case 500: return "Internal Server Error";
default: return "Unknown";
}
}

View File

@@ -0,0 +1,66 @@
#pragma once
// TLS server: OpenSSL memory-BIOs pumped over uv_tcp_t + llhttp for HTTP parsing.
// This is the genuinely new piece compared to the Node.js original.
#include <cstdint>
#include <string>
#include <vector>
#include <uv.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <llhttp.h>
#include "http.hpp"
// ---------------------------------------------------------------------------
// Per-connection TLS session
// ---------------------------------------------------------------------------
struct tls_session {
uv_tcp_t tcp_handle{};
uv_timer_t timer_handle{};
SSL* ssl = nullptr;
BIO* rbio = nullptr; // we feed encrypted data here
BIO* wbio = nullptr; // we read encrypted data from here
llhttp_t parser{};
llhttp_settings_t parser_settings{};
HttpRequest current_request{};
HttpResponse current_response{};
std::string current_header_field{}; // tracks header name during parsing
bool handshake_done = false;
uint32_t body_length = 0;
};
// ---------------------------------------------------------------------------
// Server API
// ---------------------------------------------------------------------------
// Initialize the OpenSSL context and start listening.
// Returns 0 on success.
int tls_server_init(uv_loop_t* loop, const char* host, int port,
const char* cert_path, const char* key_path);
// Set the callback invoked for each fully-parsed HTTP request.
void tls_server_set_handler(void (*handler)(tls_session*));
// Write an encrypted response buffer to the client.
void session_write(tls_session* sess, const std::string& data);
// Write an encrypted response with binary payload to the client.
void session_write_binary(tls_session* sess, const std::string& header,
const std::vector<uint8_t>& data);
// Get the reason phrase for an HTTP status code.
const char* reason_phrase(int code);
// Close a TLS session cleanly.
void session_close(tls_session* sess);
// Install SIGINT/SIGTERM handlers for graceful shutdown.
void tls_server_install_signals(uv_loop_t* loop);