Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
cmake_minimum_required(VERSION 3.25)
project(hookla CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# Enable TLS in cpp-httplib
add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT)

include(cmake/deps.cmake)

# ---- Source files ----------------------------------------------------------
set(SOURCES
src/main.cpp
src/controllers/WebhookController.cpp
src/services/DiscordMessageService.cpp
src/services/DiscordWebhookService.cpp
src/services/EmbedOptionsService.cpp
src/services/ProviderSettingsService.cpp
src/handlers/github/GithubHandler.cpp
src/handlers/gitlab/GitlabHandler.cpp
src/handlers/sonarr/SonarrHandler.cpp
)

add_executable(hookla ${SOURCES})

target_include_directories(hookla PRIVATE
src
${PQXX_INCLUDE_DIRS}
)

target_link_libraries(hookla PRIVATE
Drogon::Drogon
nlohmann_json::nlohmann_json
spdlog::spdlog
httplib::httplib
OpenSSL::SSL
OpenSSL::Crypto
${PQXX_LIBRARIES}
PostgreSQL::PostgreSQL
)

# Drogon needs coroutine support on Clang
if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
target_compile_options(hookla PRIVATE -fcoroutines)
endif()

# ---- Install ---------------------------------------------------------------
install(TARGETS hookla DESTINATION bin)
38 changes: 38 additions & 0 deletions cpp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ---- Build stage -----------------------------------------------------------
FROM debian:bookworm-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
cmake ninja-build clang-17 \
libssl-dev \
libpq-dev libpqxx-dev \
pkg-config git ca-certificates \
&& rm -rf /var/lib/apt/lists/*

ENV CC=clang-17 CXX=clang++-17

WORKDIR /src
COPY . .

RUN cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_COMPILER=clang++-17 \
&& cmake --build build --parallel

# ---- Runtime stage ---------------------------------------------------------
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 libpq5 libpqxx-7.7 ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /src/build/hookla /usr/local/bin/hookla

ENV APP_PORT=8080 \
DB_HOST=postgres \
DB_PORT=5432 \
DB_NAME=hookla \
DB_USER=hookla \
DB_SSLMODE=disable

EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/hookla"]
49 changes: 49 additions & 0 deletions cpp/cmake/deps.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
include(FetchContent)

# ---- Drogon (HTTP server + C++20 coroutines) ------------------------------
FetchContent_Declare(drogon
GIT_REPOSITORY https://github.com/drogonframework/drogon
GIT_TAG v1.9.6
GIT_SHALLOW TRUE)

# ---- nlohmann/json (JSON parsing + serialisation) -------------------------
FetchContent_Declare(nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json
GIT_TAG v3.11.3
GIT_SHALLOW TRUE)

# ---- spdlog (structured logging) ------------------------------------------
FetchContent_Declare(spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog
GIT_TAG v1.14.1
GIT_SHALLOW TRUE)

# ---- cpp-httplib (outbound HTTPS to Discord) -------------------------------
FetchContent_Declare(httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib
GIT_TAG v0.16.3
GIT_SHALLOW TRUE)

# libpqxx and OpenSSL are expected to be installed system-wide:
# apt-get install -y libpq-dev libpqxx-dev libssl-dev
# brew install libpqxx openssl
find_package(PostgreSQL REQUIRED)
find_package(OpenSSL REQUIRED)

# Build libpqxx from system pkg-config if available, else find manually
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_check_modules(PQXX libpqxx)
endif()
if(NOT PQXX_FOUND)
find_library(PQXX_LIB pqxx REQUIRED)
find_path(PQXX_INCLUDE pqxx/pqxx REQUIRED)
set(PQXX_LIBRARIES ${PQXX_LIB})
set(PQXX_INCLUDE_DIRS ${PQXX_INCLUDE})
endif()

FetchContent_MakeAvailable(nlohmann_json spdlog httplib)

# Drogon brings many transitive deps; build it last
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(drogon)
97 changes: 97 additions & 0 deletions cpp/src/controllers/WebhookController.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#include "WebhookController.h"
#include <algorithm>
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>

WebhookController::WebhookController(
std::shared_ptr<ProviderSettingsService> providerSettings,
std::shared_ptr<DiscordWebhookService> discordWebhooks,
std::shared_ptr<MainHandler> mainHandler)
: providerSettings_(std::move(providerSettings))
, discordWebhooks_(std::move(discordWebhooks))
, mainHandler_(std::move(mainHandler)) {}

// GET /process/:token — returns raw provider settings as JSON (debug endpoint)
drogon::Task<drogon::HttpResponsePtr> WebhookController::getHookInfo(
drogon::HttpRequestPtr req,
std::string token)
{
auto settings = providerSettings_->getByToken(token);
if (!settings) {
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k401Unauthorized);
resp->setBody("invalid token");
co_return resp;
}

nlohmann::json j;
j["id"] = settings->id;
j["slug"] = settings->slug;
j["discordWebhookId"] = settings->discordWebhookId;
if (settings->optionsId) j["optionsId"] = *settings->optionsId;

auto resp = drogon::HttpResponse::newHttpResponse();
resp->setContentTypeCode(drogon::CT_APPLICATION_JSON);
resp->setBody(j.dump());
co_return resp;
}

// POST /process/:token — receive and forward a webhook
drogon::Task<drogon::HttpResponsePtr> WebhookController::process(
drogon::HttpRequestPtr req,
std::string token)
{
// Normalise headers to lowercase (mirrors the Scala controller)
std::unordered_map<std::string, std::string> headers;
for (auto& [k, v] : req->getHeaders()) {
std::string lk = k;
std::transform(lk.begin(), lk.end(), lk.begin(), ::tolower);
headers[lk] = v;
}

auto settings = providerSettings_->getByToken(token);
if (!settings) {
spdlog::warn("Invalid token: {}", token);
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k401Unauthorized);
resp->setBody("invalid token");
co_return resp;
}

spdlog::debug("Received webhook for provider: {}", settings->slug);

nlohmann::json body = nlohmann::json::parse(req->getBody(), nullptr, false);
if (body.is_discarded()) {
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k400BadRequest);
resp->setBody("invalid JSON body");
co_return resp;
}

auto hook = discordWebhooks_->getById(settings->discordWebhookId);
if (!hook) {
spdlog::error("Discord webhook not found for id: {}", settings->discordWebhookId);
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k500InternalServerError);
resp->setBody("discord webhook not configured");
co_return resp;
}

auto options = providerSettings_->getOptionsForProvider(*settings);
EventData eventData{*hook, options};

try {
mainHandler_->handle(settings->slug, body, headers, eventData);
} catch (const std::exception& e) {
spdlog::error("Handler error for {}: {}", settings->slug, e.what());
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k400BadRequest);
resp->setBody(e.what());
co_return resp;
}

auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k200OK);
resp->setBody("success");
co_return resp;
}
33 changes: 33 additions & 0 deletions cpp/src/controllers/WebhookController.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#pragma once
#include <drogon/HttpController.h>
#include <memory>
#include "../handlers/MainHandler.h"
#include "../services/DiscordWebhookService.h"
#include "../services/ProviderSettingsService.h"

// Registered manually (false = not auto-created by Drogon) so we can inject deps.
class WebhookController : public drogon::HttpController<WebhookController, false> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(WebhookController::process, "/process/{token}", drogon::Post);
ADD_METHOD_TO(WebhookController::getHookInfo, "/process/{token}", drogon::Get);
METHOD_LIST_END

WebhookController(
std::shared_ptr<ProviderSettingsService> providerSettings,
std::shared_ptr<DiscordWebhookService> discordWebhooks,
std::shared_ptr<MainHandler> mainHandler);

drogon::Task<drogon::HttpResponsePtr> process(
drogon::HttpRequestPtr req,
std::string token);

drogon::Task<drogon::HttpResponsePtr> getHookInfo(
drogon::HttpRequestPtr req,
std::string token);

private:
std::shared_ptr<ProviderSettingsService> providerSettings_;
std::shared_ptr<DiscordWebhookService> discordWebhooks_;
std::shared_ptr<MainHandler> mainHandler_;
};
37 changes: 37 additions & 0 deletions cpp/src/db/ConnectionPool.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#pragma once
#include <condition_variable>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
#include <pqxx/pqxx>

// Thread-safe PostgreSQL connection pool.
// Callers acquire() a connection, use it synchronously, then release() it.
// DB methods run on a dedicated std::thread to avoid blocking the HTTP event loop.
class ConnectionPool {
public:
ConnectionPool(const std::string& connStr, size_t poolSize) {
for (size_t i = 0; i < poolSize; ++i)
pool_.push(std::make_unique<pqxx::connection>(connStr));
}

std::unique_ptr<pqxx::connection> acquire() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this] { return !pool_.empty(); });
auto conn = std::move(pool_.front());
pool_.pop();
return conn;
}

void release(std::unique_ptr<pqxx::connection> conn) {
std::lock_guard lock(mutex_);
pool_.push(std::move(conn));
cv_.notify_one();
}

private:
std::queue<std::unique_ptr<pqxx::connection>> pool_;
std::mutex mutex_;
std::condition_variable cv_;
};
22 changes: 22 additions & 0 deletions cpp/src/handlers/BaseHandler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once
#include <string>
#include <unordered_map>
#include <nlohmann/json.hpp>
#include "../types/EventData.h"

class BaseHandler {
public:
virtual ~BaseHandler() = default;

// The HTTP header (or body field) that contains the event name.
virtual const char* eventKey() const = 0;

// True if the event key lives inside the JSON body rather than an HTTP header.
virtual bool isBody() const { return false; }

// Dispatch to the appropriate event handler.
virtual void handle(
const std::string& eventName,
const nlohmann::json& body,
const EventData& data) = 0;
};
48 changes: 48 additions & 0 deletions cpp/src/handlers/MainHandler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#pragma once
#include <algorithm>
#include <memory>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include "BaseHandler.h"
#include "../types/EventData.h"

class MainHandler {
public:
void registerHandler(const std::string& slug, std::shared_ptr<BaseHandler> handler) {
handlers_[slug] = std::move(handler);
}

void handle(
const std::string& slug,
const nlohmann::json& body,
const std::unordered_map<std::string, std::string>& headers,
const EventData& data)
{
auto it = handlers_.find(slug);
if (it == handlers_.end()) {
spdlog::warn("Unhandled provider slug: {}", slug);
return;
}
auto& handler = *it->second;

std::string eventName;
if (handler.isBody()) {
eventName = body.at(handler.eventKey()).get<std::string>();
} else {
std::string key = handler.eventKey();
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
auto hit = headers.find(key);
if (hit == headers.end())
throw std::runtime_error("event header not found: " + key);
eventName = hit->second;
}

handler.handle(eventName, body, data);
}

private:
std::unordered_map<std::string, std::shared_ptr<BaseHandler>> handlers_;
};
Loading
Loading