Replace master branch with local files
This commit is contained in:
commit
875a53f499
60 changed files with 21637 additions and 0 deletions
37
backend/.dockerignore
Normal file
37
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Build artifacts
|
||||
build/
|
||||
cmake-build-*/
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
|
||||
# Conan files
|
||||
conanfile.txt
|
||||
conanfile.py
|
||||
conan.lock
|
||||
conanbuildinfo.*
|
||||
conaninfo.txt
|
||||
CMakeUserPresets.json
|
||||
.conan/
|
||||
.conan2/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Compiled objects
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Executables
|
||||
streaming-backend
|
||||
107
backend/CMakeLists.txt
Normal file
107
backend/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
cmake_minimum_required(VERSION 3.20)
|
||||
project(streaming-backend)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# Use pkg-config to find libraries
|
||||
find_package(PkgConfig REQUIRED)
|
||||
|
||||
# Find dependencies
|
||||
find_package(Drogon CONFIG REQUIRED)
|
||||
find_package(PostgreSQL REQUIRED)
|
||||
|
||||
# Find Redis dependencies
|
||||
pkg_check_modules(HIREDIS REQUIRED hiredis)
|
||||
pkg_check_modules(REDIS_PLUS_PLUS redis++)
|
||||
|
||||
# Manual fallback for redis++
|
||||
if(NOT REDIS_PLUS_PLUS_FOUND)
|
||||
find_path(REDIS_PLUS_PLUS_INCLUDE_DIR sw/redis++/redis++.h
|
||||
PATHS /usr/local/include /usr/include
|
||||
)
|
||||
find_library(REDIS_PLUS_PLUS_LIBRARY redis++
|
||||
PATHS /usr/local/lib /usr/lib /usr/lib/x86_64-linux-gnu
|
||||
)
|
||||
|
||||
if(REDIS_PLUS_PLUS_INCLUDE_DIR AND REDIS_PLUS_PLUS_LIBRARY)
|
||||
set(REDIS_PLUS_PLUS_FOUND TRUE)
|
||||
set(REDIS_PLUS_PLUS_INCLUDE_DIRS ${REDIS_PLUS_PLUS_INCLUDE_DIR})
|
||||
set(REDIS_PLUS_PLUS_LIBRARIES ${REDIS_PLUS_PLUS_LIBRARY})
|
||||
else()
|
||||
message(FATAL_ERROR "redis++ not found")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Find bcrypt library
|
||||
find_path(BCRYPT_INCLUDE_DIR bcrypt/BCrypt.hpp
|
||||
PATHS /usr/local/include /usr/include
|
||||
)
|
||||
find_library(BCRYPT_LIBRARY bcrypt
|
||||
PATHS /usr/local/lib /usr/lib /usr/lib/x86_64-linux-gnu
|
||||
)
|
||||
|
||||
if(NOT BCRYPT_INCLUDE_DIR OR NOT BCRYPT_LIBRARY)
|
||||
message(FATAL_ERROR "bcrypt not found")
|
||||
endif()
|
||||
|
||||
# Find jwt-cpp (header-only)
|
||||
find_path(JWT_CPP_INCLUDE_DIR jwt-cpp/jwt.h
|
||||
PATHS /usr/local/include /usr/include
|
||||
)
|
||||
|
||||
if(NOT JWT_CPP_INCLUDE_DIR)
|
||||
message(FATAL_ERROR "jwt-cpp not found")
|
||||
endif()
|
||||
|
||||
# Source files
|
||||
set(SOURCES
|
||||
src/main.cpp
|
||||
src/controllers/StreamController.cpp
|
||||
src/controllers/UserController.cpp
|
||||
src/controllers/AdminController.cpp
|
||||
src/controllers/RealmController.cpp
|
||||
src/services/DatabaseService.cpp
|
||||
src/services/StatsService.cpp
|
||||
src/services/RedisHelper.cpp
|
||||
src/services/AuthService.cpp
|
||||
)
|
||||
|
||||
# Create executable
|
||||
add_executable(${PROJECT_NAME} ${SOURCES})
|
||||
|
||||
# Include directories
|
||||
target_include_directories(${PROJECT_NAME}
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${BCRYPT_INCLUDE_DIR}
|
||||
${JWT_CPP_INCLUDE_DIR}
|
||||
SYSTEM PRIVATE
|
||||
${HIREDIS_INCLUDE_DIRS}
|
||||
${REDIS_PLUS_PLUS_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
# Link libraries
|
||||
target_link_libraries(${PROJECT_NAME}
|
||||
PRIVATE
|
||||
Drogon::Drogon
|
||||
PostgreSQL::PostgreSQL
|
||||
${REDIS_PLUS_PLUS_LIBRARIES}
|
||||
${HIREDIS_LIBRARIES}
|
||||
${BCRYPT_LIBRARY}
|
||||
pthread
|
||||
)
|
||||
|
||||
# Compile options
|
||||
target_compile_options(${PROJECT_NAME}
|
||||
PRIVATE
|
||||
${HIREDIS_CFLAGS_OTHER}
|
||||
${REDIS_PLUS_PLUS_CFLAGS_OTHER}
|
||||
-Wall
|
||||
-Wextra
|
||||
-Wpedantic
|
||||
-Wno-pedantic # Suppress pedantic warnings from third-party headers
|
||||
)
|
||||
# Build admin tool
|
||||
add_executable(admin-tool src/admin_tool.cpp)
|
||||
target_link_libraries(admin-tool PRIVATE Drogon::Drogon PostgreSQL::PostgreSQL)
|
||||
107
backend/Dockerfile
Normal file
107
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
FROM drogonframework/drogon:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install additional dependencies including redis-plus-plus dev package if available
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
postgresql-client \
|
||||
pkg-config \
|
||||
git \
|
||||
cmake \
|
||||
libhiredis-dev \
|
||||
curl \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Try to install redis-plus-plus from package manager first
|
||||
RUN apt-get update && \
|
||||
(apt-get install -y libredis++-dev || echo "Package not available") && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# If package not available, build from source
|
||||
RUN if ! pkg-config --exists redis++; then \
|
||||
echo "Building redis-plus-plus from source..." && \
|
||||
git clone --depth 1 https://github.com/sewenew/redis-plus-plus.git && \
|
||||
cd redis-plus-plus && \
|
||||
mkdir build && \
|
||||
cd build && \
|
||||
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DREDIS_PLUS_PLUS_CXX_STANDARD=17 \
|
||||
-DREDIS_PLUS_PLUS_BUILD_TEST=OFF \
|
||||
-DREDIS_PLUS_PLUS_BUILD_STATIC=OFF \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr/local .. && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
cd ../.. && \
|
||||
rm -rf redis-plus-plus; \
|
||||
fi
|
||||
|
||||
# Install bcrypt library
|
||||
RUN git clone --depth 1 https://github.com/trusch/libbcrypt.git && \
|
||||
cd libbcrypt && \
|
||||
mkdir build && \
|
||||
cd build && \
|
||||
cmake .. && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
cd ../.. && \
|
||||
rm -rf libbcrypt
|
||||
|
||||
# Install jwt-cpp (header-only)
|
||||
RUN git clone --depth 1 https://github.com/Thalhammer/jwt-cpp.git && \
|
||||
cd jwt-cpp && \
|
||||
mkdir build && \
|
||||
cd build && \
|
||||
cmake .. && \
|
||||
make install && \
|
||||
cd ../.. && \
|
||||
rm -rf jwt-cpp
|
||||
|
||||
# Update library cache - this is critical!
|
||||
RUN ldconfig
|
||||
|
||||
# Copy source files
|
||||
COPY CMakeLists.txt ./
|
||||
COPY src/ src/
|
||||
|
||||
# Clean any existing build artifacts
|
||||
RUN rm -rf build CMakeCache.txt
|
||||
|
||||
# Create clean build directory
|
||||
RUN mkdir -p build
|
||||
|
||||
# Build the application with RPATH set correctly
|
||||
RUN cd build && \
|
||||
cmake .. -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_RPATH="/usr/local/lib" \
|
||||
-DCMAKE_BUILD_WITH_INSTALL_RPATH=TRUE && \
|
||||
cmake --build . -j$(nproc)
|
||||
|
||||
# Copy config
|
||||
COPY config.json .
|
||||
|
||||
# Create uploads directory with proper permissions
|
||||
# Using nobody user's UID/GID (65534) for consistency with nginx
|
||||
RUN mkdir -p /app/uploads/avatars && \
|
||||
chown -R 65534:65534 /app/uploads && \
|
||||
chmod -R 755 /app/uploads
|
||||
|
||||
# Ensure libraries are available
|
||||
ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
||||
|
||||
# Add a startup script to check dependencies and create directories
|
||||
RUN echo '#!/bin/bash\n\
|
||||
echo "Checking library dependencies..."\n\
|
||||
ldd ./build/streaming-backend\n\
|
||||
echo "Ensuring upload directories exist with proper permissions..."\n\
|
||||
mkdir -p /app/uploads/avatars\n\
|
||||
chown -R 65534:65534 /app/uploads\n\
|
||||
chmod -R 755 /app/uploads\n\
|
||||
echo "Starting application..."\n\
|
||||
exec ./build/streaming-backend' > start.sh && \
|
||||
chmod +x start.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./start.sh"]
|
||||
11
backend/conanfile.txt
Normal file
11
backend/conanfile.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[requires]
|
||||
redis-plus-plus/1.3.13
|
||||
hiredis/1.2.0
|
||||
|
||||
[options]
|
||||
redis-plus-plus/*:shared=True
|
||||
hiredis/*:shared=True
|
||||
|
||||
[generators]
|
||||
CMakeDeps
|
||||
CMakeToolchain
|
||||
43
backend/config.json
Normal file
43
backend/config.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"listeners": [
|
||||
{
|
||||
"address": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"https": false
|
||||
}
|
||||
],
|
||||
"db_clients": [
|
||||
{
|
||||
"name": "default",
|
||||
"rdbms": "postgresql",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"dbname": "streaming",
|
||||
"user": "streamuser",
|
||||
"passwd": "streampass",
|
||||
"is_fast": false,
|
||||
"connection_number": 10
|
||||
}
|
||||
],
|
||||
"app": {
|
||||
"threads_num": 0,
|
||||
"enable_session": true,
|
||||
"session_timeout": 1200,
|
||||
"document_root": "",
|
||||
"upload_path": "./uploads",
|
||||
"client_max_body_size": "100M",
|
||||
"enable_brotli": true,
|
||||
"enable_gzip": true,
|
||||
"log_level": "DEBUG"
|
||||
},
|
||||
"redis": {
|
||||
"host": "redis",
|
||||
"port": 6379
|
||||
},
|
||||
"ome": {
|
||||
"api_url": "http://ovenmediaengine:8081",
|
||||
"api_token": "your-api-token"
|
||||
},
|
||||
"plugins": [],
|
||||
"custom_config": {}
|
||||
}
|
||||
71
backend/src/admin_tool.cpp
Normal file
71
backend/src/admin_tool.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <iostream>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: " << argv[0] << " -promote-admin <username>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string command = argv[1];
|
||||
|
||||
if (command != "-promote-admin" || argc != 3) {
|
||||
std::cerr << "Usage: " << argv[0] << " -promote-admin <username>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string username = argv[2];
|
||||
|
||||
// Get database config from environment or use defaults
|
||||
std::string dbHost = std::getenv("DB_HOST") ? std::getenv("DB_HOST") : "postgres";
|
||||
std::string dbName = std::getenv("DB_NAME") ? std::getenv("DB_NAME") : "streaming";
|
||||
std::string dbUser = std::getenv("DB_USER") ? std::getenv("DB_USER") : "streamuser";
|
||||
std::string dbPass = std::getenv("DB_PASS") ? std::getenv("DB_PASS") : "streampass";
|
||||
|
||||
// Create database client directly
|
||||
auto dbClient = DbClient::newPgClient(
|
||||
"host=" + dbHost + " port=5432 dbname=" + dbName +
|
||||
" user=" + dbUser + " password=" + dbPass,
|
||||
1 // connection number
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if user exists
|
||||
auto result = dbClient->execSqlSync(
|
||||
"SELECT id, username, is_admin FROM users WHERE username = $1",
|
||||
username
|
||||
);
|
||||
|
||||
if (result.empty()) {
|
||||
std::cerr << "Error: User '" << username << "' not found." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool isAdmin = result[0]["is_admin"].as<bool>();
|
||||
if (isAdmin) {
|
||||
std::cout << "User '" << username << "' is already an admin." << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Promote to admin
|
||||
dbClient->execSqlSync(
|
||||
"UPDATE users SET is_admin = true WHERE username = $1",
|
||||
username
|
||||
);
|
||||
|
||||
std::cout << "Successfully promoted '" << username << "' to admin." << std::endl;
|
||||
return 0;
|
||||
|
||||
} catch (const DrogonDbException& e) {
|
||||
std::cerr << "Database error: " << e.base().what() << std::endl;
|
||||
return 1;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
243
backend/src/common/utils.h
Normal file
243
backend/src/common/utils.h
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#pragma once
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <variant>
|
||||
#include <optional>
|
||||
#include <concepts>
|
||||
|
||||
namespace utils {
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
// Result type for better error handling
|
||||
template<typename T>
|
||||
using Result = std::variant<T, std::string>;
|
||||
|
||||
template<typename T>
|
||||
inline bool isOk(const Result<T>& r) { return std::holds_alternative<T>(r); }
|
||||
|
||||
template<typename T>
|
||||
inline T& getValue(Result<T>& r) { return std::get<T>(r); }
|
||||
|
||||
template<typename T>
|
||||
inline const std::string& getError(const Result<T>& r) { return std::get<std::string>(r); }
|
||||
|
||||
// JSON Response helpers
|
||||
inline HttpResponsePtr jsonOk(const Json::Value& data) {
|
||||
return HttpResponse::newHttpJsonResponse(data);
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value json;
|
||||
json["error"] = error;
|
||||
json["success"] = false;
|
||||
auto resp = HttpResponse::newHttpJsonResponse(json);
|
||||
resp->setStatusCode(code);
|
||||
return resp;
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonResp(const Json::Value& data, HttpStatusCode code = k200OK) {
|
||||
auto resp = HttpResponse::newHttpJsonResponse(data);
|
||||
resp->setStatusCode(code);
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Database helper with automatic error handling
|
||||
template<typename... Args>
|
||||
inline void dbQuery(const std::string& query,
|
||||
std::function<void(const drogon::orm::Result&)> onSuccess,
|
||||
std::function<void(const std::string&)> onError,
|
||||
Args&&... args) {
|
||||
auto db = app().getDbClient();
|
||||
(*db << query << std::forward<Args>(args)...)
|
||||
>> [onSuccess](const drogon::orm::Result& r) { onSuccess(r); }
|
||||
>> [onError](const drogon::orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "DB Error: " << e.base().what();
|
||||
onError(e.base().what());
|
||||
};
|
||||
}
|
||||
|
||||
// Simplified DB query that returns JSON response
|
||||
template<typename... Args>
|
||||
inline void dbJsonQuery(const std::string& query,
|
||||
std::function<Json::Value(const drogon::orm::Result&)> transform,
|
||||
std::function<void(const HttpResponsePtr&)> callback,
|
||||
Args&&... args) {
|
||||
dbQuery(query,
|
||||
[transform, callback](const drogon::orm::Result& r) {
|
||||
callback(jsonOk(transform(r)));
|
||||
},
|
||||
[callback](const std::string& error) {
|
||||
callback(jsonError(error, k500InternalServerError));
|
||||
},
|
||||
std::forward<Args>(args)...
|
||||
);
|
||||
}
|
||||
|
||||
// Thread pool executor with type constraints
|
||||
template<typename F>
|
||||
requires std::invocable<F>
|
||||
inline void runAsync(F&& task) {
|
||||
if (auto loop = app().getLoop()) {
|
||||
loop->queueInLoop([task = std::forward<F>(task)]() {
|
||||
std::thread([task]() {
|
||||
try {
|
||||
task();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Async task error: " << e.what();
|
||||
}
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
// Fallback to sync execution
|
||||
task();
|
||||
}
|
||||
}
|
||||
|
||||
// Config helper
|
||||
template<typename T>
|
||||
inline std::optional<T> getConfig(const std::string& path) {
|
||||
try {
|
||||
const auto& config = app().getCustomConfig();
|
||||
std::vector<std::string> parts;
|
||||
std::stringstream ss(path);
|
||||
std::string part;
|
||||
while (std::getline(ss, part, '.')) {
|
||||
parts.push_back(part);
|
||||
}
|
||||
|
||||
Json::Value current = config;
|
||||
for (const auto& p : parts) {
|
||||
if (!current.isMember(p)) return std::nullopt;
|
||||
current = current[p];
|
||||
}
|
||||
|
||||
if constexpr (std::is_same_v<T, std::string>) {
|
||||
return current.asString();
|
||||
} else if constexpr (std::is_same_v<T, int>) {
|
||||
return current.asInt();
|
||||
} else if constexpr (std::is_same_v<T, bool>) {
|
||||
return current.asBool();
|
||||
} else if constexpr (std::is_same_v<T, double>) {
|
||||
return current.asDouble();
|
||||
}
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Environment variable helper with fallback
|
||||
template<typename T = std::string>
|
||||
inline T getEnv(const std::string& key, const T& defaultValue = T{}) {
|
||||
const char* val = std::getenv(key.c_str());
|
||||
if (!val) return defaultValue;
|
||||
|
||||
if constexpr (std::is_same_v<T, std::string>) {
|
||||
return std::string(val);
|
||||
} else if constexpr (std::is_same_v<T, int>) {
|
||||
try { return std::stoi(val); } catch (...) { return defaultValue; }
|
||||
} else if constexpr (std::is_same_v<T, bool>) {
|
||||
std::string s(val);
|
||||
return s == "true" || s == "1" || s == "yes";
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Random string generator
|
||||
inline std::string randomString(size_t length = 32) {
|
||||
auto bytes = drogon::utils::genRandomString(length);
|
||||
return drogon::utils::base64Encode(
|
||||
reinterpret_cast<const unsigned char*>(bytes.data()),
|
||||
bytes.length()
|
||||
);
|
||||
}
|
||||
|
||||
// Timer helper
|
||||
class ScopedTimer {
|
||||
std::string name_;
|
||||
std::chrono::steady_clock::time_point start_;
|
||||
public:
|
||||
explicit ScopedTimer(const std::string& name)
|
||||
: name_(name), start_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
~ScopedTimer() {
|
||||
auto duration = std::chrono::steady_clock::now() - start_;
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
|
||||
LOG_DEBUG << name_ << " took " << ms << "ms";
|
||||
}
|
||||
};
|
||||
|
||||
#define TIMED_SCOPE(name) utils::ScopedTimer _timer(name)
|
||||
|
||||
// WebSocket broadcast helper
|
||||
template<typename Container>
|
||||
inline void wsBroadcast(const Container& connections, const Json::Value& message) {
|
||||
auto msg = Json::FastWriter().write(message);
|
||||
for (const auto& conn : connections) {
|
||||
if (conn->connected()) {
|
||||
conn->send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiter
|
||||
class RateLimiter {
|
||||
std::unordered_map<std::string, std::deque<std::chrono::steady_clock::time_point>> requests_;
|
||||
std::mutex mutex_;
|
||||
size_t maxRequests_;
|
||||
std::chrono::seconds window_;
|
||||
|
||||
public:
|
||||
RateLimiter(size_t maxRequests = 10, std::chrono::seconds window = std::chrono::seconds(60))
|
||||
: maxRequests_(maxRequests), window_(window) {}
|
||||
|
||||
bool allow(const std::string& key) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto& timestamps = requests_[key];
|
||||
|
||||
// Remove old timestamps
|
||||
while (!timestamps.empty() && now - timestamps.front() > window_) {
|
||||
timestamps.pop_front();
|
||||
}
|
||||
|
||||
if (timestamps.size() >= maxRequests_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
timestamps.push_back(now);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Global rate limiter instance
|
||||
inline RateLimiter& rateLimiter() {
|
||||
static RateLimiter limiter;
|
||||
return limiter;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
inline bool isValidStreamKey(const std::string& key) {
|
||||
return key.length() == 32 &&
|
||||
std::all_of(key.begin(), key.end(), [](char c) {
|
||||
return std::isxdigit(c);
|
||||
});
|
||||
}
|
||||
|
||||
// JSON conversion helper
|
||||
template<typename T>
|
||||
inline Json::Value toJson(const T& obj);
|
||||
|
||||
// Specializations for common types
|
||||
template<>
|
||||
inline Json::Value toJson(const std::map<std::string, std::string>& m) {
|
||||
Json::Value json;
|
||||
for (const auto& [k, v] : m) {
|
||||
json[k] = v;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
} // namespace utils
|
||||
181
backend/src/controllers/AdminController.cpp
Normal file
181
backend/src/controllers/AdminController.cpp
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#include "AdminController.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
namespace {
|
||||
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
}
|
||||
|
||||
UserInfo AdminController::getUserFromRequest(const HttpRequestPtr &req) {
|
||||
UserInfo user;
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
|
||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
||||
return user;
|
||||
}
|
||||
|
||||
std::string token = auth.substr(7);
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
void AdminController::getUsers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0 || !user.isAdmin) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, "
|
||||
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count "
|
||||
"FROM users u ORDER BY u.created_at DESC"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value users(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value user;
|
||||
user["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
user["username"] = row["username"].as<std::string>();
|
||||
user["isAdmin"] = row["is_admin"].as<bool>();
|
||||
user["isStreamer"] = row["is_streamer"].as<bool>();
|
||||
user["createdAt"] = row["created_at"].as<std::string>();
|
||||
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
|
||||
users.append(user);
|
||||
}
|
||||
|
||||
resp["users"] = users;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get users: " << e.base().what();
|
||||
callback(jsonError("Failed to get users"));
|
||||
};
|
||||
}
|
||||
|
||||
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0 || !user.isAdmin) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get live realms from database
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT r.id, r.name, r.stream_key, r.viewer_count, "
|
||||
"u.username FROM realms r "
|
||||
"JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.is_live = true"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value streams(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value stream;
|
||||
stream["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
stream["name"] = row["name"].as<std::string>();
|
||||
stream["streamKey"] = row["stream_key"].as<std::string>();
|
||||
stream["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
|
||||
stream["username"] = row["username"].as<std::string>();
|
||||
streams.append(stream);
|
||||
}
|
||||
|
||||
resp["streams"] = streams;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get active streams: " << e.base().what();
|
||||
callback(jsonError("Failed to get active streams"));
|
||||
};
|
||||
}
|
||||
|
||||
void AdminController::disconnectStream(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0 || !user.isAdmin) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to Redis set for OpenResty to disconnect
|
||||
RedisHelper::addToSet("streams_to_disconnect", streamKey);
|
||||
|
||||
// Also try direct disconnect
|
||||
OmeClient::getInstance().disconnectStream(streamKey, [callback](bool) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Stream disconnect initiated";
|
||||
callback(jsonResp(resp));
|
||||
});
|
||||
}
|
||||
|
||||
void AdminController::promoteToStreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0 || !user.isAdmin) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t targetUserId = std::stoll(userId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE users SET is_streamer = true WHERE id = $1"
|
||||
<< targetUserId
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "User promoted to streamer";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to promote user: " << e.base().what();
|
||||
callback(jsonError("Failed to promote user"));
|
||||
};
|
||||
}
|
||||
|
||||
void AdminController::demoteFromStreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0 || !user.isAdmin) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t targetUserId = std::stoll(userId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE users SET is_streamer = false WHERE id = $1"
|
||||
<< targetUserId
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "User demoted from streamer";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to demote user: " << e.base().what();
|
||||
callback(jsonError("Failed to demote user"));
|
||||
};
|
||||
}
|
||||
37
backend/src/controllers/AdminController.h
Normal file
37
backend/src/controllers/AdminController.h
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class AdminController : public HttpController<AdminController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(AdminController::getUsers, "/api/admin/users", Get);
|
||||
ADD_METHOD_TO(AdminController::getActiveStreams, "/api/admin/streams", Get);
|
||||
ADD_METHOD_TO(AdminController::disconnectStream, "/api/admin/streams/{1}/disconnect", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToStreamer, "/api/admin/users/{1}/promote", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromStreamer, "/api/admin/users/{1}/demote", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getUsers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getActiveStreams(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void disconnectStream(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey);
|
||||
|
||||
void promoteToStreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromStreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
};
|
||||
600
backend/src/controllers/RealmController.cpp
Normal file
600
backend/src/controllers/RealmController.cpp
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
#include "RealmController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include "../services/StatsService.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <drogon/Cookie.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <regex>
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
namespace {
|
||||
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
|
||||
std::string generateStreamKey() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool validateRealmName(const std::string& name) {
|
||||
if (name.length() < 3 || name.length() > 30) {
|
||||
return false;
|
||||
}
|
||||
return std::regex_match(name, std::regex("^[a-z0-9-]+$"));
|
||||
}
|
||||
|
||||
void invalidateKeyInRedis(const std::string& oldKey) {
|
||||
RedisHelper::addToSet("streams_to_disconnect", oldKey);
|
||||
RedisHelper::deleteKey("stream_key:" + oldKey);
|
||||
|
||||
services::RedisHelper::instance().keysAsync("viewer_token:*",
|
||||
[oldKey](const std::vector<std::string>& keys) {
|
||||
for (const auto& tokenKey : keys) {
|
||||
services::RedisHelper::instance().getAsync(tokenKey,
|
||||
[tokenKey, oldKey](sw::redis::OptionalString streamKey) {
|
||||
if (streamKey.has_value() && streamKey.value() == oldKey) {
|
||||
RedisHelper::deleteKey(tokenKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserInfo RealmController::getUserFromRequest(const HttpRequestPtr &req) {
|
||||
UserInfo user;
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
|
||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
||||
return user;
|
||||
}
|
||||
|
||||
std::string token = auth.substr(7);
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
void RealmController::getUserRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, name, stream_key, is_active, is_live, viewer_count, created_at "
|
||||
"FROM realms WHERE user_id = $1 ORDER BY created_at DESC"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value realms(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value realm;
|
||||
realm["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
realm["name"] = row["name"].as<std::string>();
|
||||
realm["streamKey"] = row["stream_key"].as<std::string>();
|
||||
realm["isActive"] = row["is_active"].as<bool>();
|
||||
realm["isLive"] = row["is_live"].as<bool>();
|
||||
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
|
||||
realm["createdAt"] = row["created_at"].as<std::string>();
|
||||
realms.append(realm);
|
||||
}
|
||||
|
||||
resp["realms"] = realms;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get realms: " << e.base().what();
|
||||
callback(jsonError("Failed to get realms"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::createRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is a streamer
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT is_streamer FROM users WHERE id = $1"
|
||||
<< user.id
|
||||
>> [req, callback, user, dbClient](const Result& r) {
|
||||
if (r.empty() || !r[0]["is_streamer"].as<bool>()) {
|
||||
callback(jsonError("You must be a streamer to create realms", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string name = (*json)["name"].asString();
|
||||
|
||||
if (!validateRealmName(name)) {
|
||||
callback(jsonError("Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if realm name already exists
|
||||
*dbClient << "SELECT id FROM realms WHERE name = $1"
|
||||
<< name
|
||||
>> [dbClient, user, name, callback](const Result& r2) {
|
||||
if (!r2.empty()) {
|
||||
callback(jsonError("Realm name already taken"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check user's realm limit (e.g., 5 realms per user)
|
||||
*dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1"
|
||||
<< user.id
|
||||
>> [dbClient, user, name, callback](const Result& r3) {
|
||||
if (!r3.empty() && r3[0]["count"].as<int64_t>() >= 5) {
|
||||
callback(jsonError("You have reached the maximum number of realms (5)"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string streamKey = generateStreamKey();
|
||||
|
||||
*dbClient << "INSERT INTO realms (user_id, name, stream_key) "
|
||||
"VALUES ($1, $2, $3) RETURNING id"
|
||||
<< user.id << name << streamKey
|
||||
>> [callback, name, streamKey](const Result& r4) {
|
||||
if (r4.empty()) {
|
||||
callback(jsonError("Failed to create realm"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store stream key in Redis
|
||||
RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
||||
resp["realm"]["name"] = name;
|
||||
resp["realm"]["streamKey"] = streamKey;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to create realm: " << e.base().what();
|
||||
callback(jsonError("Failed to create realm"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to count realms: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to check realm name: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to check streamer status: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::issueRealmViewerToken(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonResp({}, k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
|
||||
auto bytes = drogon::utils::genRandomString(32);
|
||||
std::string token = drogon::utils::base64Encode(
|
||||
(const unsigned char*)bytes.data(), bytes.length()
|
||||
);
|
||||
|
||||
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 30,
|
||||
[callback, token](bool stored) {
|
||||
if (!stored) {
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
return;
|
||||
}
|
||||
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
|
||||
Cookie cookie("viewer_token", token);
|
||||
cookie.setPath("/");
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(false);
|
||||
cookie.setMaxAge(300);
|
||||
resp->addCookie(cookie);
|
||||
|
||||
Json::Value body;
|
||||
body["success"] = true;
|
||||
body["viewer_token"] = token;
|
||||
body["expires_in"] = 30;
|
||||
|
||||
resp->setContentTypeCode(CT_APPLICATION_JSON);
|
||||
resp->setBody(Json::FastWriter().write(body));
|
||||
|
||||
callback(resp);
|
||||
}
|
||||
);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
// Check for viewer token
|
||||
auto token = req->getCookie("viewer_token");
|
||||
if (token.empty()) {
|
||||
callback(jsonError("No viewer token", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
// First get the stream key for this realm
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
||||
<< id
|
||||
>> [token, callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
|
||||
// Verify the token is valid for this stream
|
||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||
[callback, streamKey](const std::string& storedStreamKey) {
|
||||
if (storedStreamKey != streamKey) {
|
||||
callback(jsonError("Invalid token for this realm", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
// Token is valid, return the stream key
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["streamKey"] = streamKey;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::getRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT r.*, u.username FROM realms r "
|
||||
"JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.id = $1 AND r.user_id = $2"
|
||||
<< id << user.id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
auto& realm = resp["realm"];
|
||||
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
||||
realm["name"] = r[0]["name"].as<std::string>();
|
||||
realm["streamKey"] = r[0]["stream_key"].as<std::string>();
|
||||
realm["isActive"] = r[0]["is_active"].as<bool>();
|
||||
realm["isLive"] = r[0]["is_live"].as<bool>();
|
||||
realm["viewerCount"] = static_cast<Json::Int64>(r[0]["viewer_count"].as<int64_t>());
|
||||
realm["createdAt"] = r[0]["created_at"].as<std::string>();
|
||||
realm["username"] = r[0]["username"].as<std::string>();
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get realm: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::updateRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
// Since we removed display_name and description, there's nothing to update
|
||||
// We could just return success or remove this endpoint entirely
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
|
||||
void RealmController::deleteRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// First get the stream key to invalidate it
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND user_id = $2"
|
||||
<< id << user.id
|
||||
>> [dbClient, id, user, callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
|
||||
// Invalidate the key
|
||||
invalidateKeyInRedis(streamKey);
|
||||
|
||||
// Delete the realm
|
||||
*dbClient << "DELETE FROM realms WHERE id = $1 AND user_id = $2"
|
||||
<< id << user.id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to delete realm: " << e.base().what();
|
||||
callback(jsonError("Failed to delete realm"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get realm: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::regenerateRealmKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get old key
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND user_id = $2"
|
||||
<< id << user.id
|
||||
>> [dbClient, id, user, callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string oldKey = r[0]["stream_key"].as<std::string>();
|
||||
invalidateKeyInRedis(oldKey);
|
||||
|
||||
std::string newKey = generateStreamKey();
|
||||
|
||||
*dbClient << "UPDATE realms SET stream_key = $1 WHERE id = $2 AND user_id = $3"
|
||||
<< newKey << id << user.id
|
||||
>> [callback, newKey](const Result&) {
|
||||
// Store new key in Redis
|
||||
RedisHelper::storeKey("stream_key:" + newKey, "1", 86400);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["streamKey"] = newKey;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update stream key: " << e.base().what();
|
||||
callback(jsonError("Failed to regenerate key"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get realm: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::getRealmByName(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmName) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT r.id, r.name, r.is_live, r.viewer_count, "
|
||||
"u.username, u.avatar_url FROM realms r "
|
||||
"JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.name = $1 AND r.is_active = true"
|
||||
<< realmName
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
auto& realm = resp["realm"];
|
||||
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
||||
realm["name"] = r[0]["name"].as<std::string>();
|
||||
realm["isLive"] = r[0]["is_live"].as<bool>();
|
||||
realm["viewerCount"] = static_cast<Json::Int64>(r[0]["viewer_count"].as<int64_t>());
|
||||
realm["username"] = r[0]["username"].as<std::string>();
|
||||
realm["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get realm by name: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::getLiveRealms(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT r.name, r.viewer_count, u.username, u.avatar_url "
|
||||
"FROM realms r JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.is_live = true AND r.is_active = true "
|
||||
"ORDER BY r.viewer_count DESC"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value realm;
|
||||
realm["name"] = row["name"].as<std::string>();
|
||||
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
|
||||
realm["username"] = row["username"].as<std::string>();
|
||||
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
resp.append(realm);
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get live realms: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::validateRealmKey(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &key) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id FROM realms WHERE stream_key = $1 AND is_active = true"
|
||||
<< key
|
||||
>> [callback, key](const Result& r) {
|
||||
bool valid = !r.empty();
|
||||
if (valid) {
|
||||
// Store in Redis
|
||||
RedisHelper::storeKey("stream_key:" + key, "1", 86400);
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["valid"] = valid;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
Json::Value resp;
|
||||
resp["valid"] = false;
|
||||
callback(jsonResp(resp));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::getRealmStats(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
// Public endpoint - no authentication required
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
|
||||
StatsService::getInstance().getStreamStats(streamKey,
|
||||
[callback](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
Json::Value json;
|
||||
json["success"] = true;
|
||||
|
||||
auto& s = json["stats"];
|
||||
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers);
|
||||
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections);
|
||||
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
|
||||
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
|
||||
s["bitrate"] = stats.bitrate;
|
||||
s["codec"] = stats.codec;
|
||||
s["resolution"] = stats.resolution;
|
||||
s["fps"] = stats.fps;
|
||||
s["is_live"] = stats.isLive;
|
||||
|
||||
// Protocol breakdown
|
||||
auto& pc = s["protocol_connections"];
|
||||
pc["webrtc"] = static_cast<Json::Int64>(stats.protocolConnections.webrtc);
|
||||
pc["hls"] = static_cast<Json::Int64>(stats.protocolConnections.hls);
|
||||
pc["llhls"] = static_cast<Json::Int64>(stats.protocolConnections.llhls);
|
||||
pc["dash"] = static_cast<Json::Int64>(stats.protocolConnections.dash);
|
||||
|
||||
callback(jsonResp(json));
|
||||
} else {
|
||||
callback(jsonError("Failed to retrieve stats"));
|
||||
}
|
||||
});
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
71
backend/src/controllers/RealmController.h
Normal file
71
backend/src/controllers/RealmController.h
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class RealmController : public HttpController<RealmController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(RealmController::getUserRealms, "/api/realms", Get);
|
||||
ADD_METHOD_TO(RealmController::createRealm, "/api/realms", Post);
|
||||
ADD_METHOD_TO(RealmController::getRealm, "/api/realms/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::updateRealm, "/api/realms/{1}", Put);
|
||||
ADD_METHOD_TO(RealmController::deleteRealm, "/api/realms/{1}", Delete);
|
||||
ADD_METHOD_TO(RealmController::regenerateRealmKey, "/api/realms/{1}/regenerate-key", Post);
|
||||
ADD_METHOD_TO(RealmController::getRealmByName, "/api/realms/by-name/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::getLiveRealms, "/api/realms/live", Get);
|
||||
ADD_METHOD_TO(RealmController::validateRealmKey, "/api/realms/validate/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::issueRealmViewerToken, "/api/realms/{1}/viewer-token", Get);
|
||||
ADD_METHOD_TO(RealmController::getRealmStreamKey, "/api/realms/{1}/stream-key", Get);
|
||||
ADD_METHOD_TO(RealmController::getRealmStats, "/api/realms/{1}/stats", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getUserRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void createRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void updateRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void deleteRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void regenerateRealmKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void getRealmByName(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmName);
|
||||
|
||||
void getLiveRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void validateRealmKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &key);
|
||||
|
||||
void getRealmStats(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void issueRealmViewerToken(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void getRealmStreamKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
};
|
||||
370
backend/src/controllers/StreamController.cpp
Normal file
370
backend/src/controllers/StreamController.cpp
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
#include "StreamController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include "../services/StatsService.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include "../services/AuthService.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <drogon/Cookie.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <chrono>
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
// Helper functions at the top
|
||||
namespace {
|
||||
// JSON response helper - saves 6-8 lines per endpoint
|
||||
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonOk(const Json::Value& data) {
|
||||
return jsonResp(data);
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
|
||||
// Quick JSON builder for common patterns
|
||||
Json::Value json(std::initializer_list<std::pair<const char*, Json::Value>> items) {
|
||||
Json::Value j;
|
||||
for (const auto& [key, value] : items) {
|
||||
j[key] = value;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req) {
|
||||
UserInfo user;
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
|
||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
||||
return user;
|
||||
}
|
||||
|
||||
std::string token = auth.substr(7);
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Static member definitions
|
||||
std::mutex StreamWebSocketController::connectionsMutex_;
|
||||
std::unordered_map<std::string, std::unordered_set<WebSocketConnectionPtr>> StreamWebSocketController::tokenConnections_;
|
||||
std::unordered_set<WebSocketConnectionPtr> StreamWebSocketController::connections_;
|
||||
|
||||
void StreamController::health(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
callback(jsonOk(json({
|
||||
{"status", "ok"},
|
||||
{"timestamp", Json::Int64(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()
|
||||
).count())}
|
||||
})));
|
||||
}
|
||||
|
||||
void StreamController::validateStreamKey(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &key) {
|
||||
// Now validate against realms table
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT 1 FROM realms WHERE stream_key = $1 AND is_active = true"
|
||||
<< key
|
||||
>> [callback, key](const Result &r) {
|
||||
bool valid = !r.empty();
|
||||
if (valid) {
|
||||
// Store in Redis
|
||||
RedisHelper::storeKey("stream_key:" + key, "1", 86400);
|
||||
}
|
||||
callback(jsonOk(json({{"valid", valid}})));
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonOk(json({{"valid", false}})));
|
||||
};
|
||||
}
|
||||
|
||||
void StreamController::disconnectStream(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user owns this stream or is admin
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT user_id FROM realms WHERE stream_key = $1 AND is_active = true"
|
||||
<< streamKey
|
||||
>> [user, callback, streamKey](const Result& r) {
|
||||
if (r.empty() || (r[0]["user_id"].as<int64_t>() != user.id && !user.isAdmin)) {
|
||||
callback(jsonError("Forbidden", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
OmeClient::getInstance().disconnectStream(streamKey, [callback](bool success) {
|
||||
if (success) {
|
||||
callback(jsonOk(json({
|
||||
{"success", true},
|
||||
{"message", "Stream disconnected"}
|
||||
})));
|
||||
} else {
|
||||
callback(jsonError("Failed to disconnect stream"));
|
||||
}
|
||||
});
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void StreamController::getStreamStats(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey) {
|
||||
StatsService::getInstance().getStreamStats(streamKey,
|
||||
[callback](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
Json::Value json;
|
||||
json["success"] = true;
|
||||
|
||||
auto& s = json["stats"];
|
||||
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers);
|
||||
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections);
|
||||
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
|
||||
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
|
||||
s["bitrate"] = stats.bitrate;
|
||||
s["codec"] = stats.codec;
|
||||
s["resolution"] = stats.resolution;
|
||||
s["fps"] = stats.fps;
|
||||
s["is_live"] = stats.isLive;
|
||||
|
||||
if (stats.totalBytesIn > 0) {
|
||||
s["data_rate_in"] = stats.bitrate / 1000.0;
|
||||
}
|
||||
if (stats.totalBytesOut > 0) {
|
||||
s["data_rate_out"] = stats.totalBytesOut / 1024.0 / 1024.0;
|
||||
}
|
||||
|
||||
// Protocol breakdown
|
||||
auto& pc = s["protocol_connections"];
|
||||
pc["webrtc"] = static_cast<Json::Int64>(stats.protocolConnections.webrtc);
|
||||
pc["hls"] = static_cast<Json::Int64>(stats.protocolConnections.hls);
|
||||
pc["llhls"] = static_cast<Json::Int64>(stats.protocolConnections.llhls);
|
||||
pc["dash"] = static_cast<Json::Int64>(stats.protocolConnections.dash);
|
||||
|
||||
callback(jsonResp(json));
|
||||
} else {
|
||||
callback(jsonError("Failed to retrieve stream stats"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void StreamController::getActiveStreams(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
OmeClient::getInstance().getActiveStreams([callback](bool success, const Json::Value& omeResponse) {
|
||||
if (success) {
|
||||
LOG_INFO << "Active streams: " << omeResponse["response"].toStyledString();
|
||||
callback(jsonOk(json({
|
||||
{"success", true},
|
||||
{"streams", omeResponse["response"]}
|
||||
})));
|
||||
} else {
|
||||
callback(jsonError("Failed to get active streams from OME"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void StreamController::issueViewerToken(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey) {
|
||||
// Validate against realms
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT 1 FROM realms WHERE stream_key = $1 AND is_active = true"
|
||||
<< streamKey
|
||||
>> [callback, streamKey](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonResp({}, k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
auto bytes = drogon::utils::genRandomString(32);
|
||||
std::string token = drogon::utils::base64Encode(
|
||||
(const unsigned char*)bytes.data(), bytes.length()
|
||||
);
|
||||
|
||||
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 30,
|
||||
[callback, token](bool stored) {
|
||||
if (!stored) {
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
return;
|
||||
}
|
||||
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
|
||||
Cookie cookie("viewer_token", token);
|
||||
cookie.setPath("/");
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(false);
|
||||
cookie.setMaxAge(300);
|
||||
resp->addCookie(cookie);
|
||||
|
||||
Json::Value body;
|
||||
body["success"] = true;
|
||||
body["viewer_token"] = token;
|
||||
body["expires_in"] = 30;
|
||||
|
||||
resp->setContentTypeCode(CT_APPLICATION_JSON);
|
||||
resp->setBody(Json::FastWriter().write(body));
|
||||
|
||||
callback(resp);
|
||||
}
|
||||
);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
};
|
||||
}
|
||||
|
||||
void StreamController::heartbeat(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey) {
|
||||
auto token = req->getCookie("viewer_token");
|
||||
if (token.empty()) {
|
||||
callback(jsonResp({}, k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||
[callback, streamKey, token](const std::string& storedStreamKey) {
|
||||
if (storedStreamKey != streamKey) {
|
||||
callback(jsonResp({}, k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 30,
|
||||
[callback](bool success) {
|
||||
if (!success) {
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
return;
|
||||
}
|
||||
|
||||
callback(jsonOk(json({
|
||||
{"success", true},
|
||||
{"renewed", true}
|
||||
})));
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// WebSocket implementation
|
||||
void StreamWebSocketController::handleNewMessage(const WebSocketConnectionPtr&,
|
||||
std::string &&message,
|
||||
const WebSocketMessageType &type) {
|
||||
if (type == WebSocketMessageType::Text) {
|
||||
Json::Value msg;
|
||||
Json::Reader reader;
|
||||
if (reader.parse(message, msg) && msg["type"].asString() == "subscribe") {
|
||||
LOG_INFO << "Client subscribed to stream updates";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void StreamWebSocketController::handleNewConnection(const HttpRequestPtr &req,
|
||||
const WebSocketConnectionPtr& wsConnPtr) {
|
||||
LOG_INFO << "New WebSocket connection established";
|
||||
|
||||
auto token = req->getCookie("viewer_token");
|
||||
if (token.empty()) {
|
||||
LOG_WARN << "WebSocket connection without viewer token";
|
||||
wsConnPtr->shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||
[wsConnPtr, token](const std::string& streamKey) {
|
||||
if (streamKey.empty()) {
|
||||
LOG_WARN << "Invalid viewer token";
|
||||
wsConnPtr->shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
tokenConnections_[token].insert(wsConnPtr);
|
||||
connections_.insert(wsConnPtr);
|
||||
|
||||
LOG_INFO << "WebSocket authenticated for stream: " << streamKey;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void StreamWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) {
|
||||
LOG_INFO << "WebSocket connection closed";
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
|
||||
std::string tokenToDelete;
|
||||
for (auto& [token, conns] : tokenConnections_) {
|
||||
if (conns.erase(wsConnPtr)) {
|
||||
if (conns.empty()) {
|
||||
tokenToDelete = token;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
connections_.erase(wsConnPtr);
|
||||
|
||||
if (!tokenToDelete.empty()) {
|
||||
tokenConnections_.erase(tokenToDelete);
|
||||
|
||||
RedisHelper::deleteKeyAsync("viewer_token:" + tokenToDelete,
|
||||
[tokenToDelete](bool success) {
|
||||
if (success) {
|
||||
LOG_INFO << "Deleted viewer token on disconnect: " << tokenToDelete;
|
||||
} else {
|
||||
LOG_WARN << "Failed to delete viewer token: " << tokenToDelete;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void StreamWebSocketController::broadcastKeyUpdate(const std::string& userId, const std::string& newKey) {
|
||||
Json::Value msg;
|
||||
msg["type"] = "key_regenerated";
|
||||
msg["user_id"] = userId;
|
||||
msg["stream_key"] = newKey;
|
||||
|
||||
auto msgStr = Json::FastWriter().write(msg);
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
for (const auto& conn : connections_) {
|
||||
if (conn->connected()) {
|
||||
conn->send(msgStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void StreamWebSocketController::broadcastStatsUpdate(const Json::Value& stats) {
|
||||
std::string jsonStr = Json::FastWriter().write(stats);
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
for (const auto& conn : connections_) {
|
||||
if (conn->connected()) {
|
||||
conn->send(jsonStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
backend/src/controllers/StreamController.h
Normal file
72
backend/src/controllers/StreamController.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include <drogon/WebSocketController.h>
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class StreamController : public HttpController<StreamController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(StreamController::health, "/api/health", Get);
|
||||
ADD_METHOD_TO(StreamController::validateStreamKey, "/api/stream/validate/{1}", Get);
|
||||
ADD_METHOD_TO(StreamController::disconnectStream, "/api/stream/disconnect/{1}", Post);
|
||||
ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get);
|
||||
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
|
||||
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
|
||||
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
void health(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void validateStreamKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &key);
|
||||
|
||||
void disconnectStream(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamId);
|
||||
|
||||
void getStreamStats(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey);
|
||||
|
||||
void getActiveStreams(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void issueViewerToken(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey);
|
||||
|
||||
void heartbeat(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey);
|
||||
};
|
||||
|
||||
class StreamWebSocketController : public WebSocketController<StreamWebSocketController> {
|
||||
public:
|
||||
void handleNewMessage(const WebSocketConnectionPtr& wsConnPtr,
|
||||
std::string &&message,
|
||||
const WebSocketMessageType &type) override;
|
||||
|
||||
void handleNewConnection(const HttpRequestPtr &req,
|
||||
const WebSocketConnectionPtr& wsConnPtr) override;
|
||||
|
||||
void handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) override;
|
||||
|
||||
static void broadcastKeyUpdate(const std::string& userId, const std::string& newKey);
|
||||
static void broadcastStatsUpdate(const Json::Value& stats);
|
||||
|
||||
WS_PATH_LIST_BEGIN
|
||||
WS_PATH_ADD("/ws/stream");
|
||||
WS_PATH_LIST_END
|
||||
|
||||
private:
|
||||
static std::mutex connectionsMutex_;
|
||||
static std::unordered_map<std::string, std::unordered_set<WebSocketConnectionPtr>> tokenConnections_;
|
||||
static std::unordered_set<WebSocketConnectionPtr> connections_;
|
||||
};
|
||||
614
backend/src/controllers/UserController.cpp
Normal file
614
backend/src/controllers/UserController.cpp
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
#include "UserController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include <drogon/MultiPart.h>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
namespace {
|
||||
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
|
||||
std::string generateRandomFilename(const std::string& extension) {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
|
||||
}
|
||||
|
||||
return ss.str() + "." + extension;
|
||||
}
|
||||
|
||||
bool ensureDirectoryExists(const std::string& path) {
|
||||
try {
|
||||
std::filesystem::create_directories(path);
|
||||
// Set permissions to 755
|
||||
std::filesystem::permissions(path,
|
||||
std::filesystem::perms::owner_all |
|
||||
std::filesystem::perms::group_read | std::filesystem::perms::group_exec |
|
||||
std::filesystem::perms::others_read | std::filesystem::perms::others_exec
|
||||
);
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to create directory " << path << ": " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
|
||||
UserInfo user;
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
|
||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
||||
return user;
|
||||
}
|
||||
|
||||
std::string token = auth.substr(7);
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
void UserController::register_(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string username = (*json)["username"].asString();
|
||||
std::string password = (*json)["password"].asString();
|
||||
std::string publicKey = (*json)["publicKey"].asString();
|
||||
std::string fingerprint = (*json)["fingerprint"].asString();
|
||||
|
||||
if (username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) {
|
||||
callback(jsonError("Missing required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService::getInstance().registerUser(username, password, publicKey, fingerprint,
|
||||
[callback](bool success, const std::string& error, int64_t userId) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["userId"] = static_cast<Json::Int64>(userId);
|
||||
callback(jsonResp(resp));
|
||||
} else {
|
||||
callback(jsonError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UserController::login(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string username = (*json)["username"].asString();
|
||||
std::string password = (*json)["password"].asString();
|
||||
|
||||
if (username.empty() || password.empty()) {
|
||||
callback(jsonError("Missing credentials"));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService::getInstance().loginUser(username, password,
|
||||
[callback](bool success, const std::string& token, const UserInfo& user) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
callback(jsonResp(resp));
|
||||
} else {
|
||||
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string username = (*json)["username"].asString();
|
||||
|
||||
if (username.empty()) {
|
||||
callback(jsonError("Username required"));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService::getInstance().initiatePgpLogin(username,
|
||||
[callback](bool success, const std::string& challenge, const std::string& publicKey) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["challenge"] = challenge;
|
||||
resp["publicKey"] = publicKey;
|
||||
callback(jsonResp(resp));
|
||||
} else {
|
||||
callback(jsonError("User not found or PGP not enabled", k404NotFound));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UserController::pgpVerify(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string username = (*json)["username"].asString();
|
||||
std::string signature = (*json)["signature"].asString();
|
||||
std::string challenge = (*json)["challenge"].asString();
|
||||
|
||||
if (username.empty() || signature.empty() || challenge.empty()) {
|
||||
callback(jsonError("Missing required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
|
||||
[callback](bool success, const std::string& token, const UserInfo& user) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
callback(jsonResp(resp));
|
||||
} else {
|
||||
callback(jsonError("Invalid signature", k401Unauthorized));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UserController::getCurrentUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fresh user data from database
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
||||
"FROM users WHERE id = $1"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("User not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
||||
resp["user"]["username"] = r[0]["username"].as<std::string>();
|
||||
resp["user"]["isAdmin"] = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
resp["user"]["isStreamer"] = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
resp["user"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
resp["user"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get user data: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::updateProfile(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string bio = (*json)["bio"].asString();
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE users SET bio = $1 WHERE id = $2"
|
||||
<< bio << user.id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Profile updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update profile: " << e.base().what();
|
||||
callback(jsonError("Failed to update profile"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::updatePassword(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string oldPassword = (*json)["oldPassword"].asString();
|
||||
std::string newPassword = (*json)["newPassword"].asString();
|
||||
|
||||
if (oldPassword.empty() || newPassword.empty()) {
|
||||
callback(jsonError("Missing passwords"));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService::getInstance().updatePassword(user.id, oldPassword, newPassword,
|
||||
[callback](bool success, const std::string& error) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
callback(jsonResp(resp));
|
||||
} else {
|
||||
callback(jsonError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UserController::togglePgpOnly(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
bool enable = (*json)["enable"].asBool();
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at"
|
||||
<< enable << user.id
|
||||
>> [callback, enable](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["pgpOnly"] = enable;
|
||||
|
||||
// Return the timestamp if it was just enabled
|
||||
if (enable && !r.empty() && !r[0]["pgp_only_enabled_at"].isNull()) {
|
||||
resp["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update PGP setting: " << e.base().what();
|
||||
callback(jsonError("Failed to update setting"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::addPgpKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string publicKey = (*json)["publicKey"].asString();
|
||||
std::string fingerprint = (*json)["fingerprint"].asString();
|
||||
|
||||
if (publicKey.empty() || fingerprint.empty()) {
|
||||
callback(jsonError("Missing key data"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Check if fingerprint already exists
|
||||
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
|
||||
<< fingerprint
|
||||
>> [dbClient, user, publicKey, fingerprint, callback](const Result& r) {
|
||||
if (!r.empty()) {
|
||||
callback(jsonError("This PGP key is already registered"));
|
||||
return;
|
||||
}
|
||||
|
||||
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
||||
<< user.id << publicKey << fingerprint
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to add PGP key: " << e.base().what();
|
||||
callback(jsonError("Failed to add PGP key"));
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::getPgpKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT public_key, fingerprint, created_at FROM pgp_keys "
|
||||
"WHERE user_id = $1 ORDER BY created_at DESC"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value keys(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value key;
|
||||
key["publicKey"] = row["public_key"].as<std::string>();
|
||||
key["fingerprint"] = row["fingerprint"].as<std::string>();
|
||||
key["createdAt"] = row["created_at"].as<std::string>();
|
||||
keys.append(key);
|
||||
}
|
||||
|
||||
resp["keys"] = keys;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get PGP keys: " << e.base().what();
|
||||
callback(jsonError("Failed to get PGP keys"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::uploadAvatar(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
MultiPartParser parser;
|
||||
parser.parse(req);
|
||||
|
||||
if (parser.getFiles().empty()) {
|
||||
callback(jsonError("No file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& file = parser.getFiles()[0];
|
||||
|
||||
// Validate file size (250KB max)
|
||||
if (file.fileLength() > 250 * 1024) {
|
||||
callback(jsonError("File too large (max 250KB)"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
std::string ext = std::string(file.getFileExtension());
|
||||
if (ext != "jpg" && ext != "jpeg" && ext != "png" && ext != "gif") {
|
||||
callback(jsonError("Invalid file type (jpg, png, gif only)"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const std::string uploadDir = "/app/uploads/avatars";
|
||||
if (!ensureDirectoryExists(uploadDir)) {
|
||||
callback(jsonError("Failed to create upload directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique filename using hex string
|
||||
std::string filename = generateRandomFilename(ext);
|
||||
|
||||
// Build the full file path
|
||||
std::string fullPath = uploadDir + "/" + filename;
|
||||
|
||||
// Ensure the file doesn't already exist (extremely unlikely with random names)
|
||||
if (std::filesystem::exists(fullPath)) {
|
||||
LOG_WARN << "File already exists, regenerating name";
|
||||
filename = generateRandomFilename(ext);
|
||||
fullPath = uploadDir + "/" + filename;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the uploaded file data and size
|
||||
const char* fileData = file.fileData();
|
||||
size_t fileSize = file.fileLength();
|
||||
|
||||
if (!fileData || fileSize == 0) {
|
||||
LOG_ERROR << "Empty file data";
|
||||
callback(jsonError("Empty file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Write file data directly to avoid directory creation issues
|
||||
std::ofstream ofs(fullPath, std::ios::binary);
|
||||
if (!ofs) {
|
||||
LOG_ERROR << "Failed to open file for writing: " << fullPath;
|
||||
callback(jsonError("Failed to create file"));
|
||||
return;
|
||||
}
|
||||
|
||||
ofs.write(fileData, fileSize);
|
||||
ofs.close();
|
||||
|
||||
if (!ofs) {
|
||||
LOG_ERROR << "Failed to write file data";
|
||||
callback(jsonError("Failed to write file"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify it's actually a file
|
||||
if (!std::filesystem::is_regular_file(fullPath)) {
|
||||
LOG_ERROR << "Created path is not a regular file: " << fullPath;
|
||||
std::filesystem::remove_all(fullPath); // Clean up
|
||||
callback(jsonError("Failed to save avatar correctly"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set file permissions to 644
|
||||
std::filesystem::permissions(fullPath,
|
||||
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write |
|
||||
std::filesystem::perms::group_read |
|
||||
std::filesystem::perms::others_read
|
||||
);
|
||||
|
||||
LOG_INFO << "Avatar saved successfully to: " << fullPath;
|
||||
LOG_INFO << "File size: " << std::filesystem::file_size(fullPath) << " bytes";
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception while saving avatar: " << e.what();
|
||||
// Clean up any partial files/directories
|
||||
if (std::filesystem::exists(fullPath)) {
|
||||
std::filesystem::remove_all(fullPath);
|
||||
}
|
||||
callback(jsonError("Failed to save avatar"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store as proper URL path
|
||||
std::string avatarUrl = "/uploads/avatars/" + filename;
|
||||
|
||||
// Update database with the URL
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE users SET avatar_url = $1 WHERE id = $2"
|
||||
<< avatarUrl << user.id
|
||||
>> [callback, avatarUrl](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["avatarUrl"] = avatarUrl;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update avatar: " << e.base().what();
|
||||
callback(jsonError("Failed to update avatar"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::getProfile(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username) {
|
||||
// Public endpoint - no authentication required
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT u.username, u.bio, u.avatar_url, u.created_at, "
|
||||
"u.is_pgp_only, u.pgp_only_enabled_at "
|
||||
"FROM users u WHERE u.username = $1"
|
||||
<< username
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("User not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["profile"]["username"] = r[0]["username"].as<std::string>();
|
||||
resp["profile"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
resp["profile"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
resp["profile"]["createdAt"] = r[0]["created_at"].as<std::string>();
|
||||
resp["profile"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
resp["profile"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void UserController::getUserPgpKeys(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username) {
|
||||
// Public endpoint - no authentication required
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT pk.public_key, pk.fingerprint, pk.created_at "
|
||||
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
||||
"WHERE u.username = $1 ORDER BY pk.created_at DESC"
|
||||
<< username
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value keys(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value key;
|
||||
key["publicKey"] = row["public_key"].as<std::string>();
|
||||
key["fingerprint"] = row["fingerprint"].as<std::string>();
|
||||
key["createdAt"] = row["created_at"].as<std::string>();
|
||||
keys.append(key);
|
||||
}
|
||||
|
||||
resp["keys"] = keys;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get user PGP keys: " << e.base().what();
|
||||
callback(jsonError("Failed to get PGP keys"));
|
||||
};
|
||||
}
|
||||
67
backend/src/controllers/UserController.h
Normal file
67
backend/src/controllers/UserController.h
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class UserController : public HttpController<UserController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
||||
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
||||
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
||||
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
||||
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
||||
ADD_METHOD_TO(UserController::updateProfile, "/api/user/profile", Put);
|
||||
ADD_METHOD_TO(UserController::updatePassword, "/api/user/password", Put);
|
||||
ADD_METHOD_TO(UserController::togglePgpOnly, "/api/user/pgp-only", Put);
|
||||
ADD_METHOD_TO(UserController::addPgpKey, "/api/user/pgp-key", Post);
|
||||
ADD_METHOD_TO(UserController::getPgpKeys, "/api/user/pgp-keys", Get);
|
||||
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
|
||||
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
|
||||
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void register_(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void login(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void pgpChallenge(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void pgpVerify(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getCurrentUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateProfile(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updatePassword(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void togglePgpOnly(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void addPgpKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getPgpKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadAvatar(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getProfile(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
|
||||
void getUserPgpKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
};
|
||||
105
backend/src/main.cpp
Normal file
105
backend/src/main.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#include <drogon/drogon.h>
|
||||
#include <drogon/HttpAppFramework.h>
|
||||
#include "controllers/StreamController.h"
|
||||
#include "controllers/UserController.h"
|
||||
#include "controllers/AdminController.h"
|
||||
#include "controllers/RealmController.h"
|
||||
#include "services/DatabaseService.h"
|
||||
#include "services/StatsService.h"
|
||||
#include "services/AuthService.h"
|
||||
#include <exception>
|
||||
#include <csignal>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
int main() {
|
||||
// Simplified signal handlers
|
||||
signal(SIGSEGV, [](int s){ LOG_ERROR << "Signal " << s; exit(s); });
|
||||
signal(SIGABRT, [](int s){ LOG_ERROR << "Signal " << s; exit(s); });
|
||||
|
||||
try {
|
||||
LOG_INFO << "Starting streaming backend server...";
|
||||
|
||||
// Create upload directories
|
||||
mkdir("./uploads", 0755);
|
||||
mkdir("./uploads/avatars", 0755);
|
||||
|
||||
// Initialize DatabaseService
|
||||
LOG_INFO << "Initializing DatabaseService...";
|
||||
DatabaseService::getInstance().initialize();
|
||||
|
||||
// Load config
|
||||
LOG_INFO << "Loading configuration...";
|
||||
app().loadConfigFile("config.json");
|
||||
|
||||
// Register a pre-routing advice to handle CORS
|
||||
app().registerPreRoutingAdvice([](const HttpRequestPtr &req,
|
||||
AdviceCallback &&acb,
|
||||
AdviceChainCallback &&accb) {
|
||||
// Handle CORS preflight requests
|
||||
if (req->getMethod() == Options) {
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(k204NoContent);
|
||||
|
||||
// Get origin from request
|
||||
std::string origin = req->getHeader("Origin");
|
||||
if (origin.empty()) {
|
||||
origin = "*";
|
||||
}
|
||||
|
||||
resp->addHeader("Access-Control-Allow-Origin", origin);
|
||||
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
resp->addHeader("Access-Control-Allow-Credentials", "true");
|
||||
resp->addHeader("Access-Control-Max-Age", "86400");
|
||||
acb(resp);
|
||||
return;
|
||||
}
|
||||
accb();
|
||||
});
|
||||
|
||||
// Register post-handling advice to add CORS headers to all responses
|
||||
app().registerPostHandlingAdvice([](const HttpRequestPtr &req,
|
||||
const HttpResponsePtr &resp) {
|
||||
// Get origin from request
|
||||
std::string origin = req->getHeader("Origin");
|
||||
if (origin.empty()) {
|
||||
origin = "*";
|
||||
}
|
||||
|
||||
resp->addHeader("Access-Control-Allow-Origin", origin);
|
||||
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
resp->addHeader("Access-Control-Allow-Credentials", "true");
|
||||
});
|
||||
|
||||
// Register beginning advice to initialize StatsService after app starts
|
||||
app().registerBeginningAdvice([]() {
|
||||
LOG_INFO << "Application started successfully";
|
||||
|
||||
// Initialize StatsService after app is running
|
||||
LOG_INFO << "Initializing StatsService...";
|
||||
StatsService::getInstance().initialize();
|
||||
});
|
||||
|
||||
app().setTermSignalHandler([]() {
|
||||
LOG_INFO << "Received termination signal, shutting down...";
|
||||
StatsService::getInstance().shutdown();
|
||||
app().quit();
|
||||
});
|
||||
|
||||
// Start the application
|
||||
LOG_INFO << "Starting Drogon framework...";
|
||||
app().run();
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception caught in main: " << e.what();
|
||||
return 1;
|
||||
} catch (...) {
|
||||
LOG_ERROR << "Unknown exception caught in main";
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
15
backend/src/models/Realm.h
Normal file
15
backend/src/models/Realm.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
struct Realm {
|
||||
int64_t id;
|
||||
int64_t userId;
|
||||
std::string name;
|
||||
std::string streamKey;
|
||||
bool isActive;
|
||||
bool isLive;
|
||||
int64_t viewerCount;
|
||||
std::chrono::system_clock::time_point createdAt;
|
||||
std::chrono::system_clock::time_point updatedAt;
|
||||
};
|
||||
12
backend/src/models/StreamKey.h
Normal file
12
backend/src/models/StreamKey.h
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
struct StreamKey {
|
||||
int64_t id;
|
||||
int64_t user_id;
|
||||
std::string key;
|
||||
bool is_active;
|
||||
std::chrono::system_clock::time_point created_at;
|
||||
std::chrono::system_clock::time_point updated_at;
|
||||
};
|
||||
347
backend/src/services/AuthService.cpp
Normal file
347
backend/src/services/AuthService.cpp
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
#include "AuthService.h"
|
||||
#include "DatabaseService.h"
|
||||
#include "RedisHelper.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <regex>
|
||||
#include <random>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
bool AuthService::validatePassword(const std::string& password, std::string& error) {
|
||||
if (password.length() < 8) {
|
||||
error = "Password must be at least 8 characters long";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!std::regex_search(password, std::regex("[0-9]"))) {
|
||||
error = "Password must contain at least one number";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!std::regex_search(password, std::regex("[!@#$%^&*(),.?\":{}|<>]"))) {
|
||||
error = "Password must contain at least one symbol";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void AuthService::registerUser(const std::string& username, const std::string& password,
|
||||
const std::string& publicKey, const std::string& fingerprint,
|
||||
std::function<void(bool, const std::string&, int64_t)> callback) {
|
||||
|
||||
// Validate username
|
||||
if (username.length() < 3 || username.length() > 30) {
|
||||
callback(false, "Username must be between 3 and 30 characters", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
||||
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password
|
||||
std::string error;
|
||||
if (!validatePassword(password, error)) {
|
||||
callback(false, error, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Check if username exists
|
||||
*dbClient << "SELECT id FROM users WHERE username = $1"
|
||||
<< username
|
||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r) {
|
||||
if (!r.empty()) {
|
||||
callback(false, "Username already exists", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if fingerprint exists
|
||||
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
|
||||
<< fingerprint
|
||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r2) {
|
||||
if (!r2.empty()) {
|
||||
callback(false, "This PGP key is already registered", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash password
|
||||
std::string hash = BCrypt::generateHash(password);
|
||||
|
||||
// Begin transaction
|
||||
auto trans = dbClient->newTransaction();
|
||||
|
||||
// Insert user with explicit false values for booleans
|
||||
*trans << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only) VALUES ($1, $2, false, false, false) RETURNING id"
|
||||
<< username << hash
|
||||
>> [trans, publicKey, fingerprint, callback](const Result& r3) {
|
||||
if (r3.empty()) {
|
||||
callback(false, "Failed to create user", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t userId = r3[0]["id"].as<int64_t>();
|
||||
|
||||
// Insert PGP key
|
||||
*trans << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
||||
<< userId << publicKey << fingerprint
|
||||
>> [trans, callback, userId](const Result&) {
|
||||
// Transaction commits automatically
|
||||
callback(true, "", userId);
|
||||
}
|
||||
>> [trans, callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
|
||||
callback(false, "Failed to save PGP key", 0);
|
||||
};
|
||||
}
|
||||
>> [trans, callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to insert user: " << e.base().what();
|
||||
callback(false, "Registration failed", 0);
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "Database error", 0);
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "Database error", 0);
|
||||
};
|
||||
}
|
||||
|
||||
void AuthService::loginUser(const std::string& username, const std::string& password,
|
||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
||||
"FROM users WHERE username = $1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PGP-only is enabled BEFORE password validation
|
||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
|
||||
if (isPgpOnly) {
|
||||
// Return a specific error for PGP-only accounts
|
||||
callback(false, "PGP-only login enabled for this account", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||
|
||||
if (!BCrypt::validatePassword(password, hash)) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
UserInfo user;
|
||||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isPgpOnly = isPgpOnly;
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
|
||||
std::string token = generateToken(user);
|
||||
callback(true, token, user);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "", UserInfo{});
|
||||
};
|
||||
}
|
||||
|
||||
void AuthService::initiatePgpLogin(const std::string& username,
|
||||
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Generate random challenge
|
||||
auto bytes = drogon::utils::genRandomString(32);
|
||||
std::string challenge = drogon::utils::base64Encode(
|
||||
reinterpret_cast<const unsigned char*>(bytes.data()), bytes.length()
|
||||
);
|
||||
|
||||
// Store challenge in Redis with 5 minute TTL
|
||||
RedisHelper::storeKeyAsync("pgp_challenge:" + username, challenge, 300,
|
||||
[dbClient, username, challenge, callback](bool stored) {
|
||||
if (!stored) {
|
||||
callback(false, "", "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's latest public key
|
||||
*dbClient << "SELECT pk.public_key FROM pgp_keys pk "
|
||||
"JOIN users u ON pk.user_id = u.id "
|
||||
"WHERE u.username = $1 "
|
||||
"ORDER BY pk.created_at DESC LIMIT 1"
|
||||
<< username
|
||||
>> [callback, challenge](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "", "");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string publicKey = r[0]["public_key"].as<std::string>();
|
||||
callback(true, challenge, publicKey);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "", "");
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
|
||||
const std::string& challenge,
|
||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||
// Get stored challenge from Redis
|
||||
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
||||
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
||||
if (storedChallenge.empty() || storedChallenge != challenge) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete challenge after use
|
||||
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
||||
|
||||
// In a real implementation, you would verify the signature here
|
||||
// For now, we'll trust the client-side verification
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
||||
"FROM users WHERE username = $1"
|
||||
<< username
|
||||
>> [callback, this](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
UserInfo user;
|
||||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
|
||||
std::string token = generateToken(user);
|
||||
callback(true, token, user);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "", UserInfo{});
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
std::string AuthService::generateToken(const UserInfo& user) {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
auto token = jwt::create()
|
||||
.set_issuer("streaming-app")
|
||||
.set_type("JWS")
|
||||
.set_issued_at(std::chrono::system_clock::now())
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
||||
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
||||
.set_payload_claim("username", jwt::claim(user.username))
|
||||
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
||||
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
||||
.sign(jwt::algorithm::hs256{jwtSecret_});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
try {
|
||||
auto decoded = jwt::decode(token);
|
||||
|
||||
auto verifier = jwt::verify()
|
||||
.allow_algorithm(jwt::algorithm::hs256{jwtSecret_})
|
||||
.with_issuer("streaming-app");
|
||||
|
||||
verifier.verify(decoded);
|
||||
|
||||
userInfo.id = std::stoll(decoded.get_payload_claim("user_id").as_string());
|
||||
userInfo.username = decoded.get_payload_claim("username").as_string();
|
||||
userInfo.isAdmin = decoded.get_payload_claim("is_admin").as_string() == "1";
|
||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_DEBUG << "Token validation failed: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
// Validate new password
|
||||
std::string error;
|
||||
if (!validatePassword(newPassword, error)) {
|
||||
callback(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Verify old password
|
||||
*dbClient << "SELECT password_hash FROM users WHERE id = $1"
|
||||
<< userId
|
||||
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "User not found");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||
|
||||
if (!BCrypt::validatePassword(oldPassword, hash)) {
|
||||
callback(false, "Incorrect password");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
std::string newHash = BCrypt::generateHash(newPassword);
|
||||
|
||||
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
||||
<< newHash << userId
|
||||
>> [callback](const Result&) {
|
||||
callback(true, "");
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update password: " << e.base().what();
|
||||
callback(false, "Failed to update password");
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "Database error");
|
||||
};
|
||||
}
|
||||
63
backend/src/services/AuthService.h
Normal file
63
backend/src/services/AuthService.h
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
struct UserInfo {
|
||||
int64_t id;
|
||||
std::string username;
|
||||
bool isAdmin;
|
||||
bool isStreamer;
|
||||
bool isPgpOnly;
|
||||
std::string bio;
|
||||
std::string avatarUrl;
|
||||
std::string pgpOnlyEnabledAt;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
public:
|
||||
static AuthService& getInstance() {
|
||||
static AuthService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// User registration
|
||||
void registerUser(const std::string& username, const std::string& password,
|
||||
const std::string& publicKey, const std::string& fingerprint,
|
||||
std::function<void(bool success, const std::string& error, int64_t userId)> callback);
|
||||
|
||||
// User login with password
|
||||
void loginUser(const std::string& username, const std::string& password,
|
||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
||||
|
||||
// User login with PGP (returns challenge)
|
||||
void initiatePgpLogin(const std::string& username,
|
||||
std::function<void(bool success, const std::string& challenge, const std::string& publicKey)> callback);
|
||||
|
||||
// Verify PGP signature
|
||||
void verifyPgpLogin(const std::string& username, const std::string& signature, const std::string& challenge,
|
||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
||||
|
||||
// Validate JWT token
|
||||
bool validateToken(const std::string& token, UserInfo& userInfo);
|
||||
|
||||
// Update password
|
||||
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword,
|
||||
std::function<void(bool success, const std::string& error)> callback);
|
||||
|
||||
// Check password requirements
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
|
||||
// Generate JWT token
|
||||
std::string generateToken(const UserInfo& user);
|
||||
|
||||
private:
|
||||
AuthService() = default;
|
||||
~AuthService() = default;
|
||||
AuthService(const AuthService&) = delete;
|
||||
AuthService& operator=(const AuthService&) = delete;
|
||||
|
||||
std::string jwtSecret_;
|
||||
};
|
||||
76
backend/src/services/CorsMiddleware.h
Normal file
76
backend/src/services/CorsMiddleware.h
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
namespace middleware {
|
||||
|
||||
class CorsMiddleware {
|
||||
public:
|
||||
struct Config {
|
||||
std::vector<std::string> allowOrigins = {"*"};
|
||||
std::vector<std::string> allowMethods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"};
|
||||
std::vector<std::string> allowHeaders = {"Content-Type", "Authorization"};
|
||||
bool allowCredentials = true;
|
||||
int maxAge = 86400;
|
||||
};
|
||||
|
||||
static void enable(const Config& config = {}) {
|
||||
using namespace drogon;
|
||||
|
||||
auto cfg = std::make_shared<Config>(config);
|
||||
|
||||
auto addHeaders = [cfg](const HttpResponsePtr &resp, const HttpRequestPtr &req) {
|
||||
std::string origin = req->getHeader("Origin");
|
||||
|
||||
// Check if origin is allowed
|
||||
bool allowed = false;
|
||||
for (const auto& allowedOrigin : cfg->allowOrigins) {
|
||||
if (allowedOrigin == "*" || allowedOrigin == origin) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
resp->addHeader("Access-Control-Allow-Origin", origin.empty() ? "*" : origin);
|
||||
resp->addHeader("Access-Control-Allow-Methods", joinStrings(cfg->allowMethods, ", "));
|
||||
resp->addHeader("Access-Control-Allow-Headers", joinStrings(cfg->allowHeaders, ", "));
|
||||
if (cfg->allowCredentials) {
|
||||
resp->addHeader("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
app().registerPreRoutingAdvice([cfg, addHeaders](const HttpRequestPtr &req,
|
||||
AdviceCallback &&acb,
|
||||
AdviceChainCallback &&accb) {
|
||||
if (req->getMethod() == Options) {
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(k204NoContent);
|
||||
addHeaders(resp, req);
|
||||
resp->addHeader("Access-Control-Max-Age", std::to_string(cfg->maxAge));
|
||||
acb(resp);
|
||||
return;
|
||||
}
|
||||
accb();
|
||||
});
|
||||
|
||||
// Add CORS headers to all responses
|
||||
app().registerPostHandlingAdvice([addHeaders](const HttpRequestPtr &req,
|
||||
const HttpResponsePtr &resp) {
|
||||
addHeaders(resp, req);
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
static std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter) {
|
||||
std::string result;
|
||||
for (size_t i = 0; i < strings.size(); ++i) {
|
||||
result += strings[i];
|
||||
if (i < strings.size() - 1) result += delimiter;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace middleware
|
||||
120
backend/src/services/DatabaseService.cpp
Normal file
120
backend/src/services/DatabaseService.cpp
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#include "DatabaseService.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
namespace {
|
||||
void storeKeyInRedis(const std::string& streamKey) {
|
||||
// Store the stream key in Redis for validation (24 hour TTL)
|
||||
bool stored = RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
|
||||
|
||||
if (stored) {
|
||||
LOG_INFO << "Stored stream key in Redis: " << streamKey;
|
||||
} else {
|
||||
LOG_ERROR << "Failed to store key in Redis: " << streamKey;
|
||||
}
|
||||
}
|
||||
|
||||
std::string generateStreamKey() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
}
|
||||
|
||||
void DatabaseService::initialize() {
|
||||
LOG_INFO << "Initializing Database Service...";
|
||||
}
|
||||
|
||||
void DatabaseService::getUserStreamKey(int64_t userId,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
auto dbClient = drogon::app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT key FROM stream_keys WHERE user_id = $1 AND is_active = true"
|
||||
<< userId
|
||||
>> [callback, userId, dbClient](const Result &r) {
|
||||
if (!r.empty()) {
|
||||
std::string key = r[0]["key"].as<std::string>();
|
||||
// Also store in Redis when retrieved
|
||||
storeKeyInRedis(key);
|
||||
callback(true, key);
|
||||
} else {
|
||||
// Generate new key for user
|
||||
std::string newKey = generateStreamKey();
|
||||
|
||||
*dbClient << "INSERT INTO stream_keys (user_id, key, is_active) VALUES ($1, $2, true)"
|
||||
<< userId << newKey
|
||||
>> [callback, newKey](const Result &) {
|
||||
storeKeyInRedis(newKey);
|
||||
callback(true, newKey);
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Failed to create stream key: " << e.base().what();
|
||||
callback(false, "");
|
||||
};
|
||||
}
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, "");
|
||||
};
|
||||
}
|
||||
|
||||
void DatabaseService::updateUserStreamKey(int64_t userId,
|
||||
const std::string& newKey,
|
||||
std::function<void(bool)> callback) {
|
||||
auto dbClient = drogon::app().getDbClient();
|
||||
|
||||
// Execute as separate queries instead of transaction for simplicity
|
||||
*dbClient << "UPDATE stream_keys SET is_active = false WHERE user_id = $1"
|
||||
<< userId
|
||||
>> [dbClient, userId, newKey, callback](const Result &) {
|
||||
// Insert new key
|
||||
*dbClient << "INSERT INTO stream_keys (user_id, key, is_active) VALUES ($1, $2, true)"
|
||||
<< userId << newKey
|
||||
>> [callback, newKey](const Result &) {
|
||||
// Store new key in Redis
|
||||
storeKeyInRedis(newKey);
|
||||
callback(true);
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Failed to insert new key: " << e.base().what();
|
||||
callback(false);
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Failed to deactivate old keys: " << e.base().what();
|
||||
callback(false);
|
||||
};
|
||||
}
|
||||
|
||||
void DatabaseService::validateStreamKey(const std::string& key,
|
||||
std::function<void(bool)> callback) {
|
||||
auto dbClient = drogon::app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT 1 FROM stream_keys WHERE key = $1 AND is_active = true"
|
||||
<< key
|
||||
>> [callback, key](const Result &r) {
|
||||
bool valid = !r.empty();
|
||||
if (valid) {
|
||||
// Also store in Redis when validated
|
||||
storeKeyInRedis(key);
|
||||
}
|
||||
callback(valid);
|
||||
}
|
||||
>> [callback](const DrogonDbException &e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false);
|
||||
};
|
||||
}
|
||||
30
backend/src/services/DatabaseService.h
Normal file
30
backend/src/services/DatabaseService.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class DatabaseService {
|
||||
public:
|
||||
static DatabaseService& getInstance() {
|
||||
static DatabaseService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void initialize();
|
||||
|
||||
void getUserStreamKey(int64_t userId,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
void updateUserStreamKey(int64_t userId,
|
||||
const std::string& newKey,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
void validateStreamKey(const std::string& key,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
private:
|
||||
DatabaseService() = default;
|
||||
~DatabaseService() = default;
|
||||
DatabaseService(const DatabaseService&) = delete;
|
||||
DatabaseService& operator=(const DatabaseService&) = delete;
|
||||
};
|
||||
176
backend/src/services/OmeClient.h
Normal file
176
backend/src/services/OmeClient.h
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
// TODO: Consider implementing OME webhooks for real-time updates instead of polling
|
||||
// OME supports webhooks for stream events (start/stop/etc) which would be more efficient
|
||||
// than polling. See: https://airensoft.gitbook.io/ovenmediaengine/access-control/admission-webhooks
|
||||
|
||||
class OmeClient {
|
||||
public:
|
||||
static OmeClient& getInstance() {
|
||||
static OmeClient instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Get list of active streams
|
||||
void getActiveStreams(std::function<void(bool, const Json::Value&)> callback) {
|
||||
auto request = createRequest(drogon::Get, "/v1/vhosts/default/apps/app/streams");
|
||||
|
||||
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
|
||||
if (result == drogon::ReqResult::Ok && response && response->getStatusCode() == drogon::k200OK) {
|
||||
try {
|
||||
Json::Value json = *response->getJsonObject();
|
||||
callback(true, json);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse OME response: " << e.what();
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR << "Failed to get active streams from OME";
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get stats for a specific stream
|
||||
void getStreamStats(const std::string& streamKey,
|
||||
std::function<void(bool, const Json::Value&)> callback) {
|
||||
std::string path = "/v1/stats/current/vhosts/default/apps/app/streams/" + streamKey;
|
||||
auto request = createRequest(drogon::Get, path);
|
||||
|
||||
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
|
||||
if (result == drogon::ReqResult::Ok && response) {
|
||||
if (response->getStatusCode() == drogon::k200OK) {
|
||||
try {
|
||||
Json::Value json = *response->getJsonObject();
|
||||
callback(true, json);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse stats response: " << e.what();
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
} else {
|
||||
// Not found or error - return empty but success (stream offline)
|
||||
Json::Value empty;
|
||||
callback(true, empty);
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR << "Request to OME failed";
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get detailed stream info including track metadata (resolution, codec, etc.)
|
||||
void getStreamInfo(const std::string& streamKey,
|
||||
std::function<void(bool, const Json::Value&)> callback) {
|
||||
std::string path = "/v1/vhosts/default/apps/app/streams/" + streamKey;
|
||||
auto request = createRequest(drogon::Get, path);
|
||||
|
||||
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
|
||||
if (result == drogon::ReqResult::Ok && response) {
|
||||
if (response->getStatusCode() == drogon::k200OK) {
|
||||
try {
|
||||
Json::Value json = *response->getJsonObject();
|
||||
callback(true, json);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse stream info response: " << e.what();
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
} else {
|
||||
// Stream not found or error
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR << "Stream info request to OME failed";
|
||||
Json::Value empty;
|
||||
callback(false, empty);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect a stream
|
||||
void disconnectStream(const std::string& streamId,
|
||||
std::function<void(bool)> callback) {
|
||||
std::string path = "/v1/vhosts/default/apps/app/streams/" + streamId;
|
||||
auto request = createRequest(drogon::Delete, path);
|
||||
|
||||
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
|
||||
bool success = (result == drogon::ReqResult::Ok &&
|
||||
response &&
|
||||
response->getStatusCode() == drogon::k200OK);
|
||||
callback(success);
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
OmeClient() = default;
|
||||
~OmeClient() = default;
|
||||
OmeClient(const OmeClient&) = delete;
|
||||
OmeClient& operator=(const OmeClient&) = delete;
|
||||
|
||||
std::string getBaseUrl() {
|
||||
// Check environment variable first
|
||||
const char* envUrl = std::getenv("OME_API_URL");
|
||||
if (envUrl) {
|
||||
return std::string(envUrl);
|
||||
}
|
||||
|
||||
// Try to get from Drogon config
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("ome") && config["ome"].isMember("api_url")) {
|
||||
return config["ome"]["api_url"].asString();
|
||||
}
|
||||
} catch (...) {
|
||||
// Config not available
|
||||
}
|
||||
|
||||
return "http://ovenmediaengine:8081"; // Default
|
||||
}
|
||||
|
||||
std::string getApiToken() {
|
||||
// Check environment variable first
|
||||
const char* envToken = std::getenv("OME_API_TOKEN");
|
||||
if (envToken) {
|
||||
return std::string(envToken);
|
||||
}
|
||||
|
||||
// Try to get from Drogon config
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("ome") && config["ome"].isMember("api_token")) {
|
||||
return config["ome"]["api_token"].asString();
|
||||
}
|
||||
} catch (...) {
|
||||
// Config not available
|
||||
}
|
||||
|
||||
return "your-api-token"; // Default
|
||||
}
|
||||
|
||||
drogon::HttpClientPtr getClient() {
|
||||
return drogon::HttpClient::newHttpClient(getBaseUrl());
|
||||
}
|
||||
|
||||
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path) {
|
||||
auto request = drogon::HttpRequest::newHttpRequest();
|
||||
request->setMethod(method);
|
||||
request->setPath(path);
|
||||
|
||||
// Add authorization header (OME uses Basic auth with token as username)
|
||||
const auto token = getApiToken();
|
||||
const auto b64 = drogon::utils::base64Encode(token);
|
||||
request->addHeader("Authorization", std::string("Basic ") + b64);
|
||||
|
||||
return request;
|
||||
}
|
||||
};
|
||||
311
backend/src/services/RedisHelper.cpp
Normal file
311
backend/src/services/RedisHelper.cpp
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
#include "RedisHelper.h"
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace services {
|
||||
|
||||
RedisHelper &RedisHelper::instance() {
|
||||
static RedisHelper inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
RedisHelper::RedisHelper() : _initialized(false) {
|
||||
LOG_INFO << "RedisHelper created (connection will be established on first use)";
|
||||
}
|
||||
|
||||
RedisHelper::~RedisHelper() = default;
|
||||
|
||||
void RedisHelper::ensureConnected() {
|
||||
if (_initialized) return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(_initMutex);
|
||||
if (_initialized) return; // Double-check
|
||||
|
||||
try {
|
||||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
opts.password = envPass;
|
||||
}
|
||||
|
||||
opts.socket_timeout = std::chrono::milliseconds(1000);
|
||||
opts.connect_timeout = std::chrono::milliseconds(1000);
|
||||
|
||||
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port;
|
||||
|
||||
_redis = std::make_unique<sw::redis::Redis>(opts);
|
||||
_redis->ping();
|
||||
|
||||
_initialized = true;
|
||||
LOG_INFO << "Redis connection established successfully";
|
||||
} catch (const sw::redis::Error& e) {
|
||||
LOG_ERROR << "Failed to connect to Redis: " << e.what();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
std::string RedisHelper::getRedisHost() const {
|
||||
const char* envHost = std::getenv("REDIS_HOST");
|
||||
if (envHost) return std::string(envHost);
|
||||
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("redis") && config["redis"].isMember("host")) {
|
||||
return config["redis"]["host"].asString();
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
return "redis";
|
||||
}
|
||||
|
||||
int RedisHelper::getRedisPort() const {
|
||||
const char* envPort = std::getenv("REDIS_PORT");
|
||||
if (envPort) {
|
||||
try {
|
||||
return std::stoi(envPort);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("redis") && config["redis"].isMember("port")) {
|
||||
return config["redis"]["port"].asInt();
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
return 6379;
|
||||
}
|
||||
|
||||
void RedisHelper::executeInThreadPool(std::function<void()> task) {
|
||||
auto loop = drogon::app().getLoop();
|
||||
if (!loop) {
|
||||
LOG_ERROR << "Event loop not available, executing task synchronously";
|
||||
try {
|
||||
task();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error executing task: " << e.what();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loop->queueInLoop([task = std::move(task)]() {
|
||||
std::thread([task]() {
|
||||
try {
|
||||
task();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error in thread pool task: " << e.what();
|
||||
}
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
|
||||
// Define a macro to generate async methods
|
||||
#define REDIS_ASYNC_IMPL(method, return_type, operation) \
|
||||
void RedisHelper::method##Async(const std::string &key, \
|
||||
std::function<void(return_type)> callback) { \
|
||||
executeAsync<return_type>( \
|
||||
[this, key]() { \
|
||||
return _redis->operation; \
|
||||
}, \
|
||||
std::move(callback) \
|
||||
); \
|
||||
}
|
||||
|
||||
// Specialized async methods using the template
|
||||
|
||||
void RedisHelper::setexAsync(const std::string &key,
|
||||
const std::string &value,
|
||||
long ttlSeconds,
|
||||
std::function<void(bool)> callback) {
|
||||
executeAsync<bool>(
|
||||
[this, key, value, ttlSeconds]() {
|
||||
_redis->setex(key, ttlSeconds, value);
|
||||
return true;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::getAsync(const std::string &key,
|
||||
std::function<void(sw::redis::OptionalString)> callback) {
|
||||
executeAsync<sw::redis::OptionalString>(
|
||||
[this, key]() {
|
||||
return _redis->get(key);
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::delAsync(const std::string &key,
|
||||
std::function<void(bool)> callback) {
|
||||
executeAsync<bool>(
|
||||
[this, key]() {
|
||||
return _redis->del(key) > 0;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::saddAsync(const std::string &setName,
|
||||
const std::string &value,
|
||||
std::function<void(bool)> callback) {
|
||||
executeAsync<bool>(
|
||||
[this, setName, value]() {
|
||||
return _redis->sadd(setName, value) > 0;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::sremAsync(const std::string &setName,
|
||||
const std::string &value,
|
||||
std::function<void(bool)> callback) {
|
||||
executeAsync<bool>(
|
||||
[this, setName, value]() {
|
||||
return _redis->srem(setName, value) > 0;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::smembersAsync(const std::string &setName,
|
||||
std::function<void(std::vector<std::string>)> callback) {
|
||||
executeAsync<std::vector<std::string>>(
|
||||
[this, setName]() {
|
||||
std::vector<std::string> members;
|
||||
_redis->smembers(setName, std::back_inserter(members));
|
||||
return members;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::keysAsync(const std::string &pattern,
|
||||
std::function<void(std::vector<std::string>)> callback) {
|
||||
executeAsync<std::vector<std::string>>(
|
||||
[this, pattern]() {
|
||||
std::vector<std::string> keys;
|
||||
_redis->keys(pattern, std::back_inserter(keys));
|
||||
return keys;
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
void RedisHelper::expireAsync(const std::string &key,
|
||||
long ttlSeconds,
|
||||
std::function<void(bool)> callback) {
|
||||
executeAsync<bool>(
|
||||
[this, key, ttlSeconds]() {
|
||||
return _redis->expire(key, ttlSeconds);
|
||||
},
|
||||
std::move(callback)
|
||||
);
|
||||
}
|
||||
|
||||
// Sync versions for compatibility
|
||||
std::unique_ptr<sw::redis::Redis> RedisHelper::getConnection() {
|
||||
ensureConnected();
|
||||
|
||||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
opts.password = envPass;
|
||||
}
|
||||
|
||||
opts.socket_timeout = std::chrono::milliseconds(200);
|
||||
opts.connect_timeout = std::chrono::milliseconds(200);
|
||||
|
||||
return std::make_unique<sw::redis::Redis>(opts);
|
||||
}
|
||||
|
||||
bool RedisHelper::storeKey(const std::string &key, const std::string &value, int ttl) {
|
||||
try {
|
||||
ensureConnected();
|
||||
if (ttl > 0) {
|
||||
_redis->setex(key, ttl, value);
|
||||
} else {
|
||||
_redis->set(key, value);
|
||||
}
|
||||
return true;
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis SET error: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string RedisHelper::getKey(const std::string &key) {
|
||||
try {
|
||||
ensureConnected();
|
||||
auto val = _redis->get(key);
|
||||
return val.has_value() ? val.value() : "";
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis GET error: " << e.what();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool RedisHelper::deleteKey(const std::string &key) {
|
||||
try {
|
||||
ensureConnected();
|
||||
return _redis->del(key) > 0;
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis DEL error: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool RedisHelper::addToSet(const std::string &setName, const std::string &value) {
|
||||
try {
|
||||
ensureConnected();
|
||||
return _redis->sadd(setName, value) > 0;
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis SADD error: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool RedisHelper::removeFromSet(const std::string &setName, const std::string &value) {
|
||||
try {
|
||||
ensureConnected();
|
||||
return _redis->srem(setName, value) > 0;
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis SREM error: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated command executor - simplified
|
||||
void RedisHelper::executeAsync(const std::string &command,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
// For the single use case in the code (EXPIRE), handle it directly
|
||||
std::istringstream iss(command);
|
||||
std::string op, key;
|
||||
long ttl;
|
||||
iss >> op >> key >> ttl;
|
||||
|
||||
if (op == "EXPIRE" || op == "expire") {
|
||||
expireAsync(key, ttl, [callback](bool success) {
|
||||
callback(success, success ? "1" : "0");
|
||||
});
|
||||
} else {
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->queueInLoop([callback]() {
|
||||
callback(false, "Unsupported command in executeAsync. Use specific async methods.");
|
||||
});
|
||||
} else {
|
||||
callback(false, "Unsupported command in executeAsync. Use specific async methods.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
190
backend/src/services/RedisHelper.h
Normal file
190
backend/src/services/RedisHelper.h
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#pragma once
|
||||
|
||||
#include <sw/redis++/redis++.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
namespace services {
|
||||
|
||||
class RedisHelper {
|
||||
public:
|
||||
// Singleton accessor
|
||||
static RedisHelper &instance();
|
||||
|
||||
// Generic async execute wrapper
|
||||
template<typename Result, typename Callback>
|
||||
void executeAsync(std::function<Result()> redisOp, Callback&& callback) {
|
||||
executeInThreadPool([this, redisOp = std::move(redisOp),
|
||||
callback = std::forward<Callback>(callback)]() {
|
||||
try {
|
||||
ensureConnected();
|
||||
auto result = redisOp();
|
||||
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->queueInLoop([callback, result = std::move(result)]() {
|
||||
callback(std::move(result));
|
||||
});
|
||||
} else {
|
||||
callback(std::move(result));
|
||||
}
|
||||
} catch (const sw::redis::Error &e) {
|
||||
LOG_ERROR << "Redis operation error: " << e.what();
|
||||
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->queueInLoop([callback]() {
|
||||
callback(Result{});
|
||||
});
|
||||
} else {
|
||||
callback(Result{});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Async SETEX
|
||||
void setexAsync(const std::string &key,
|
||||
const std::string &value,
|
||||
long ttlSeconds,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Async GET
|
||||
void getAsync(const std::string &key,
|
||||
std::function<void(sw::redis::OptionalString)> callback);
|
||||
|
||||
// Async DEL
|
||||
void delAsync(const std::string &key,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Async SADD
|
||||
void saddAsync(const std::string &setName,
|
||||
const std::string &value,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Async SREM
|
||||
void sremAsync(const std::string &setName,
|
||||
const std::string &value,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Async SMEMBERS
|
||||
void smembersAsync(const std::string &setName,
|
||||
std::function<void(std::vector<std::string>)> callback);
|
||||
|
||||
// Async KEYS
|
||||
void keysAsync(const std::string &pattern,
|
||||
std::function<void(std::vector<std::string>)> callback);
|
||||
|
||||
// Async EXPIRE
|
||||
void expireAsync(const std::string &key,
|
||||
long ttlSeconds,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Sync versions for compatibility
|
||||
std::unique_ptr<sw::redis::Redis> getConnection();
|
||||
bool storeKey(const std::string &key, const std::string &value, int ttl = 0);
|
||||
std::string getKey(const std::string &key);
|
||||
bool deleteKey(const std::string &key);
|
||||
bool addToSet(const std::string &setName, const std::string &value);
|
||||
bool removeFromSet(const std::string &setName, const std::string &value);
|
||||
|
||||
// Compatibility wrappers - keep for backward compatibility
|
||||
static void storeKeyAsync(const std::string &key, const std::string &value, int ttl,
|
||||
std::function<void(bool)> callback) {
|
||||
instance().setexAsync(key, value, ttl, callback);
|
||||
}
|
||||
|
||||
static void getKeyAsync(const std::string &key,
|
||||
std::function<void(const std::string&)> callback) {
|
||||
instance().getAsync(key, [callback](sw::redis::OptionalString val) {
|
||||
callback(val.has_value() ? val.value() : "");
|
||||
});
|
||||
}
|
||||
|
||||
static void deleteKeyAsync(const std::string &key,
|
||||
std::function<void(bool)> callback) {
|
||||
instance().delAsync(key, callback);
|
||||
}
|
||||
|
||||
// Execute arbitrary command asynchronously (deprecated)
|
||||
void executeAsync(const std::string &command,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
private:
|
||||
RedisHelper();
|
||||
~RedisHelper();
|
||||
RedisHelper(const RedisHelper &) = delete;
|
||||
RedisHelper &operator=(const RedisHelper &) = delete;
|
||||
|
||||
void ensureConnected();
|
||||
void executeInThreadPool(std::function<void()> task);
|
||||
std::string getRedisHost() const;
|
||||
int getRedisPort() const;
|
||||
|
||||
std::unique_ptr<sw::redis::Redis> _redis;
|
||||
bool _initialized;
|
||||
std::mutex _initMutex;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
|
||||
// Compatibility layer for existing code
|
||||
class RedisHelper {
|
||||
public:
|
||||
using RedisConnectionPtr = std::unique_ptr<sw::redis::Redis>;
|
||||
|
||||
static RedisConnectionPtr getConnection() {
|
||||
return services::RedisHelper::instance().getConnection();
|
||||
}
|
||||
|
||||
static std::string getRedisHost() {
|
||||
const char* envHost = std::getenv("REDIS_HOST");
|
||||
return envHost ? std::string(envHost) : "redis";
|
||||
}
|
||||
|
||||
static int getRedisPort() {
|
||||
const char* envPort = std::getenv("REDIS_PORT");
|
||||
return envPort ? std::stoi(envPort) : 6379;
|
||||
}
|
||||
|
||||
static bool storeKey(const std::string& key, const std::string& value, int ttl = 0) {
|
||||
return services::RedisHelper::instance().storeKey(key, value, ttl);
|
||||
}
|
||||
|
||||
static std::string getKey(const std::string& key) {
|
||||
return services::RedisHelper::instance().getKey(key);
|
||||
}
|
||||
|
||||
static bool deleteKey(const std::string& key) {
|
||||
return services::RedisHelper::instance().deleteKey(key);
|
||||
}
|
||||
|
||||
static bool addToSet(const std::string& setName, const std::string& value) {
|
||||
return services::RedisHelper::instance().addToSet(setName, value);
|
||||
}
|
||||
|
||||
static bool removeFromSet(const std::string& setName, const std::string& value) {
|
||||
return services::RedisHelper::instance().removeFromSet(setName, value);
|
||||
}
|
||||
|
||||
static void storeKeyAsync(const std::string& key, const std::string& value, int ttl,
|
||||
std::function<void(bool)> callback) {
|
||||
services::RedisHelper::storeKeyAsync(key, value, ttl, callback);
|
||||
}
|
||||
|
||||
static void getKeyAsync(const std::string& key,
|
||||
std::function<void(const std::string&)> callback) {
|
||||
services::RedisHelper::getKeyAsync(key, callback);
|
||||
}
|
||||
|
||||
static void deleteKeyAsync(const std::string& key,
|
||||
std::function<void(bool)> callback) {
|
||||
services::RedisHelper::deleteKeyAsync(key, callback);
|
||||
}
|
||||
|
||||
static void executeAsync(const std::string& command,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
services::RedisHelper::instance().executeAsync(command, callback);
|
||||
}
|
||||
};
|
||||
356
backend/src/services/StatsService.cpp
Normal file
356
backend/src/services/StatsService.cpp
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
#include "StatsService.h"
|
||||
#include "../controllers/StreamController.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
// Macro to simplify JSON integer assignments
|
||||
#define JSON_INT(json, field, value) json[field] = static_cast<Json::Int64>(value)
|
||||
|
||||
StatsService::~StatsService() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void StatsService::initialize() {
|
||||
LOG_INFO << "Initializing Stats Service...";
|
||||
running_ = true;
|
||||
|
||||
drogon::app().registerBeginningAdvice([this]() {
|
||||
LOG_INFO << "Starting stats polling timer...";
|
||||
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
try {
|
||||
timerId_ = loop->runEvery(
|
||||
pollInterval_.count(),
|
||||
[this]() {
|
||||
if (!running_) return;
|
||||
try {
|
||||
pollOmeStats();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error in stats polling: " << e.what();
|
||||
}
|
||||
}
|
||||
);
|
||||
LOG_INFO << "Stats polling timer started with " << pollInterval_.count() << "s interval";
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to create stats timer: " << e.what();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void StatsService::shutdown() {
|
||||
LOG_INFO << "Shutting down Stats Service...";
|
||||
running_ = false;
|
||||
|
||||
if (timerId_.has_value()) {
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->invalidateTimer(timerId_.value());
|
||||
}
|
||||
timerId_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
int64_t StatsService::getUniqueViewerCount(const std::string& streamKey) {
|
||||
try {
|
||||
auto redis = services::RedisHelper::instance().getConnection();
|
||||
if (!redis) return 0;
|
||||
|
||||
std::vector<std::string> keys;
|
||||
redis->keys("viewer_token:*", std::back_inserter(keys));
|
||||
|
||||
return std::count_if(keys.begin(), keys.end(), [&redis, &streamKey](const auto& tokenKey) {
|
||||
auto storedKey = redis->get(tokenKey);
|
||||
return storedKey.has_value() && storedKey.value() == streamKey;
|
||||
});
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error getting unique viewer count: " << e.what();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void StatsService::pollOmeStats() {
|
||||
// Get active streams from OME
|
||||
OmeClient::getInstance().getActiveStreams([this](bool success, const Json::Value& json) {
|
||||
if (success && json["response"].isArray()) {
|
||||
for (const auto& stream : json["response"]) {
|
||||
if (stream.isString()) {
|
||||
updateStreamStats(stream.asString());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Poll known stream keys from Redis
|
||||
services::RedisHelper::instance().keysAsync("stream_key:*",
|
||||
[this](const std::vector<std::string>& keys) {
|
||||
for (const auto& key : keys) {
|
||||
if (auto pos = key.find(':'); pos != std::string::npos) {
|
||||
updateStreamStats(key.substr(pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void StatsService::updateStreamStats(const std::string& streamKey) {
|
||||
fetchStatsFromOme(streamKey, [this, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
|
||||
storeStatsInRedis(streamKey, updatedStats);
|
||||
|
||||
// Update realm in database
|
||||
updateRealmLiveStatus(streamKey, updatedStats);
|
||||
|
||||
// Only broadcast if stream has meaningful data
|
||||
if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) {
|
||||
Json::Value msg;
|
||||
msg["type"] = "stats_update";
|
||||
msg["stream_key"] = streamKey;
|
||||
|
||||
auto& s = msg["stats"];
|
||||
JSON_INT(s, "connections", updatedStats.uniqueViewers);
|
||||
JSON_INT(s, "raw_connections", updatedStats.currentConnections);
|
||||
s["bitrate"] = updatedStats.bitrate;
|
||||
s["resolution"] = updatedStats.resolution;
|
||||
s["fps"] = updatedStats.fps;
|
||||
s["codec"] = updatedStats.codec;
|
||||
s["is_live"] = updatedStats.isLive;
|
||||
JSON_INT(s, "bytes_in", updatedStats.totalBytesIn);
|
||||
JSON_INT(s, "bytes_out", updatedStats.totalBytesOut);
|
||||
|
||||
// Protocol breakdown
|
||||
auto& pc = s["protocol_connections"];
|
||||
JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc);
|
||||
JSON_INT(pc, "hls", updatedStats.protocolConnections.hls);
|
||||
JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls);
|
||||
JSON_INT(pc, "dash", updatedStats.protocolConnections.dash);
|
||||
|
||||
StreamWebSocketController::broadcastStatsUpdate(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void StatsService::updateRealmLiveStatus(const std::string& streamKey, const StreamStats& stats) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Update realm's live status and viewer count
|
||||
*dbClient << "UPDATE realms SET is_live = $1, viewer_count = $2 WHERE stream_key = $3"
|
||||
<< stats.isLive << stats.uniqueViewers << streamKey
|
||||
>> [streamKey, stats](const orm::Result&) {
|
||||
LOG_DEBUG << "Updated realm status for stream " << streamKey
|
||||
<< " - Live: " << stats.isLive
|
||||
<< ", Viewers: " << stats.uniqueViewers;
|
||||
}
|
||||
>> [streamKey](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update realm status for " << streamKey
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void StatsService::fetchStatsFromOme(const std::string& streamKey,
|
||||
std::function<void(bool, const StreamStats&)> callback) {
|
||||
LOG_DEBUG << "Fetching stats for stream: " << streamKey;
|
||||
|
||||
OmeClient::getInstance().getStreamStats(streamKey, [this, callback, streamKey](bool success, const Json::Value& json) {
|
||||
StreamStats stats;
|
||||
|
||||
if (success && json.isMember("response") && !json["response"].isNull()) {
|
||||
try {
|
||||
const auto& data = json["response"];
|
||||
|
||||
// Parse connections
|
||||
if (data.isMember("connections")) {
|
||||
const auto& conns = data["connections"];
|
||||
int64_t totalConns = 0;
|
||||
|
||||
for (const auto& protocolName : conns.getMemberNames()) {
|
||||
int64_t count = conns[protocolName].asInt64();
|
||||
auto& pc = stats.protocolConnections;
|
||||
|
||||
if (protocolName == "webrtc") pc.webrtc = count;
|
||||
else if (protocolName == "hls") pc.hls = count;
|
||||
else if (protocolName == "llhls") pc.llhls = count;
|
||||
else if (protocolName == "dash") pc.dash = count;
|
||||
|
||||
totalConns += count;
|
||||
}
|
||||
|
||||
stats.currentConnections = totalConns;
|
||||
stats.totalConnections = totalConns;
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
stats.bitrate = data.isMember("lastThroughputIn") ?
|
||||
data["lastThroughputIn"].asDouble() :
|
||||
(data.isMember("avgThroughputIn") ? data["avgThroughputIn"].asDouble() : 0);
|
||||
|
||||
// Byte counters
|
||||
if (data.isMember("totalBytesIn")) stats.totalBytesIn = data["totalBytesIn"].asInt64();
|
||||
if (data.isMember("totalBytesOut")) stats.totalBytesOut = data["totalBytesOut"].asInt64();
|
||||
|
||||
stats.isLive = (stats.bitrate > 0 || stats.currentConnections > 0);
|
||||
LOG_DEBUG << "OME stats response: " << json.toStyledString();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse stats: " << e.what();
|
||||
stats.isLive = false;
|
||||
}
|
||||
} else {
|
||||
stats.isLive = false;
|
||||
}
|
||||
|
||||
stats.lastUpdated = std::chrono::system_clock::now();
|
||||
|
||||
// Now fetch stream info for resolution/codec/fps
|
||||
OmeClient::getInstance().getStreamInfo(streamKey, [callback, stats](bool infoSuccess, const Json::Value& infoJson) mutable {
|
||||
// Parse stream metadata if available
|
||||
if (infoSuccess && infoJson.isMember("response") && infoJson["response"].isMember("tracks")) {
|
||||
try {
|
||||
for (const auto& track : infoJson["response"]["tracks"]) {
|
||||
if (track["type"].asString() == "video") {
|
||||
if (track.isMember("codec")) {
|
||||
stats.codec = track["codec"].asString();
|
||||
}
|
||||
if (track.isMember("width") && track.isMember("height")) {
|
||||
stats.resolution = std::to_string(track["width"].asInt()) + "x" +
|
||||
std::to_string(track["height"].asInt());
|
||||
}
|
||||
if (track.isMember("framerate")) {
|
||||
stats.fps = track["framerate"].asDouble();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse stream info: " << e.what();
|
||||
}
|
||||
}
|
||||
|
||||
callback(true, stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void StatsService::storeStatsInRedis(const std::string& streamKey, const StreamStats& stats) {
|
||||
Json::Value json;
|
||||
JSON_INT(json, "connections", stats.currentConnections);
|
||||
JSON_INT(json, "unique_viewers", stats.uniqueViewers);
|
||||
JSON_INT(json, "total_connections", stats.totalConnections);
|
||||
JSON_INT(json, "bytes_in", stats.totalBytesIn);
|
||||
JSON_INT(json, "bytes_out", stats.totalBytesOut);
|
||||
json["bitrate"] = stats.bitrate;
|
||||
json["codec"] = stats.codec;
|
||||
json["resolution"] = stats.resolution;
|
||||
json["fps"] = stats.fps;
|
||||
json["is_live"] = stats.isLive;
|
||||
JSON_INT(json, "last_updated",
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
stats.lastUpdated.time_since_epoch()
|
||||
).count()
|
||||
);
|
||||
|
||||
// Protocol connections
|
||||
Json::Value pc;
|
||||
JSON_INT(pc, "webrtc", stats.protocolConnections.webrtc);
|
||||
JSON_INT(pc, "hls", stats.protocolConnections.hls);
|
||||
JSON_INT(pc, "llhls", stats.protocolConnections.llhls);
|
||||
JSON_INT(pc, "dash", stats.protocolConnections.dash);
|
||||
json["protocol_connections"] = pc;
|
||||
|
||||
// Store connection drop timestamp if recent
|
||||
auto timeSinceDrop = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now() - stats.lastConnectionDrop).count();
|
||||
if (timeSinceDrop < 60) {
|
||||
JSON_INT(json, "last_connection_drop",
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
stats.lastConnectionDrop.time_since_epoch()
|
||||
).count()
|
||||
);
|
||||
}
|
||||
|
||||
RedisHelper::storeKey("stream_stats:" + streamKey, Json::FastWriter().write(json), 10);
|
||||
}
|
||||
|
||||
void StatsService::getStreamStats(const std::string& streamKey,
|
||||
std::function<void(bool, const StreamStats&)> callback) {
|
||||
std::string jsonStr = RedisHelper::getKey("stream_stats:" + streamKey);
|
||||
|
||||
if (jsonStr.empty()) {
|
||||
// Fetch fresh stats from OME and populate uniqueViewers
|
||||
LOG_DEBUG << "No cached stats, fetching from OME for " << streamKey;
|
||||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
// FIX: Set uniqueViewers on cache miss!
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Json::Value json;
|
||||
Json::Reader reader;
|
||||
if (reader.parse(jsonStr, json)) {
|
||||
StreamStats stats;
|
||||
stats.currentConnections = json["connections"].asInt64();
|
||||
stats.uniqueViewers = json["unique_viewers"].asInt64();
|
||||
stats.totalConnections = json["total_connections"].asInt64();
|
||||
stats.totalBytesIn = json["bytes_in"].asInt64();
|
||||
stats.totalBytesOut = json["bytes_out"].asInt64();
|
||||
stats.bitrate = json["bitrate"].asDouble();
|
||||
stats.codec = json["codec"].asString();
|
||||
stats.resolution = json["resolution"].asString();
|
||||
stats.fps = json["fps"].asDouble();
|
||||
stats.isLive = json["is_live"].asBool();
|
||||
|
||||
// Parse protocol connections
|
||||
if (json.isMember("protocol_connections")) {
|
||||
const auto& pc = json["protocol_connections"];
|
||||
stats.protocolConnections.webrtc = pc["webrtc"].asInt64();
|
||||
stats.protocolConnections.hls = pc["hls"].asInt64();
|
||||
stats.protocolConnections.llhls = pc["llhls"].asInt64();
|
||||
stats.protocolConnections.dash = pc["dash"].asInt64();
|
||||
}
|
||||
|
||||
stats.lastUpdated = std::chrono::system_clock::time_point(
|
||||
std::chrono::seconds(json["last_updated"].asInt64())
|
||||
);
|
||||
|
||||
callback(true, stats);
|
||||
LOG_DEBUG << "Retrieved cached stats for " << streamKey;
|
||||
} else {
|
||||
// Fallback to fresh fetch if cached data is corrupted
|
||||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to parse cached stats: " << e.what();
|
||||
// Fallback to fresh fetch
|
||||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
72
backend/src/services/StatsService.h
Normal file
72
backend/src/services/StatsService.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <trantor/net/EventLoop.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
|
||||
struct StreamStats {
|
||||
int64_t currentConnections = 0; // Raw connection count from OME
|
||||
int64_t uniqueViewers = 0; // Unique viewer tokens
|
||||
int64_t totalConnections = 0;
|
||||
int64_t totalBytesIn = 0;
|
||||
int64_t totalBytesOut = 0;
|
||||
double bitrate = 0.0;
|
||||
std::string codec;
|
||||
std::string resolution;
|
||||
double fps = 0.0;
|
||||
bool isLive = false;
|
||||
std::chrono::system_clock::time_point lastUpdated;
|
||||
|
||||
// Protocol-specific connections
|
||||
struct ProtocolConnections {
|
||||
int64_t webrtc = 0;
|
||||
int64_t hls = 0;
|
||||
int64_t llhls = 0;
|
||||
int64_t dash = 0;
|
||||
} protocolConnections;
|
||||
|
||||
// Connection history for deduplication
|
||||
std::chrono::system_clock::time_point lastConnectionDrop;
|
||||
int64_t previousTotalConnections = 0;
|
||||
};
|
||||
|
||||
class StatsService {
|
||||
public:
|
||||
static StatsService& getInstance() {
|
||||
static StatsService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void initialize();
|
||||
void shutdown();
|
||||
|
||||
// Get cached stats from Redis
|
||||
void getStreamStats(const std::string& streamKey,
|
||||
std::function<void(bool, const StreamStats&)> callback);
|
||||
|
||||
// Force update stats for a specific stream
|
||||
void updateStreamStats(const std::string& streamKey);
|
||||
|
||||
// Get unique viewer count for a stream
|
||||
int64_t getUniqueViewerCount(const std::string& streamKey);
|
||||
|
||||
private:
|
||||
StatsService() = default;
|
||||
~StatsService();
|
||||
StatsService(const StatsService&) = delete;
|
||||
StatsService& operator=(const StatsService&) = delete;
|
||||
|
||||
bool getPreviousStats(const std::string& streamKey, StreamStats& stats);
|
||||
void pollOmeStats();
|
||||
void storeStatsInRedis(const std::string& streamKey, const StreamStats& stats);
|
||||
void fetchStatsFromOme(const std::string& streamKey,
|
||||
std::function<void(bool, const StreamStats&)> callback);
|
||||
void updateRealmLiveStatus(const std::string& streamKey, const StreamStats& stats);
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::optional<trantor::TimerId> timerId_;
|
||||
std::chrono::seconds pollInterval_{2}; // Poll every 2 seconds
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue