From 875a53f499ae9d47a1b141fbde671720ad9e4d8c Mon Sep 17 00:00:00 2001 From: doomtube Date: Sun, 3 Aug 2025 21:53:15 -0400 Subject: [PATCH] Replace master branch with local files --- .env | 11 + backend/.dockerignore | 37 + backend/CMakeLists.txt | 107 + backend/Dockerfile | 107 + backend/conanfile.txt | 11 + backend/config.json | 43 + backend/src/admin_tool.cpp | 71 + backend/src/common/utils.h | 243 + backend/src/controllers/AdminController.cpp | 181 + backend/src/controllers/AdminController.h | 37 + backend/src/controllers/RealmController.cpp | 600 + backend/src/controllers/RealmController.h | 71 + backend/src/controllers/StreamController.cpp | 370 + backend/src/controllers/StreamController.h | 72 + backend/src/controllers/UserController.cpp | 614 + backend/src/controllers/UserController.h | 67 + backend/src/main.cpp | 105 + backend/src/models/Realm.h | 15 + backend/src/models/StreamKey.h | 12 + backend/src/services/AuthService.cpp | 347 + backend/src/services/AuthService.h | 63 + backend/src/services/CorsMiddleware.h | 76 + backend/src/services/DatabaseService.cpp | 120 + backend/src/services/DatabaseService.h | 30 + backend/src/services/OmeClient.h | 176 + backend/src/services/RedisHelper.cpp | 311 + backend/src/services/RedisHelper.h | 190 + backend/src/services/StatsService.cpp | 356 + backend/src/services/StatsService.h | 72 + database/init.sql | 110 + docker | 0 docker-compose.yml | 134 + frontend/.gitignore | 10 + frontend/Dockerfile | 41 + frontend/package.json | 31 + frontend/src/app.css | 323 + frontend/src/app.d.ts | 23 + frontend/src/app.html | 29 + frontend/src/lib/api.js | 36 + frontend/src/lib/pgp.js | 99 + frontend/src/lib/stores/auth.js | 128 + frontend/src/lib/websocket.js | 56 + frontend/src/routes/+layout.svelte | 228 + frontend/src/routes/+page.svelte | 204 + frontend/src/routes/[realm]/live/+page.svelte | 669 + frontend/src/routes/admin/+page.svelte | 385 + frontend/src/routes/login/+page.svelte | 534 + frontend/src/routes/my-realms/+page.svelte | 504 + .../routes/profile/[username]/+page.svelte | 448 + frontend/src/routes/settings/+page.svelte | 1173 ++ frontend/svelte.config.js | 56 + frontend/tsconfig.json | 18 + frontend/vite.config.ts | 10 + openresty/Dockerfile | 28 + openresty/lua/auth.lua | 67 + openresty/lua/redis_helper.lua | 126 + openresty/lua/stream_monitor.lua | 84 + openresty/nginx.conf | 450 + ovenmediaengine/Server.xml | 123 + text.txt | 10995 ++++++++++++++++ 60 files changed, 21637 insertions(+) create mode 100644 .env create mode 100644 backend/.dockerignore create mode 100644 backend/CMakeLists.txt create mode 100644 backend/Dockerfile create mode 100644 backend/conanfile.txt create mode 100644 backend/config.json create mode 100644 backend/src/admin_tool.cpp create mode 100644 backend/src/common/utils.h create mode 100644 backend/src/controllers/AdminController.cpp create mode 100644 backend/src/controllers/AdminController.h create mode 100644 backend/src/controllers/RealmController.cpp create mode 100644 backend/src/controllers/RealmController.h create mode 100644 backend/src/controllers/StreamController.cpp create mode 100644 backend/src/controllers/StreamController.h create mode 100644 backend/src/controllers/UserController.cpp create mode 100644 backend/src/controllers/UserController.h create mode 100644 backend/src/main.cpp create mode 100644 backend/src/models/Realm.h create mode 100644 backend/src/models/StreamKey.h create mode 100644 backend/src/services/AuthService.cpp create mode 100644 backend/src/services/AuthService.h create mode 100644 backend/src/services/CorsMiddleware.h create mode 100644 backend/src/services/DatabaseService.cpp create mode 100644 backend/src/services/DatabaseService.h create mode 100644 backend/src/services/OmeClient.h create mode 100644 backend/src/services/RedisHelper.cpp create mode 100644 backend/src/services/RedisHelper.h create mode 100644 backend/src/services/StatsService.cpp create mode 100644 backend/src/services/StatsService.h create mode 100644 database/init.sql create mode 100644 docker create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/package.json create mode 100644 frontend/src/app.css create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/api.js create mode 100644 frontend/src/lib/pgp.js create mode 100644 frontend/src/lib/stores/auth.js create mode 100644 frontend/src/lib/websocket.js create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/src/routes/[realm]/live/+page.svelte create mode 100644 frontend/src/routes/admin/+page.svelte create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/my-realms/+page.svelte create mode 100644 frontend/src/routes/profile/[username]/+page.svelte create mode 100644 frontend/src/routes/settings/+page.svelte create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 openresty/Dockerfile create mode 100644 openresty/lua/auth.lua create mode 100644 openresty/lua/redis_helper.lua create mode 100644 openresty/lua/stream_monitor.lua create mode 100644 openresty/nginx.conf create mode 100644 ovenmediaengine/Server.xml create mode 100644 text.txt diff --git a/.env b/.env new file mode 100644 index 0000000..3200623 --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +# Database +DB_PASSWORD=your-secure-password + +# JWT +JWT_SECRET=your-very-long-random-jwt-secret + +# OvenMediaEngine API +OME_API_TOKEN=your-ome-api-token + +# Application +APP_ENV=production \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..daa53c4 --- /dev/null +++ b/backend/.dockerignore @@ -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 \ No newline at end of file diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt new file mode 100644 index 0000000..c402dfc --- /dev/null +++ b/backend/CMakeLists.txt @@ -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) \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5541565 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/conanfile.txt b/backend/conanfile.txt new file mode 100644 index 0000000..9b8a760 --- /dev/null +++ b/backend/conanfile.txt @@ -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 diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..3c188ea --- /dev/null +++ b/backend/config.json @@ -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": {} +} \ No newline at end of file diff --git a/backend/src/admin_tool.cpp b/backend/src/admin_tool.cpp new file mode 100644 index 0000000..5babfdf --- /dev/null +++ b/backend/src/admin_tool.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include + +using namespace drogon; +using namespace drogon::orm; + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " -promote-admin " << std::endl; + return 1; + } + + std::string command = argv[1]; + + if (command != "-promote-admin" || argc != 3) { + std::cerr << "Usage: " << argv[0] << " -promote-admin " << 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(); + 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; + } +} \ No newline at end of file diff --git a/backend/src/common/utils.h b/backend/src/common/utils.h new file mode 100644 index 0000000..cabfa44 --- /dev/null +++ b/backend/src/common/utils.h @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace utils { + +using namespace drogon; + +// Result type for better error handling +template +using Result = std::variant; + +template +inline bool isOk(const Result& r) { return std::holds_alternative(r); } + +template +inline T& getValue(Result& r) { return std::get(r); } + +template +inline const std::string& getError(const Result& r) { return std::get(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 +inline void dbQuery(const std::string& query, + std::function onSuccess, + std::function onError, + Args&&... args) { + auto db = app().getDbClient(); + (*db << query << std::forward(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 +inline void dbJsonQuery(const std::string& query, + std::function transform, + std::function 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)... + ); +} + +// Thread pool executor with type constraints +template +requires std::invocable +inline void runAsync(F&& task) { + if (auto loop = app().getLoop()) { + loop->queueInLoop([task = std::forward(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 +inline std::optional getConfig(const std::string& path) { + try { + const auto& config = app().getCustomConfig(); + std::vector 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) { + return current.asString(); + } else if constexpr (std::is_same_v) { + return current.asInt(); + } else if constexpr (std::is_same_v) { + return current.asBool(); + } else if constexpr (std::is_same_v) { + return current.asDouble(); + } + } catch (...) { + return std::nullopt; + } + return std::nullopt; +} + +// Environment variable helper with fallback +template +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) { + return std::string(val); + } else if constexpr (std::is_same_v) { + try { return std::stoi(val); } catch (...) { return defaultValue; } + } else if constexpr (std::is_same_v) { + 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(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(duration).count(); + LOG_DEBUG << name_ << " took " << ms << "ms"; + } +}; + +#define TIMED_SCOPE(name) utils::ScopedTimer _timer(name) + +// WebSocket broadcast helper +template +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> 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 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 +inline Json::Value toJson(const T& obj); + +// Specializations for common types +template<> +inline Json::Value toJson(const std::map& m) { + Json::Value json; + for (const auto& [k, v] : m) { + json[k] = v; + } + return json; +} + +} // namespace utils \ No newline at end of file diff --git a/backend/src/controllers/AdminController.cpp b/backend/src/controllers/AdminController.cpp new file mode 100644 index 0000000..988c2a9 --- /dev/null +++ b/backend/src/controllers/AdminController.cpp @@ -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 &&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(row["id"].as()); + user["username"] = row["username"].as(); + user["isAdmin"] = row["is_admin"].as(); + user["isStreamer"] = row["is_streamer"].as(); + user["createdAt"] = row["created_at"].as(); + user["realmCount"] = static_cast(row["realm_count"].as()); + 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 &&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(row["id"].as()); + stream["name"] = row["name"].as(); + stream["streamKey"] = row["stream_key"].as(); + stream["viewerCount"] = static_cast(row["viewer_count"].as()); + stream["username"] = row["username"].as(); + 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 &&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 &&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 &&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")); + }; +} \ No newline at end of file diff --git a/backend/src/controllers/AdminController.h b/backend/src/controllers/AdminController.h new file mode 100644 index 0000000..7fb5f42 --- /dev/null +++ b/backend/src/controllers/AdminController.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include "../services/AuthService.h" + +using namespace drogon; + +class AdminController : public HttpController { +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 &&callback); + + void getActiveStreams(const HttpRequestPtr &req, + std::function &&callback); + + void disconnectStream(const HttpRequestPtr &req, + std::function &&callback, + const std::string &streamKey); + + void promoteToStreamer(const HttpRequestPtr &req, + std::function &&callback, + const std::string &userId); + + void demoteFromStreamer(const HttpRequestPtr &req, + std::function &&callback, + const std::string &userId); + +private: + UserInfo getUserFromRequest(const HttpRequestPtr &req); +}; \ No newline at end of file diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp new file mode 100644 index 0000000..05f51c7 --- /dev/null +++ b/backend/src/controllers/RealmController.cpp @@ -0,0 +1,600 @@ +#include "RealmController.h" +#include "../services/DatabaseService.h" +#include "../services/StatsService.h" +#include "../services/RedisHelper.h" +#include "../services/OmeClient.h" +#include +#include +#include +#include +#include +#include + +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& 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 &&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(row["id"].as()); + realm["name"] = row["name"].as(); + realm["streamKey"] = row["stream_key"].as(); + realm["isActive"] = row["is_active"].as(); + realm["isLive"] = row["is_live"].as(); + realm["viewerCount"] = static_cast(row["viewer_count"].as()); + realm["createdAt"] = row["created_at"].as(); + 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 &&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()) { + 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() >= 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(r4[0]["id"].as()); + 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 &&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(); + + 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 &&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(); + + // 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 &&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(r[0]["id"].as()); + realm["name"] = r[0]["name"].as(); + realm["streamKey"] = r[0]["stream_key"].as(); + realm["isActive"] = r[0]["is_active"].as(); + realm["isLive"] = r[0]["is_live"].as(); + realm["viewerCount"] = static_cast(r[0]["viewer_count"].as()); + realm["createdAt"] = r[0]["created_at"].as(); + realm["username"] = r[0]["username"].as(); + + 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 &&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 &&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(); + + // 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 &&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(); + 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 &&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(r[0]["id"].as()); + realm["name"] = r[0]["name"].as(); + realm["isLive"] = r[0]["is_live"].as(); + realm["viewerCount"] = static_cast(r[0]["viewer_count"].as()); + realm["username"] = r[0]["username"].as(); + realm["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); + + 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 &&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(); + realm["viewerCount"] = static_cast(row["viewer_count"].as()); + realm["username"] = row["username"].as(); + realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + 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 &&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 &&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(); + + 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(stats.uniqueViewers); + s["total_connections"] = static_cast(stats.totalConnections); + s["bytes_in"] = static_cast(stats.totalBytesIn); + s["bytes_out"] = static_cast(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(stats.protocolConnections.webrtc); + pc["hls"] = static_cast(stats.protocolConnections.hls); + pc["llhls"] = static_cast(stats.protocolConnections.llhls); + pc["dash"] = static_cast(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")); + }; +} \ No newline at end of file diff --git a/backend/src/controllers/RealmController.h b/backend/src/controllers/RealmController.h new file mode 100644 index 0000000..2477dcc --- /dev/null +++ b/backend/src/controllers/RealmController.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include "../services/AuthService.h" + +using namespace drogon; + +class RealmController : public HttpController { +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 &&callback); + + void createRealm(const HttpRequestPtr &req, + std::function &&callback); + + void getRealm(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void updateRealm(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void deleteRealm(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void regenerateRealmKey(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void getRealmByName(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmName); + + void getLiveRealms(const HttpRequestPtr &req, + std::function &&callback); + + void validateRealmKey(const HttpRequestPtr &req, + std::function &&callback, + const std::string &key); + + void getRealmStats(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void issueRealmViewerToken(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + + void getRealmStreamKey(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmId); + +private: + UserInfo getUserFromRequest(const HttpRequestPtr &req); +}; \ No newline at end of file diff --git a/backend/src/controllers/StreamController.cpp b/backend/src/controllers/StreamController.cpp new file mode 100644 index 0000000..fb7b3cd --- /dev/null +++ b/backend/src/controllers/StreamController.cpp @@ -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 +#include +#include +#include +#include +#include + +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> 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> StreamWebSocketController::tokenConnections_; +std::unordered_set StreamWebSocketController::connections_; + +void StreamController::health(const HttpRequestPtr &, + std::function &&callback) { + callback(jsonOk(json({ + {"status", "ok"}, + {"timestamp", Json::Int64(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count())} + }))); +} + +void StreamController::validateStreamKey(const HttpRequestPtr &, + std::function &&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 &&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() != 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 &&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(stats.uniqueViewers); + s["total_connections"] = static_cast(stats.totalConnections); + s["bytes_in"] = static_cast(stats.totalBytesIn); + s["bytes_out"] = static_cast(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(stats.protocolConnections.webrtc); + pc["hls"] = static_cast(stats.protocolConnections.hls); + pc["llhls"] = static_cast(stats.protocolConnections.llhls); + pc["dash"] = static_cast(stats.protocolConnections.dash); + + callback(jsonResp(json)); + } else { + callback(jsonError("Failed to retrieve stream stats")); + } + }); +} + +void StreamController::getActiveStreams(const HttpRequestPtr &, + std::function &&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 &&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 &&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 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 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 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 lock(connectionsMutex_); + for (const auto& conn : connections_) { + if (conn->connected()) { + conn->send(jsonStr); + } + } +} \ No newline at end of file diff --git a/backend/src/controllers/StreamController.h b/backend/src/controllers/StreamController.h new file mode 100644 index 0000000..6ce77be --- /dev/null +++ b/backend/src/controllers/StreamController.h @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +using namespace drogon; + +class StreamController : public HttpController { +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 &&callback); + + void validateStreamKey(const HttpRequestPtr &req, + std::function &&callback, + const std::string &key); + + void disconnectStream(const HttpRequestPtr &req, + std::function &&callback, + const std::string &streamId); + + void getStreamStats(const HttpRequestPtr &req, + std::function &&callback, + const std::string &streamKey); + + void getActiveStreams(const HttpRequestPtr &req, + std::function &&callback); + + void issueViewerToken(const HttpRequestPtr &req, + std::function &&callback, + const std::string &streamKey); + + void heartbeat(const HttpRequestPtr &req, + std::function &&callback, + const std::string &streamKey); +}; + +class StreamWebSocketController : public WebSocketController { +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> tokenConnections_; + static std::unordered_set connections_; +}; \ No newline at end of file diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp new file mode 100644 index 0000000..f3558ed --- /dev/null +++ b/backend/src/controllers/UserController.cpp @@ -0,0 +1,614 @@ +#include "UserController.h" +#include "../services/DatabaseService.h" +#include +#include +#include +#include +#include +#include + +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 &&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(userId); + callback(jsonResp(resp)); + } else { + callback(jsonError(error)); + } + }); +} + +void UserController::login(const HttpRequestPtr &req, + std::function &&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(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 &&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 &&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(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 &&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(r[0]["id"].as()); + resp["user"]["username"] = r[0]["username"].as(); + resp["user"]["isAdmin"] = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as(); + resp["user"]["isStreamer"] = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as(); + resp["user"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as(); + resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); + resp["user"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); + resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); + 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 &&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 &&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 &&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(); + } + + 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 &&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 &&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(); + key["fingerprint"] = row["fingerprint"].as(); + key["createdAt"] = row["created_at"].as(); + 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 &&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 &&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(); + resp["profile"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); + resp["profile"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); + resp["profile"]["createdAt"] = r[0]["created_at"].as(); + resp["profile"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as(); + resp["profile"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); + + 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 &&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(); + key["fingerprint"] = row["fingerprint"].as(); + key["createdAt"] = row["created_at"].as(); + 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")); + }; +} \ No newline at end of file diff --git a/backend/src/controllers/UserController.h b/backend/src/controllers/UserController.h new file mode 100644 index 0000000..7ec6c0c --- /dev/null +++ b/backend/src/controllers/UserController.h @@ -0,0 +1,67 @@ +#pragma once +#include +#include "../services/AuthService.h" + +using namespace drogon; + +class UserController : public HttpController { +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 &&callback); + + void login(const HttpRequestPtr &req, + std::function &&callback); + + void pgpChallenge(const HttpRequestPtr &req, + std::function &&callback); + + void pgpVerify(const HttpRequestPtr &req, + std::function &&callback); + + void getCurrentUser(const HttpRequestPtr &req, + std::function &&callback); + + void updateProfile(const HttpRequestPtr &req, + std::function &&callback); + + void updatePassword(const HttpRequestPtr &req, + std::function &&callback); + + void togglePgpOnly(const HttpRequestPtr &req, + std::function &&callback); + + void addPgpKey(const HttpRequestPtr &req, + std::function &&callback); + + void getPgpKeys(const HttpRequestPtr &req, + std::function &&callback); + + void uploadAvatar(const HttpRequestPtr &req, + std::function &&callback); + + void getProfile(const HttpRequestPtr &req, + std::function &&callback, + const std::string &username); + + void getUserPgpKeys(const HttpRequestPtr &req, + std::function &&callback, + const std::string &username); +private: + UserInfo getUserFromRequest(const HttpRequestPtr &req); +}; \ No newline at end of file diff --git a/backend/src/main.cpp b/backend/src/main.cpp new file mode 100644 index 0000000..ce81fa7 --- /dev/null +++ b/backend/src/main.cpp @@ -0,0 +1,105 @@ +#include +#include +#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 +#include +#include + +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; +} \ No newline at end of file diff --git a/backend/src/models/Realm.h b/backend/src/models/Realm.h new file mode 100644 index 0000000..c3b861b --- /dev/null +++ b/backend/src/models/Realm.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +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; +}; \ No newline at end of file diff --git a/backend/src/models/StreamKey.h b/backend/src/models/StreamKey.h new file mode 100644 index 0000000..d20d10b --- /dev/null +++ b/backend/src/models/StreamKey.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include + +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; +}; \ No newline at end of file diff --git a/backend/src/services/AuthService.cpp b/backend/src/services/AuthService.cpp new file mode 100644 index 0000000..0aac11d --- /dev/null +++ b/backend/src/services/AuthService.cpp @@ -0,0 +1,347 @@ +#include "AuthService.h" +#include "DatabaseService.h" +#include "RedisHelper.h" +#include +#include +#include + +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 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(); + + // 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 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(); + + 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(); + + if (!BCrypt::validatePassword(password, hash)) { + callback(false, "", UserInfo{}); + return; + } + + UserInfo user; + user.id = r[0]["id"].as(); + user.username = r[0]["username"].as(); + user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as(); + user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as(); + user.isPgpOnly = isPgpOnly; + user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); + user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); + user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); + + 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 callback) { + auto dbClient = app().getDbClient(); + + // Generate random challenge + auto bytes = drogon::utils::genRandomString(32); + std::string challenge = drogon::utils::base64Encode( + reinterpret_cast(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(); + 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 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(); + user.username = r[0]["username"].as(); + user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as(); + user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as(); + user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as(); + user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); + user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); + user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); + + 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 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(); + + 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"); + }; +} \ No newline at end of file diff --git a/backend/src/services/AuthService.h b/backend/src/services/AuthService.h new file mode 100644 index 0000000..79fc81b --- /dev/null +++ b/backend/src/services/AuthService.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include + +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 callback); + + // User login with password + void loginUser(const std::string& username, const std::string& password, + std::function callback); + + // User login with PGP (returns challenge) + void initiatePgpLogin(const std::string& username, + std::function callback); + + // Verify PGP signature + void verifyPgpLogin(const std::string& username, const std::string& signature, const std::string& challenge, + std::function 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 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_; +}; \ No newline at end of file diff --git a/backend/src/services/CorsMiddleware.h b/backend/src/services/CorsMiddleware.h new file mode 100644 index 0000000..365c762 --- /dev/null +++ b/backend/src/services/CorsMiddleware.h @@ -0,0 +1,76 @@ +#pragma once +#include + +namespace middleware { + +class CorsMiddleware { +public: + struct Config { + std::vector allowOrigins = {"*"}; + std::vector allowMethods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"}; + std::vector 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); + + 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& 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 \ No newline at end of file diff --git a/backend/src/services/DatabaseService.cpp b/backend/src/services/DatabaseService.cpp new file mode 100644 index 0000000..844f413 --- /dev/null +++ b/backend/src/services/DatabaseService.cpp @@ -0,0 +1,120 @@ +#include "DatabaseService.h" +#include "../services/RedisHelper.h" +#include +#include +#include +#include + +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 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(); + // 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 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 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); + }; +} \ No newline at end of file diff --git a/backend/src/services/DatabaseService.h b/backend/src/services/DatabaseService.h new file mode 100644 index 0000000..d9613a5 --- /dev/null +++ b/backend/src/services/DatabaseService.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +class DatabaseService { +public: + static DatabaseService& getInstance() { + static DatabaseService instance; + return instance; + } + + void initialize(); + + void getUserStreamKey(int64_t userId, + std::function callback); + + void updateUserStreamKey(int64_t userId, + const std::string& newKey, + std::function callback); + + void validateStreamKey(const std::string& key, + std::function callback); + +private: + DatabaseService() = default; + ~DatabaseService() = default; + DatabaseService(const DatabaseService&) = delete; + DatabaseService& operator=(const DatabaseService&) = delete; +}; \ No newline at end of file diff --git a/backend/src/services/OmeClient.h b/backend/src/services/OmeClient.h new file mode 100644 index 0000000..36a8480 --- /dev/null +++ b/backend/src/services/OmeClient.h @@ -0,0 +1,176 @@ +#pragma once +#include +#include +#include +#include + +// 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 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 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 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 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; + } +}; \ No newline at end of file diff --git a/backend/src/services/RedisHelper.cpp b/backend/src/services/RedisHelper.cpp new file mode 100644 index 0000000..7470732 --- /dev/null +++ b/backend/src/services/RedisHelper.cpp @@ -0,0 +1,311 @@ +#include "RedisHelper.h" +#include +#include +#include +#include +#include + +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 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(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 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 callback) { \ + executeAsync( \ + [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 callback) { + executeAsync( + [this, key, value, ttlSeconds]() { + _redis->setex(key, ttlSeconds, value); + return true; + }, + std::move(callback) + ); +} + +void RedisHelper::getAsync(const std::string &key, + std::function callback) { + executeAsync( + [this, key]() { + return _redis->get(key); + }, + std::move(callback) + ); +} + +void RedisHelper::delAsync(const std::string &key, + std::function callback) { + executeAsync( + [this, key]() { + return _redis->del(key) > 0; + }, + std::move(callback) + ); +} + +void RedisHelper::saddAsync(const std::string &setName, + const std::string &value, + std::function callback) { + executeAsync( + [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 callback) { + executeAsync( + [this, setName, value]() { + return _redis->srem(setName, value) > 0; + }, + std::move(callback) + ); +} + +void RedisHelper::smembersAsync(const std::string &setName, + std::function)> callback) { + executeAsync>( + [this, setName]() { + std::vector members; + _redis->smembers(setName, std::back_inserter(members)); + return members; + }, + std::move(callback) + ); +} + +void RedisHelper::keysAsync(const std::string &pattern, + std::function)> callback) { + executeAsync>( + [this, pattern]() { + std::vector keys; + _redis->keys(pattern, std::back_inserter(keys)); + return keys; + }, + std::move(callback) + ); +} + +void RedisHelper::expireAsync(const std::string &key, + long ttlSeconds, + std::function callback) { + executeAsync( + [this, key, ttlSeconds]() { + return _redis->expire(key, ttlSeconds); + }, + std::move(callback) + ); +} + +// Sync versions for compatibility +std::unique_ptr 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(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 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 \ No newline at end of file diff --git a/backend/src/services/RedisHelper.h b/backend/src/services/RedisHelper.h new file mode 100644 index 0000000..f483e9d --- /dev/null +++ b/backend/src/services/RedisHelper.h @@ -0,0 +1,190 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace services { + +class RedisHelper { +public: + // Singleton accessor + static RedisHelper &instance(); + + // Generic async execute wrapper + template + void executeAsync(std::function redisOp, Callback&& callback) { + executeInThreadPool([this, redisOp = std::move(redisOp), + callback = std::forward(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 callback); + + // Async GET + void getAsync(const std::string &key, + std::function callback); + + // Async DEL + void delAsync(const std::string &key, + std::function callback); + + // Async SADD + void saddAsync(const std::string &setName, + const std::string &value, + std::function callback); + + // Async SREM + void sremAsync(const std::string &setName, + const std::string &value, + std::function callback); + + // Async SMEMBERS + void smembersAsync(const std::string &setName, + std::function)> callback); + + // Async KEYS + void keysAsync(const std::string &pattern, + std::function)> callback); + + // Async EXPIRE + void expireAsync(const std::string &key, + long ttlSeconds, + std::function callback); + + // Sync versions for compatibility + std::unique_ptr 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 callback) { + instance().setexAsync(key, value, ttl, callback); + } + + static void getKeyAsync(const std::string &key, + std::function callback) { + instance().getAsync(key, [callback](sw::redis::OptionalString val) { + callback(val.has_value() ? val.value() : ""); + }); + } + + static void deleteKeyAsync(const std::string &key, + std::function callback) { + instance().delAsync(key, callback); + } + + // Execute arbitrary command asynchronously (deprecated) + void executeAsync(const std::string &command, + std::function callback); + +private: + RedisHelper(); + ~RedisHelper(); + RedisHelper(const RedisHelper &) = delete; + RedisHelper &operator=(const RedisHelper &) = delete; + + void ensureConnected(); + void executeInThreadPool(std::function task); + std::string getRedisHost() const; + int getRedisPort() const; + + std::unique_ptr _redis; + bool _initialized; + std::mutex _initMutex; +}; + +} // namespace services + +// Compatibility layer for existing code +class RedisHelper { +public: + using RedisConnectionPtr = std::unique_ptr; + + 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 callback) { + services::RedisHelper::storeKeyAsync(key, value, ttl, callback); + } + + static void getKeyAsync(const std::string& key, + std::function callback) { + services::RedisHelper::getKeyAsync(key, callback); + } + + static void deleteKeyAsync(const std::string& key, + std::function callback) { + services::RedisHelper::deleteKeyAsync(key, callback); + } + + static void executeAsync(const std::string& command, + std::function callback) { + services::RedisHelper::instance().executeAsync(command, callback); + } +}; \ No newline at end of file diff --git a/backend/src/services/StatsService.cpp b/backend/src/services/StatsService.cpp new file mode 100644 index 0000000..f486a1b --- /dev/null +++ b/backend/src/services/StatsService.cpp @@ -0,0 +1,356 @@ +#include "StatsService.h" +#include "../controllers/StreamController.h" +#include "../services/RedisHelper.h" +#include "../services/OmeClient.h" +#include +#include + +using namespace drogon; + +// Macro to simplify JSON integer assignments +#define JSON_INT(json, field, value) json[field] = static_cast(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 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& 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 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( + 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::system_clock::now() - stats.lastConnectionDrop).count(); + if (timeSinceDrop < 60) { + JSON_INT(json, "last_connection_drop", + std::chrono::duration_cast( + 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 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); + } + }); + } +} \ No newline at end of file diff --git a/backend/src/services/StatsService.h b/backend/src/services/StatsService.h new file mode 100644 index 0000000..4034d9b --- /dev/null +++ b/backend/src/services/StatsService.h @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +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 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 callback); + void updateRealmLiveStatus(const std::string& streamKey, const StreamStats& stats); + + std::atomic running_{false}; + std::optional timerId_; + std::chrono::seconds pollInterval_{2}; // Poll every 2 seconds +}; \ No newline at end of file diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..d4ec620 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,110 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + is_admin BOOLEAN DEFAULT false, + is_streamer BOOLEAN DEFAULT false, + is_pgp_only BOOLEAN DEFAULT false, + pgp_only_enabled_at TIMESTAMP WITH TIME ZONE, + bio TEXT DEFAULT '', + avatar_url VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create pgp_keys table +CREATE TABLE IF NOT EXISTS pgp_keys ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + fingerprint VARCHAR(40) UNIQUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create realms table (removed display_name and description) +CREATE TABLE IF NOT EXISTS realms ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) UNIQUE NOT NULL, + stream_key VARCHAR(64) UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT true, + is_live BOOLEAN DEFAULT false, + viewer_count INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create stream_keys table (deprecated, kept for compatibility) +CREATE TABLE IF NOT EXISTS stream_keys ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key VARCHAR(64) UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_is_streamer ON users(is_streamer); +CREATE INDEX idx_users_is_pgp_only ON users(is_pgp_only); +CREATE INDEX idx_pgp_keys_user_id ON pgp_keys(user_id); +CREATE INDEX idx_pgp_keys_fingerprint ON pgp_keys(fingerprint); +CREATE INDEX idx_realms_user_id ON realms(user_id); +CREATE INDEX idx_realms_name ON realms(name); +CREATE INDEX idx_realms_stream_key ON realms(stream_key); +CREATE INDEX idx_realms_is_live ON realms(is_live); +CREATE INDEX idx_stream_keys_user_id ON stream_keys(user_id); +CREATE INDEX idx_stream_keys_key ON stream_keys(key) WHERE is_active = true; +CREATE INDEX idx_stream_keys_active ON stream_keys(is_active); + +-- Create updated_at trigger +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_realms_updated_at BEFORE UPDATE ON realms + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_stream_keys_updated_at BEFORE UPDATE ON stream_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Create function to deactivate old keys when a new one is created +CREATE OR REPLACE FUNCTION deactivate_old_stream_keys() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_active = true THEN + UPDATE stream_keys + SET is_active = false + WHERE user_id = NEW.user_id + AND id != NEW.id + AND is_active = true; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER deactivate_old_keys AFTER INSERT OR UPDATE ON stream_keys + FOR EACH ROW EXECUTE FUNCTION deactivate_old_stream_keys(); + +-- Add constraint to ensure pgp_only_enabled_at is set when is_pgp_only is true +CREATE OR REPLACE FUNCTION check_pgp_only_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_pgp_only = true AND NEW.pgp_only_enabled_at IS NULL THEN + NEW.pgp_only_enabled_at = CURRENT_TIMESTAMP; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER ensure_pgp_only_timestamp BEFORE INSERT OR UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION check_pgp_only_timestamp(); \ No newline at end of file diff --git a/docker b/docker new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07901fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,134 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: streaming + POSTGRES_USER: streamuser + POSTGRES_PASSWORD: streampass # Fixed: hardcoded for consistency + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql + - ./scripts:/scripts:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U streamuser -d streaming"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend + + ovenmediaengine: + image: airensoft/ovenmediaengine:latest + ports: + - "1935:1935" # RTMP + - "9999:9999/udp" # SRT + - "8088:8080" # HLS/LLHLS + - "8081:8081" # API (internal) + - "3333:3333" # WebRTC Signaling + - "3478:3478" # WebRTC ICE + - "10000-10009:10000-10009/udp" # WebRTC Candidates + volumes: + - ./ovenmediaengine/Server.xml:/opt/ovenmediaengine/bin/origin_conf/Server.xml + - ome_logs:/var/log/ovenmediaengine + environment: + OME_API_PORT: 8081 + OME_API_ACCESS_TOKEN: your-api-token + networks: + - backend + - frontend + + drogon-backend: + build: ./backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + DB_HOST: postgres + DB_NAME: streaming + DB_USER: streamuser + DB_PASS: streampass # Fixed: matching postgres password + REDIS_HOST: redis + REDIS_PORT: 6379 + JWT_SECRET: your-jwt-secret + OME_API_URL: http://ovenmediaengine:8081 + OME_API_TOKEN: your-api-token + volumes: + - ./backend/config.json:/app/config.json + - uploads:/app/uploads + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - backend + + openresty: + build: ./openresty + ports: + - "80:80" + - "443:443" + depends_on: + drogon-backend: + condition: service_healthy + ovenmediaengine: + condition: service_started + redis: + condition: service_healthy + environment: + REDIS_HOST: redis + REDIS_PORT: 6379 + BACKEND_URL: http://drogon-backend:8080 + OME_URL: http://ovenmediaengine:8081 + volumes: + - ./openresty/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf + - ./openresty/lua:/usr/local/openresty/nginx/lua + - uploads:/app/uploads:ro # Mount uploads volume to the same path + networks: + - frontend + - backend + + sveltekit: + build: ./frontend + depends_on: + openresty: + condition: service_started + environment: + # Fixed: Added VITE_ prefix for client-side access + VITE_API_URL: http://localhost/api + VITE_WS_URL: ws://localhost/ws + VITE_STREAM_PORT: 8088 + # Server-side only variables (no prefix needed) + NODE_ENV: production + networks: + - frontend + +networks: + frontend: + driver: bridge + backend: + driver: bridge + +volumes: + postgres_data: + redis_data: + ome_logs: + uploads: # Named volume for uploads \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..fe6a775 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2ac7439 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,41 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci + +# Copy source files +COPY . . + +# Set environment variables for build +ENV VITE_API_URL=http://localhost/api +ENV VITE_WS_URL=ws://localhost/ws +ENV VITE_STREAM_PORT=8088 + +# Generate .svelte-kit directory +RUN npx svelte-kit sync + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/build ./build +COPY --from=builder /app/package*.json ./ + +# Install production dependencies only +RUN npm ci --omit=dev + +# Expose port +EXPOSE 3000 + +# Set environment to production +ENV NODE_ENV=production + +CMD ["node", "build"] \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d5c8d64 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "streaming-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "start": "node build" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^4.0.1", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/node": "^20.11.0", + "svelte": "^4.2.0", + "svelte-check": "^3.6.0", + "tslib": "^2.6.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.7.2", + "hls.js": "^1.6.7", + "mdb-ui-kit": "^9.1.0", + "openpgp": "^5.11.0", + "ovenplayer": "^0.10.43" + }, + "type": "module" +} \ No newline at end of file diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..1dbfd04 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,323 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #561d5e; + --black: #000; + --white: #fff; + --gray: #888; + --light-gray: #f5f5f5; + --error: #dc3545; + --success: #28a745; + --border: #333; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--black); + color: var(--white); + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.auth-container { + max-width: 400px; + margin: 4rem auto; + padding: 2rem; + background: #111; + border-radius: 8px; + border: 1px solid var(--border); +} + +h1, h2, h3 { + margin-bottom: 1rem; + color: var(--white); +} + +.form-group { + margin-bottom: 1.5rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +input[type="text"], +input[type="password"], +textarea, +select { + width: 100%; + padding: 0.75rem; + background: var(--black); + color: var(--white); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--primary); +} + +button, .btn { + padding: 0.75rem 1.5rem; + background: var(--primary); + color: var(--white); + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + text-decoration: none; + display: inline-block; + transition: opacity 0.2s; +} + +button:hover:not(:disabled), +.btn:hover { + opacity: 0.9; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + border: 1px solid var(--primary); +} + +.btn-danger { + background: var(--error); +} + +.btn-block { + width: 100%; + display: block; +} + +.error { + color: var(--error); + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.success { + color: var(--success); + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.nav { + background: #111; + border-bottom: 1px solid var(--border); + padding: 1rem 0; + margin-bottom: 2rem; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand { + font-size: 1.25rem; + font-weight: 600; + color: var(--white); + text-decoration: none; +} + + + +.card { + background: #111; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; +} + +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; +} + +.avatar-small { + width: 40px; + height: 40px; +} + +.file-input-wrapper { + position: relative; + overflow: hidden; + display: inline-block; +} + +.file-input-wrapper input[type="file"] { + position: absolute; + left: -9999px; +} + +.pgp-key { + font-family: monospace; + font-size: 0.8rem; + background: rgba(255, 255, 255, 0.05); + padding: 1rem; + border-radius: 4px; + white-space: pre-wrap; + word-break: break-all; +} + +.fingerprint { + font-family: monospace; + font-size: 0.9rem; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.table th { + font-weight: 600; + color: var(--primary); +} + +.badge { + display: inline-block; + padding: 0.25rem 0.75rem; + background: var(--primary); + color: var(--white); + border-radius: 12px; + font-size: 0.85rem; +} + +.badge-admin { + background: var(--error); +} + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: #111; + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.modal-close { + background: none; + border: none; + color: var(--gray); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; +} + +.modal-close:hover { + color: var(--white); +} + +/* Ensure stream pages have black background */ +html { + background: var(--black); +} + +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + .nav-links { + flex-wrap: wrap; + gap: 1rem; + } + + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +.avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + background: var(--gray); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 600; + color: var(--white); + overflow: hidden; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-small { + width: 40px; + height: 40px; + font-size: 1rem; +} \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..d5eaa50 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,23 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + user?: { + id: number; + username: string; + }; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } + + interface Window { + OvenPlayer: any; + Hls: any; + } +} + +export {}; \ No newline at end of file diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..c8573a2 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,29 @@ + + + + + + + Live Streaming Platform + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..31c317c --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,36 @@ +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api'; + +async function fetchAPI(endpoint, options = {}) { + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + credentials: 'include', // Always include credentials + }); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + return response.json(); +} + +export async function getStreamKey() { + return fetchAPI('/stream/key'); +} + +export async function regenerateStreamKey() { + return fetchAPI('/stream/key/regenerate', { + method: 'POST', + }); +} + +export async function validateStreamKey(key) { + return fetchAPI(`/stream/validate/${key}`); +} + +export async function getStreamStats(streamKey) { + return fetchAPI(`/stream/stats/${streamKey}`); +} \ No newline at end of file diff --git a/frontend/src/lib/pgp.js b/frontend/src/lib/pgp.js new file mode 100644 index 0000000..061aee2 --- /dev/null +++ b/frontend/src/lib/pgp.js @@ -0,0 +1,99 @@ +// Client-side PGP utilities - wraps openpgp for browser-only usage + +export async function generateKeyPair(username, passphrase = '') { + if (typeof window === 'undefined') { + throw new Error('PGP operations can only be performed in the browser'); + } + + const { generateKey, readKey } = await import('openpgp'); + + const { privateKey, publicKey } = await generateKey({ + type: 'rsa', + rsaBits: 2048, + userIDs: [{ name: username }], + passphrase + }); + + const key = await readKey({ armoredKey: publicKey }); + const fingerprint = key.getFingerprint(); + + return { + privateKey, + publicKey, + fingerprint + }; +} + +export async function getFingerprint(publicKey) { + if (typeof window === 'undefined') return null; + + try { + const { readKey } = await import('openpgp'); + const key = await readKey({ armoredKey: publicKey }); + return key.getFingerprint(); + } catch (error) { + console.error('Error getting fingerprint:', error); + return null; + } +} + +export async function signMessage(message, privateKeyArmored, passphrase = '') { + if (typeof window === 'undefined') { + throw new Error('PGP operations can only be performed in the browser'); + } + + const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp'); + + const privateKey = await decryptKey({ + privateKey: await readPrivateKey({ armoredKey: privateKeyArmored }), + passphrase + }); + + const unsignedMessage = await createMessage({ text: message }); + const signature = await sign({ + message: unsignedMessage, + signingKeys: privateKey, + detached: true + }); + + return signature; +} + +export async function verifySignature(message, signature, publicKeyArmored) { + if (typeof window === 'undefined') return false; + + try { + const { readKey, readSignature, createMessage, verify } = await import('openpgp'); + + const publicKey = await readKey({ armoredKey: publicKeyArmored }); + const signatureObj = await readSignature({ armoredSignature: signature }); + const messageObj = await createMessage({ text: message }); + + const verificationResult = await verify({ + message: messageObj, + signature: signatureObj, + verificationKeys: publicKey + }); + + const { verified } = verificationResult.signatures[0]; + return await verified; + } catch (error) { + console.error('Signature verification error:', error); + return false; + } +} + +export function storePrivateKey(privateKey) { + if (typeof window === 'undefined') return; + localStorage.setItem('pgp_private_key', privateKey); +} + +export function getStoredPrivateKey() { + if (typeof window === 'undefined') return null; + return localStorage.getItem('pgp_private_key'); +} + +export function removeStoredPrivateKey() { + if (typeof window === 'undefined') return; + localStorage.removeItem('pgp_private_key'); +} \ No newline at end of file diff --git a/frontend/src/lib/stores/auth.js b/frontend/src/lib/stores/auth.js new file mode 100644 index 0000000..29b2e21 --- /dev/null +++ b/frontend/src/lib/stores/auth.js @@ -0,0 +1,128 @@ +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; + +function createAuthStore() { + const { subscribe, set, update } = writable({ + user: null, + token: null, + loading: true + }); + + return { + subscribe, + + async init() { + if (!browser) return; + + const token = localStorage.getItem('auth_token'); + if (!token) { + set({ user: null, token: null, loading: false }); + return; + } + + try { + const response = await fetch('/api/user/me', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + set({ user: data.user, token, loading: false }); + } else { + localStorage.removeItem('auth_token'); + set({ user: null, token: null, loading: false }); + } + } catch (error) { + console.error('Auth init error:', error); + set({ user: null, token: null, loading: false }); + } + }, + + async login(credentials) { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + localStorage.setItem('auth_token', data.token); + set({ user: data.user, token: data.token, loading: false }); + goto('/'); + return { success: true }; + } + + return { success: false, error: data.error || 'Invalid credentials' }; + }, + + async loginWithPgp(username, signature, challenge) { + const response = await fetch('/api/auth/pgp-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, signature, challenge }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + localStorage.setItem('auth_token', data.token); + set({ user: data.user, token: data.token, loading: false }); + goto('/'); + return { success: true }; + } + + return { success: false, error: data.error || 'Invalid signature' }; + }, + + async register(userData) { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + return { success: true, userId: data.userId }; + } + + return { success: false, error: data.error || 'Registration failed' }; + }, + + updateUser(userData) { + update(state => ({ + ...state, + user: userData + })); + }, + + logout() { + localStorage.removeItem('auth_token'); + set({ user: null, token: null, loading: false }); + goto('/login'); + } + }; +} + +export const auth = createAuthStore(); + +export const isAuthenticated = derived( + auth, + $auth => !!$auth.user +); + +export const isAdmin = derived( + auth, + $auth => $auth.user?.isAdmin || false +); + +export const isStreamer = derived( + auth, + $auth => $auth.user?.isStreamer || false +); \ No newline at end of file diff --git a/frontend/src/lib/websocket.js b/frontend/src/lib/websocket.js new file mode 100644 index 0000000..7cb9430 --- /dev/null +++ b/frontend/src/lib/websocket.js @@ -0,0 +1,56 @@ +let ws = null; +let reconnectTimeout = null; + +const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws'; + +export function connectWebSocket(onMessage) { + if (ws?.readyState === WebSocket.OPEN) return; + + // WebSocket doesn't support withCredentials, but cookies are sent automatically + // on same-origin requests + ws = new WebSocket(`${WS_URL}/stream`); + + ws.onopen = () => { + console.log('WebSocket connected'); + ws?.send(JSON.stringify({ type: 'subscribe' })); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + onMessage(data); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + ws.onclose = () => { + console.log('WebSocket disconnected'); + // Reconnect after 5 seconds + reconnectTimeout = setTimeout(() => { + connectWebSocket(onMessage); + }, 5000); + }; +} + +export function disconnectWebSocket() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + + if (ws) { + ws.close(); + ws = null; + } +} + +export function sendMessage(message) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } +} \ No newline at end of file diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..4a5aea5 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,228 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..8e1d3c2 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,204 @@ + + + + +
+
+

Live Streams

+

Watch your favorite streamers live

+
+ + {#if loading} +
+

Loading streams...

+
+ {:else if streams.length === 0} +
+
📺
+

No streams live right now

+

Check back later or become a streamer yourself!

+
+ {:else} + + {/if} +
\ No newline at end of file diff --git a/frontend/src/routes/[realm]/live/+page.svelte b/frontend/src/routes/[realm]/live/+page.svelte new file mode 100644 index 0000000..4aeb7b5 --- /dev/null +++ b/frontend/src/routes/[realm]/live/+page.svelte @@ -0,0 +1,669 @@ + + + + +{#if loading} +
+

Loading stream...

+
+{:else if error && !realm} +
+

Stream Not Found

+

{error}

+ Back to Home +
+{:else if realm} +
+
+
+
+
+
+
+
+
+
+ +
+
+

{realm.name}

+
+ {#if realm.avatarUrl} + {realm.username} + {:else} +
+ {/if} +
+
{realm.username}
+
+ {realm.viewerCount} {realm.viewerCount === 1 ? 'viewer' : 'viewers'} +
+
+
+
+
+
+ + +
+{/if} + +{#if message} +
+ {message} +
+{/if} \ No newline at end of file diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..bd2f550 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,385 @@ + + + + +
+
+

Admin Dashboard

+ +
+ + {#if message} +
{message}
+ {/if} + + {#if error} +
{error}
+ {/if} + +
+
+
{users.length}
+
Total Users
+
+
+
{users.filter(u => u.isAdmin).length}
+
Admins
+
+
+
{users.filter(u => u.isStreamer).length}
+
Streamers
+
+
+
{streams.length}
+
Active Streams
+
+
+ +
+
+ + +
+
+ + {#if loading} +

Loading...

+ {:else if activeTab === 'users'} +
+
+ + + + + + + + + + + + + {#each users as user} + + + + + + + + + {/each} + +
IDUsernameRolesRealmsCreatedActions
{user.id} + + {user.username} + + +
+ {#if user.isAdmin} + Admin + {/if} + {#if user.isStreamer} + Streamer + {/if} + {#if !user.isAdmin && !user.isStreamer} + User + {/if} +
+
{user.realmCount || 0}{formatDate(user.createdAt)} +
+ + View + + {#if !user.isAdmin} + {#if !user.isStreamer} + + {:else} + + {/if} + {/if} +
+
+
+
+ {:else if activeTab === 'streams'} +
+ {#if streams.length > 0} +
+ + + + + + + + + + + + {#each streams as stream} + + + + + + + + {/each} + +
RealmStreamerStream KeyViewersActions
+ + {stream.name} + + {stream.username} + {stream.streamKey} + {stream.viewerCount} + +
+
+ {:else} +

No active streams

+ {/if} +
+ {/if} +
\ No newline at end of file diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..53f8987 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,534 @@ + + +
+ {#if showGeneratedKeys} +

Your PGP Keys

+

+ Important: Save your private key securely. You will need it to login with PGP. +

+ +
+ +