cpp
This commit is contained in:
64
jtlsrv-cpp/src/config.hpp
Normal file
64
jtlsrv-cpp/src/config.hpp
Normal 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
276
jtlsrv-cpp/src/db/pool.cpp
Normal 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;
|
||||
}
|
||||
55
jtlsrv-cpp/src/db/pool.hpp
Normal file
55
jtlsrv-cpp/src/db/pool.hpp
Normal 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();
|
||||
11
jtlsrv-cpp/src/endpoints/category.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/category.cpp
Normal 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);
|
||||
}
|
||||
22
jtlsrv-cpp/src/endpoints/cimage.cpp
Normal file
22
jtlsrv-cpp/src/endpoints/cimage.cpp
Normal 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);
|
||||
}
|
||||
61
jtlsrv-cpp/src/endpoints/client.cpp
Normal file
61
jtlsrv-cpp/src/endpoints/client.cpp
Normal 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."}
|
||||
});
|
||||
}
|
||||
10
jtlsrv-cpp/src/endpoints/customergroup.cpp
Normal file
10
jtlsrv-cpp/src/endpoints/customergroup.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/deleted_entity.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/deleted_entity.cpp
Normal 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);
|
||||
}
|
||||
41
jtlsrv-cpp/src/endpoints/init.cpp
Normal file
41
jtlsrv-cpp/src/endpoints/init.cpp
Normal 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"}
|
||||
});
|
||||
}
|
||||
28
jtlsrv-cpp/src/endpoints/order.cpp
Normal file
28
jtlsrv-cpp/src/endpoints/order.cpp
Normal 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);
|
||||
}
|
||||
22
jtlsrv-cpp/src/endpoints/pimage.cpp
Normal file
22
jtlsrv-cpp/src/endpoints/pimage.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/product.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/product.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/productcomposite.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/productcomposite.cpp
Normal 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
121
jtlsrv-cpp/src/http.cpp
Normal 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
76
jtlsrv-cpp/src/http.hpp
Normal 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
45
jtlsrv-cpp/src/log.cpp
Normal 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
35
jtlsrv-cpp/src/log.hpp
Normal 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
173
jtlsrv-cpp/src/main.cpp
Normal 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;
|
||||
}
|
||||
44
jtlsrv-cpp/src/order_log.cpp
Normal file
44
jtlsrv-cpp/src/order_log.cpp
Normal 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_);
|
||||
}
|
||||
17
jtlsrv-cpp/src/order_log.hpp
Normal file
17
jtlsrv-cpp/src/order_log.hpp
Normal 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;
|
||||
};
|
||||
27
jtlsrv-cpp/src/pairing.cpp
Normal file
27
jtlsrv-cpp/src/pairing.cpp
Normal 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_;
|
||||
}
|
||||
33
jtlsrv-cpp/src/pairing.hpp
Normal file
33
jtlsrv-cpp/src/pairing.hpp
Normal 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_;
|
||||
};
|
||||
59
jtlsrv-cpp/src/queries/category_list.hpp
Normal file
59
jtlsrv-cpp/src/queries/category_list.hpp
Normal 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;
|
||||
}
|
||||
40
jtlsrv-cpp/src/queries/composite_product_list.hpp
Normal file
40
jtlsrv-cpp/src/queries/composite_product_list.hpp
Normal 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;
|
||||
}
|
||||
65
jtlsrv-cpp/src/queries/counts.hpp
Normal file
65
jtlsrv-cpp/src/queries/counts.hpp
Normal 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}});
|
||||
}
|
||||
48
jtlsrv-cpp/src/queries/customer_groups.hpp
Normal file
48
jtlsrv-cpp/src/queries/customer_groups.hpp
Normal 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});
|
||||
}
|
||||
28
jtlsrv-cpp/src/queries/deleted_entity_list.hpp
Normal file
28
jtlsrv-cpp/src/queries/deleted_entity_list.hpp
Normal 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;
|
||||
}
|
||||
132
jtlsrv-cpp/src/queries/image.hpp
Normal file
132
jtlsrv-cpp/src/queries/image.hpp
Normal 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 {};
|
||||
}
|
||||
167
jtlsrv-cpp/src/queries/product_list.hpp
Normal file
167
jtlsrv-cpp/src/queries/product_list.hpp
Normal 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;
|
||||
}
|
||||
21
jtlsrv-cpp/src/queries/shop.hpp
Normal file
21
jtlsrv-cpp/src/queries/shop.hpp
Normal 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;
|
||||
}
|
||||
40
jtlsrv-cpp/src/request_log.cpp
Normal file
40
jtlsrv-cpp/src/request_log.cpp
Normal 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_);
|
||||
}
|
||||
16
jtlsrv-cpp/src/request_log.hpp
Normal file
16
jtlsrv-cpp/src/request_log.hpp
Normal 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
68
jtlsrv-cpp/src/router.cpp
Normal 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
35
jtlsrv-cpp/src/router.hpp
Normal 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;
|
||||
};
|
||||
383
jtlsrv-cpp/src/tls_server.cpp
Normal file
383
jtlsrv-cpp/src/tls_server.cpp
Normal 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";
|
||||
}
|
||||
}
|
||||
66
jtlsrv-cpp/src/tls_server.hpp
Normal file
66
jtlsrv-cpp/src/tls_server.hpp
Normal 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);
|
||||
Reference in New Issue
Block a user