Replace master branch with local files

This commit is contained in:
doomtube 2025-08-03 21:53:15 -04:00
commit 875a53f499
60 changed files with 21637 additions and 0 deletions

11
.env Normal file
View file

@ -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

37
backend/.dockerignore Normal file
View file

@ -0,0 +1,37 @@
# Build artifacts
build/
cmake-build-*/
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
Makefile
# Conan files
conanfile.txt
conanfile.py
conan.lock
conanbuildinfo.*
conaninfo.txt
CMakeUserPresets.json
.conan/
.conan2/
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Compiled objects
*.o
*.a
*.so
*.dylib
# Executables
streaming-backend

107
backend/CMakeLists.txt Normal file
View file

@ -0,0 +1,107 @@
cmake_minimum_required(VERSION 3.20)
project(streaming-backend)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Use pkg-config to find libraries
find_package(PkgConfig REQUIRED)
# Find dependencies
find_package(Drogon CONFIG REQUIRED)
find_package(PostgreSQL REQUIRED)
# Find Redis dependencies
pkg_check_modules(HIREDIS REQUIRED hiredis)
pkg_check_modules(REDIS_PLUS_PLUS redis++)
# Manual fallback for redis++
if(NOT REDIS_PLUS_PLUS_FOUND)
find_path(REDIS_PLUS_PLUS_INCLUDE_DIR sw/redis++/redis++.h
PATHS /usr/local/include /usr/include
)
find_library(REDIS_PLUS_PLUS_LIBRARY redis++
PATHS /usr/local/lib /usr/lib /usr/lib/x86_64-linux-gnu
)
if(REDIS_PLUS_PLUS_INCLUDE_DIR AND REDIS_PLUS_PLUS_LIBRARY)
set(REDIS_PLUS_PLUS_FOUND TRUE)
set(REDIS_PLUS_PLUS_INCLUDE_DIRS ${REDIS_PLUS_PLUS_INCLUDE_DIR})
set(REDIS_PLUS_PLUS_LIBRARIES ${REDIS_PLUS_PLUS_LIBRARY})
else()
message(FATAL_ERROR "redis++ not found")
endif()
endif()
# Find bcrypt library
find_path(BCRYPT_INCLUDE_DIR bcrypt/BCrypt.hpp
PATHS /usr/local/include /usr/include
)
find_library(BCRYPT_LIBRARY bcrypt
PATHS /usr/local/lib /usr/lib /usr/lib/x86_64-linux-gnu
)
if(NOT BCRYPT_INCLUDE_DIR OR NOT BCRYPT_LIBRARY)
message(FATAL_ERROR "bcrypt not found")
endif()
# Find jwt-cpp (header-only)
find_path(JWT_CPP_INCLUDE_DIR jwt-cpp/jwt.h
PATHS /usr/local/include /usr/include
)
if(NOT JWT_CPP_INCLUDE_DIR)
message(FATAL_ERROR "jwt-cpp not found")
endif()
# Source files
set(SOURCES
src/main.cpp
src/controllers/StreamController.cpp
src/controllers/UserController.cpp
src/controllers/AdminController.cpp
src/controllers/RealmController.cpp
src/services/DatabaseService.cpp
src/services/StatsService.cpp
src/services/RedisHelper.cpp
src/services/AuthService.cpp
)
# Create executable
add_executable(${PROJECT_NAME} ${SOURCES})
# Include directories
target_include_directories(${PROJECT_NAME}
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${BCRYPT_INCLUDE_DIR}
${JWT_CPP_INCLUDE_DIR}
SYSTEM PRIVATE
${HIREDIS_INCLUDE_DIRS}
${REDIS_PLUS_PLUS_INCLUDE_DIRS}
)
# Link libraries
target_link_libraries(${PROJECT_NAME}
PRIVATE
Drogon::Drogon
PostgreSQL::PostgreSQL
${REDIS_PLUS_PLUS_LIBRARIES}
${HIREDIS_LIBRARIES}
${BCRYPT_LIBRARY}
pthread
)
# Compile options
target_compile_options(${PROJECT_NAME}
PRIVATE
${HIREDIS_CFLAGS_OTHER}
${REDIS_PLUS_PLUS_CFLAGS_OTHER}
-Wall
-Wextra
-Wpedantic
-Wno-pedantic # Suppress pedantic warnings from third-party headers
)
# Build admin tool
add_executable(admin-tool src/admin_tool.cpp)
target_link_libraries(admin-tool PRIVATE Drogon::Drogon PostgreSQL::PostgreSQL)

107
backend/Dockerfile Normal file
View file

@ -0,0 +1,107 @@
FROM drogonframework/drogon:latest
WORKDIR /app
# Install additional dependencies including redis-plus-plus dev package if available
RUN apt-get update && apt-get install -y \
libpq-dev \
postgresql-client \
pkg-config \
git \
cmake \
libhiredis-dev \
curl \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Try to install redis-plus-plus from package manager first
RUN apt-get update && \
(apt-get install -y libredis++-dev || echo "Package not available") && \
rm -rf /var/lib/apt/lists/*
# If package not available, build from source
RUN if ! pkg-config --exists redis++; then \
echo "Building redis-plus-plus from source..." && \
git clone --depth 1 https://github.com/sewenew/redis-plus-plus.git && \
cd redis-plus-plus && \
mkdir build && \
cd build && \
cmake -DCMAKE_BUILD_TYPE=Release \
-DREDIS_PLUS_PLUS_CXX_STANDARD=17 \
-DREDIS_PLUS_PLUS_BUILD_TEST=OFF \
-DREDIS_PLUS_PLUS_BUILD_STATIC=OFF \
-DCMAKE_INSTALL_PREFIX=/usr/local .. && \
make -j$(nproc) && \
make install && \
cd ../.. && \
rm -rf redis-plus-plus; \
fi
# Install bcrypt library
RUN git clone --depth 1 https://github.com/trusch/libbcrypt.git && \
cd libbcrypt && \
mkdir build && \
cd build && \
cmake .. && \
make -j$(nproc) && \
make install && \
cd ../.. && \
rm -rf libbcrypt
# Install jwt-cpp (header-only)
RUN git clone --depth 1 https://github.com/Thalhammer/jwt-cpp.git && \
cd jwt-cpp && \
mkdir build && \
cd build && \
cmake .. && \
make install && \
cd ../.. && \
rm -rf jwt-cpp
# Update library cache - this is critical!
RUN ldconfig
# Copy source files
COPY CMakeLists.txt ./
COPY src/ src/
# Clean any existing build artifacts
RUN rm -rf build CMakeCache.txt
# Create clean build directory
RUN mkdir -p build
# Build the application with RPATH set correctly
RUN cd build && \
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_RPATH="/usr/local/lib" \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=TRUE && \
cmake --build . -j$(nproc)
# Copy config
COPY config.json .
# Create uploads directory with proper permissions
# Using nobody user's UID/GID (65534) for consistency with nginx
RUN mkdir -p /app/uploads/avatars && \
chown -R 65534:65534 /app/uploads && \
chmod -R 755 /app/uploads
# Ensure libraries are available
ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
# Add a startup script to check dependencies and create directories
RUN echo '#!/bin/bash\n\
echo "Checking library dependencies..."\n\
ldd ./build/streaming-backend\n\
echo "Ensuring upload directories exist with proper permissions..."\n\
mkdir -p /app/uploads/avatars\n\
chown -R 65534:65534 /app/uploads\n\
chmod -R 755 /app/uploads\n\
echo "Starting application..."\n\
exec ./build/streaming-backend' > start.sh && \
chmod +x start.sh
EXPOSE 8080
CMD ["./start.sh"]

11
backend/conanfile.txt Normal file
View file

@ -0,0 +1,11 @@
[requires]
redis-plus-plus/1.3.13
hiredis/1.2.0
[options]
redis-plus-plus/*:shared=True
hiredis/*:shared=True
[generators]
CMakeDeps
CMakeToolchain

43
backend/config.json Normal file
View file

@ -0,0 +1,43 @@
{
"listeners": [
{
"address": "0.0.0.0",
"port": 8080,
"https": false
}
],
"db_clients": [
{
"name": "default",
"rdbms": "postgresql",
"host": "postgres",
"port": 5432,
"dbname": "streaming",
"user": "streamuser",
"passwd": "streampass",
"is_fast": false,
"connection_number": 10
}
],
"app": {
"threads_num": 0,
"enable_session": true,
"session_timeout": 1200,
"document_root": "",
"upload_path": "./uploads",
"client_max_body_size": "100M",
"enable_brotli": true,
"enable_gzip": true,
"log_level": "DEBUG"
},
"redis": {
"host": "redis",
"port": 6379
},
"ome": {
"api_url": "http://ovenmediaengine:8081",
"api_token": "your-api-token"
},
"plugins": [],
"custom_config": {}
}

View file

@ -0,0 +1,71 @@
#include <drogon/drogon.h>
#include <drogon/orm/DbClient.h>
#include <iostream>
#include <cstdlib>
using namespace drogon;
using namespace drogon::orm;
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "Usage: " << argv[0] << " -promote-admin <username>" << std::endl;
return 1;
}
std::string command = argv[1];
if (command != "-promote-admin" || argc != 3) {
std::cerr << "Usage: " << argv[0] << " -promote-admin <username>" << std::endl;
return 1;
}
std::string username = argv[2];
// Get database config from environment or use defaults
std::string dbHost = std::getenv("DB_HOST") ? std::getenv("DB_HOST") : "postgres";
std::string dbName = std::getenv("DB_NAME") ? std::getenv("DB_NAME") : "streaming";
std::string dbUser = std::getenv("DB_USER") ? std::getenv("DB_USER") : "streamuser";
std::string dbPass = std::getenv("DB_PASS") ? std::getenv("DB_PASS") : "streampass";
// Create database client directly
auto dbClient = DbClient::newPgClient(
"host=" + dbHost + " port=5432 dbname=" + dbName +
" user=" + dbUser + " password=" + dbPass,
1 // connection number
);
try {
// Check if user exists
auto result = dbClient->execSqlSync(
"SELECT id, username, is_admin FROM users WHERE username = $1",
username
);
if (result.empty()) {
std::cerr << "Error: User '" << username << "' not found." << std::endl;
return 1;
}
bool isAdmin = result[0]["is_admin"].as<bool>();
if (isAdmin) {
std::cout << "User '" << username << "' is already an admin." << std::endl;
return 0;
}
// Promote to admin
dbClient->execSqlSync(
"UPDATE users SET is_admin = true WHERE username = $1",
username
);
std::cout << "Successfully promoted '" << username << "' to admin." << std::endl;
return 0;
} catch (const DrogonDbException& e) {
std::cerr << "Database error: " << e.base().what() << std::endl;
return 1;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}

243
backend/src/common/utils.h Normal file
View file

@ -0,0 +1,243 @@
#pragma once
#include <drogon/drogon.h>
#include <drogon/orm/DbClient.h>
#include <variant>
#include <optional>
#include <concepts>
namespace utils {
using namespace drogon;
// Result type for better error handling
template<typename T>
using Result = std::variant<T, std::string>;
template<typename T>
inline bool isOk(const Result<T>& r) { return std::holds_alternative<T>(r); }
template<typename T>
inline T& getValue(Result<T>& r) { return std::get<T>(r); }
template<typename T>
inline const std::string& getError(const Result<T>& r) { return std::get<std::string>(r); }
// JSON Response helpers
inline HttpResponsePtr jsonOk(const Json::Value& data) {
return HttpResponse::newHttpJsonResponse(data);
}
inline HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value json;
json["error"] = error;
json["success"] = false;
auto resp = HttpResponse::newHttpJsonResponse(json);
resp->setStatusCode(code);
return resp;
}
inline HttpResponsePtr jsonResp(const Json::Value& data, HttpStatusCode code = k200OK) {
auto resp = HttpResponse::newHttpJsonResponse(data);
resp->setStatusCode(code);
return resp;
}
// Database helper with automatic error handling
template<typename... Args>
inline void dbQuery(const std::string& query,
std::function<void(const drogon::orm::Result&)> onSuccess,
std::function<void(const std::string&)> onError,
Args&&... args) {
auto db = app().getDbClient();
(*db << query << std::forward<Args>(args)...)
>> [onSuccess](const drogon::orm::Result& r) { onSuccess(r); }
>> [onError](const drogon::orm::DrogonDbException& e) {
LOG_ERROR << "DB Error: " << e.base().what();
onError(e.base().what());
};
}
// Simplified DB query that returns JSON response
template<typename... Args>
inline void dbJsonQuery(const std::string& query,
std::function<Json::Value(const drogon::orm::Result&)> transform,
std::function<void(const HttpResponsePtr&)> callback,
Args&&... args) {
dbQuery(query,
[transform, callback](const drogon::orm::Result& r) {
callback(jsonOk(transform(r)));
},
[callback](const std::string& error) {
callback(jsonError(error, k500InternalServerError));
},
std::forward<Args>(args)...
);
}
// Thread pool executor with type constraints
template<typename F>
requires std::invocable<F>
inline void runAsync(F&& task) {
if (auto loop = app().getLoop()) {
loop->queueInLoop([task = std::forward<F>(task)]() {
std::thread([task]() {
try {
task();
} catch (const std::exception& e) {
LOG_ERROR << "Async task error: " << e.what();
}
}).detach();
});
} else {
// Fallback to sync execution
task();
}
}
// Config helper
template<typename T>
inline std::optional<T> getConfig(const std::string& path) {
try {
const auto& config = app().getCustomConfig();
std::vector<std::string> parts;
std::stringstream ss(path);
std::string part;
while (std::getline(ss, part, '.')) {
parts.push_back(part);
}
Json::Value current = config;
for (const auto& p : parts) {
if (!current.isMember(p)) return std::nullopt;
current = current[p];
}
if constexpr (std::is_same_v<T, std::string>) {
return current.asString();
} else if constexpr (std::is_same_v<T, int>) {
return current.asInt();
} else if constexpr (std::is_same_v<T, bool>) {
return current.asBool();
} else if constexpr (std::is_same_v<T, double>) {
return current.asDouble();
}
} catch (...) {
return std::nullopt;
}
return std::nullopt;
}
// Environment variable helper with fallback
template<typename T = std::string>
inline T getEnv(const std::string& key, const T& defaultValue = T{}) {
const char* val = std::getenv(key.c_str());
if (!val) return defaultValue;
if constexpr (std::is_same_v<T, std::string>) {
return std::string(val);
} else if constexpr (std::is_same_v<T, int>) {
try { return std::stoi(val); } catch (...) { return defaultValue; }
} else if constexpr (std::is_same_v<T, bool>) {
std::string s(val);
return s == "true" || s == "1" || s == "yes";
}
return defaultValue;
}
// Random string generator
inline std::string randomString(size_t length = 32) {
auto bytes = drogon::utils::genRandomString(length);
return drogon::utils::base64Encode(
reinterpret_cast<const unsigned char*>(bytes.data()),
bytes.length()
);
}
// Timer helper
class ScopedTimer {
std::string name_;
std::chrono::steady_clock::time_point start_;
public:
explicit ScopedTimer(const std::string& name)
: name_(name), start_(std::chrono::steady_clock::now()) {}
~ScopedTimer() {
auto duration = std::chrono::steady_clock::now() - start_;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
LOG_DEBUG << name_ << " took " << ms << "ms";
}
};
#define TIMED_SCOPE(name) utils::ScopedTimer _timer(name)
// WebSocket broadcast helper
template<typename Container>
inline void wsBroadcast(const Container& connections, const Json::Value& message) {
auto msg = Json::FastWriter().write(message);
for (const auto& conn : connections) {
if (conn->connected()) {
conn->send(msg);
}
}
}
// Rate limiter
class RateLimiter {
std::unordered_map<std::string, std::deque<std::chrono::steady_clock::time_point>> requests_;
std::mutex mutex_;
size_t maxRequests_;
std::chrono::seconds window_;
public:
RateLimiter(size_t maxRequests = 10, std::chrono::seconds window = std::chrono::seconds(60))
: maxRequests_(maxRequests), window_(window) {}
bool allow(const std::string& key) {
std::lock_guard<std::mutex> lock(mutex_);
auto now = std::chrono::steady_clock::now();
auto& timestamps = requests_[key];
// Remove old timestamps
while (!timestamps.empty() && now - timestamps.front() > window_) {
timestamps.pop_front();
}
if (timestamps.size() >= maxRequests_) {
return false;
}
timestamps.push_back(now);
return true;
}
};
// Global rate limiter instance
inline RateLimiter& rateLimiter() {
static RateLimiter limiter;
return limiter;
}
// Validation helpers
inline bool isValidStreamKey(const std::string& key) {
return key.length() == 32 &&
std::all_of(key.begin(), key.end(), [](char c) {
return std::isxdigit(c);
});
}
// JSON conversion helper
template<typename T>
inline Json::Value toJson(const T& obj);
// Specializations for common types
template<>
inline Json::Value toJson(const std::map<std::string, std::string>& m) {
Json::Value json;
for (const auto& [k, v] : m) {
json[k] = v;
}
return json;
}
} // namespace utils

View file

@ -0,0 +1,181 @@
#include "AdminController.h"
#include "../services/OmeClient.h"
#include "../services/RedisHelper.h"
using namespace drogon::orm;
namespace {
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
auto r = HttpResponse::newHttpJsonResponse(j);
r->setStatusCode(c);
return r;
}
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value j;
j["success"] = false;
j["error"] = error;
return jsonResp(j, code);
}
}
UserInfo AdminController::getUserFromRequest(const HttpRequestPtr &req) {
UserInfo user;
std::string auth = req->getHeader("Authorization");
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
return user;
}
std::string token = auth.substr(7);
AuthService::getInstance().validateToken(token, user);
return user;
}
void AdminController::getUsers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, "
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count "
"FROM users u ORDER BY u.created_at DESC"
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value users(Json::arrayValue);
for (const auto& row : r) {
Json::Value user;
user["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
user["username"] = row["username"].as<std::string>();
user["isAdmin"] = row["is_admin"].as<bool>();
user["isStreamer"] = row["is_streamer"].as<bool>();
user["createdAt"] = row["created_at"].as<std::string>();
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
users.append(user);
}
resp["users"] = users;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get users: " << e.base().what();
callback(jsonError("Failed to get users"));
};
}
void AdminController::getActiveStreams(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
// Get live realms from database
auto dbClient = app().getDbClient();
*dbClient << "SELECT r.id, r.name, r.stream_key, r.viewer_count, "
"u.username FROM realms r "
"JOIN users u ON r.user_id = u.id "
"WHERE r.is_live = true"
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value streams(Json::arrayValue);
for (const auto& row : r) {
Json::Value stream;
stream["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
stream["name"] = row["name"].as<std::string>();
stream["streamKey"] = row["stream_key"].as<std::string>();
stream["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
stream["username"] = row["username"].as<std::string>();
streams.append(stream);
}
resp["streams"] = streams;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get active streams: " << e.base().what();
callback(jsonError("Failed to get active streams"));
};
}
void AdminController::disconnectStream(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
// Add to Redis set for OpenResty to disconnect
RedisHelper::addToSet("streams_to_disconnect", streamKey);
// Also try direct disconnect
OmeClient::getInstance().disconnectStream(streamKey, [callback](bool) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Stream disconnect initiated";
callback(jsonResp(resp));
});
}
void AdminController::promoteToStreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
int64_t targetUserId = std::stoll(userId);
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET is_streamer = true WHERE id = $1"
<< targetUserId
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "User promoted to streamer";
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to promote user: " << e.base().what();
callback(jsonError("Failed to promote user"));
};
}
void AdminController::demoteFromStreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
int64_t targetUserId = std::stoll(userId);
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET is_streamer = false WHERE id = $1"
<< targetUserId
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "User demoted from streamer";
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to demote user: " << e.base().what();
callback(jsonError("Failed to demote user"));
};
}

View file

@ -0,0 +1,37 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class AdminController : public HttpController<AdminController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(AdminController::getUsers, "/api/admin/users", Get);
ADD_METHOD_TO(AdminController::getActiveStreams, "/api/admin/streams", Get);
ADD_METHOD_TO(AdminController::disconnectStream, "/api/admin/streams/{1}/disconnect", Post);
ADD_METHOD_TO(AdminController::promoteToStreamer, "/api/admin/users/{1}/promote", Post);
ADD_METHOD_TO(AdminController::demoteFromStreamer, "/api/admin/users/{1}/demote", Post);
METHOD_LIST_END
void getUsers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getActiveStreams(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void disconnectStream(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
void promoteToStreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromStreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
};

View file

@ -0,0 +1,600 @@
#include "RealmController.h"
#include "../services/DatabaseService.h"
#include "../services/StatsService.h"
#include "../services/RedisHelper.h"
#include "../services/OmeClient.h"
#include <drogon/utils/Utilities.h>
#include <drogon/Cookie.h>
#include <random>
#include <sstream>
#include <iomanip>
#include <regex>
using namespace drogon::orm;
namespace {
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
auto r = HttpResponse::newHttpJsonResponse(j);
r->setStatusCode(c);
return r;
}
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value j;
j["success"] = false;
j["error"] = error;
return jsonResp(j, code);
}
std::string generateStreamKey() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(0, 255);
std::stringstream ss;
for (int i = 0; i < 16; ++i) {
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
}
return ss.str();
}
bool validateRealmName(const std::string& name) {
if (name.length() < 3 || name.length() > 30) {
return false;
}
return std::regex_match(name, std::regex("^[a-z0-9-]+$"));
}
void invalidateKeyInRedis(const std::string& oldKey) {
RedisHelper::addToSet("streams_to_disconnect", oldKey);
RedisHelper::deleteKey("stream_key:" + oldKey);
services::RedisHelper::instance().keysAsync("viewer_token:*",
[oldKey](const std::vector<std::string>& keys) {
for (const auto& tokenKey : keys) {
services::RedisHelper::instance().getAsync(tokenKey,
[tokenKey, oldKey](sw::redis::OptionalString streamKey) {
if (streamKey.has_value() && streamKey.value() == oldKey) {
RedisHelper::deleteKey(tokenKey);
}
}
);
}
}
);
}
}
UserInfo RealmController::getUserFromRequest(const HttpRequestPtr &req) {
UserInfo user;
std::string auth = req->getHeader("Authorization");
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
return user;
}
std::string token = auth.substr(7);
AuthService::getInstance().validateToken(token, user);
return user;
}
void RealmController::getUserRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, name, stream_key, is_active, is_live, viewer_count, created_at "
"FROM realms WHERE user_id = $1 ORDER BY created_at DESC"
<< user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value realms(Json::arrayValue);
for (const auto& row : r) {
Json::Value realm;
realm["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
realm["name"] = row["name"].as<std::string>();
realm["streamKey"] = row["stream_key"].as<std::string>();
realm["isActive"] = row["is_active"].as<bool>();
realm["isLive"] = row["is_live"].as<bool>();
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
realm["createdAt"] = row["created_at"].as<std::string>();
realms.append(realm);
}
resp["realms"] = realms;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get realms: " << e.base().what();
callback(jsonError("Failed to get realms"));
};
}
void RealmController::createRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
// Check if user is a streamer
auto dbClient = app().getDbClient();
*dbClient << "SELECT is_streamer FROM users WHERE id = $1"
<< user.id
>> [req, callback, user, dbClient](const Result& r) {
if (r.empty() || !r[0]["is_streamer"].as<bool>()) {
callback(jsonError("You must be a streamer to create realms", k403Forbidden));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string name = (*json)["name"].asString();
if (!validateRealmName(name)) {
callback(jsonError("Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only"));
return;
}
// Check if realm name already exists
*dbClient << "SELECT id FROM realms WHERE name = $1"
<< name
>> [dbClient, user, name, callback](const Result& r2) {
if (!r2.empty()) {
callback(jsonError("Realm name already taken"));
return;
}
// Check user's realm limit (e.g., 5 realms per user)
*dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1"
<< user.id
>> [dbClient, user, name, callback](const Result& r3) {
if (!r3.empty() && r3[0]["count"].as<int64_t>() >= 5) {
callback(jsonError("You have reached the maximum number of realms (5)"));
return;
}
std::string streamKey = generateStreamKey();
*dbClient << "INSERT INTO realms (user_id, name, stream_key) "
"VALUES ($1, $2, $3) RETURNING id"
<< user.id << name << streamKey
>> [callback, name, streamKey](const Result& r4) {
if (r4.empty()) {
callback(jsonError("Failed to create realm"));
return;
}
// Store stream key in Redis
RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
Json::Value resp;
resp["success"] = true;
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
resp["realm"]["name"] = name;
resp["realm"]["streamKey"] = streamKey;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to create realm: " << e.base().what();
callback(jsonError("Failed to create realm"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to count realms: " << e.base().what();
callback(jsonError("Database error"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to check realm name: " << e.base().what();
callback(jsonError("Database error"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to check streamer status: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::issueRealmViewerToken(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
int64_t id = std::stoll(realmId);
auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
<< id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonResp({}, k404NotFound));
return;
}
std::string streamKey = r[0]["stream_key"].as<std::string>();
auto bytes = drogon::utils::genRandomString(32);
std::string token = drogon::utils::base64Encode(
(const unsigned char*)bytes.data(), bytes.length()
);
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 30,
[callback, token](bool stored) {
if (!stored) {
callback(jsonResp({}, k500InternalServerError));
return;
}
auto resp = HttpResponse::newHttpResponse();
Cookie cookie("viewer_token", token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(false);
cookie.setMaxAge(300);
resp->addCookie(cookie);
Json::Value body;
body["success"] = true;
body["viewer_token"] = token;
body["expires_in"] = 30;
resp->setContentTypeCode(CT_APPLICATION_JSON);
resp->setBody(Json::FastWriter().write(body));
callback(resp);
}
);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonResp({}, k500InternalServerError));
};
}
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
// Check for viewer token
auto token = req->getCookie("viewer_token");
if (token.empty()) {
callback(jsonError("No viewer token", k403Forbidden));
return;
}
int64_t id = std::stoll(realmId);
// First get the stream key for this realm
auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
<< id
>> [token, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
std::string streamKey = r[0]["stream_key"].as<std::string>();
// Verify the token is valid for this stream
RedisHelper::getKeyAsync("viewer_token:" + token,
[callback, streamKey](const std::string& storedStreamKey) {
if (storedStreamKey != streamKey) {
callback(jsonError("Invalid token for this realm", k403Forbidden));
return;
}
// Token is valid, return the stream key
Json::Value resp;
resp["success"] = true;
resp["streamKey"] = streamKey;
callback(jsonResp(resp));
}
);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::getRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t id = std::stoll(realmId);
auto dbClient = app().getDbClient();
*dbClient << "SELECT r.*, u.username FROM realms r "
"JOIN users u ON r.user_id = u.id "
"WHERE r.id = $1 AND r.user_id = $2"
<< id << user.id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
auto& realm = resp["realm"];
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
realm["name"] = r[0]["name"].as<std::string>();
realm["streamKey"] = r[0]["stream_key"].as<std::string>();
realm["isActive"] = r[0]["is_active"].as<bool>();
realm["isLive"] = r[0]["is_live"].as<bool>();
realm["viewerCount"] = static_cast<Json::Int64>(r[0]["viewer_count"].as<int64_t>());
realm["createdAt"] = r[0]["created_at"].as<std::string>();
realm["username"] = r[0]["username"].as<std::string>();
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get realm: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::updateRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
// Since we removed display_name and description, there's nothing to update
// We could just return success or remove this endpoint entirely
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
}
void RealmController::deleteRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t id = std::stoll(realmId);
auto dbClient = app().getDbClient();
// First get the stream key to invalidate it
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND user_id = $2"
<< id << user.id
>> [dbClient, id, user, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
std::string streamKey = r[0]["stream_key"].as<std::string>();
// Invalidate the key
invalidateKeyInRedis(streamKey);
// Delete the realm
*dbClient << "DELETE FROM realms WHERE id = $1 AND user_id = $2"
<< id << user.id
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to delete realm: " << e.base().what();
callback(jsonError("Failed to delete realm"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get realm: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::regenerateRealmKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t id = std::stoll(realmId);
auto dbClient = app().getDbClient();
// Get old key
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND user_id = $2"
<< id << user.id
>> [dbClient, id, user, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
std::string oldKey = r[0]["stream_key"].as<std::string>();
invalidateKeyInRedis(oldKey);
std::string newKey = generateStreamKey();
*dbClient << "UPDATE realms SET stream_key = $1 WHERE id = $2 AND user_id = $3"
<< newKey << id << user.id
>> [callback, newKey](const Result&) {
// Store new key in Redis
RedisHelper::storeKey("stream_key:" + newKey, "1", 86400);
Json::Value resp;
resp["success"] = true;
resp["streamKey"] = newKey;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update stream key: " << e.base().what();
callback(jsonError("Failed to regenerate key"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get realm: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::getRealmByName(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmName) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT r.id, r.name, r.is_live, r.viewer_count, "
"u.username, u.avatar_url FROM realms r "
"JOIN users u ON r.user_id = u.id "
"WHERE r.name = $1 AND r.is_active = true"
<< realmName
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
auto& realm = resp["realm"];
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
realm["name"] = r[0]["name"].as<std::string>();
realm["isLive"] = r[0]["is_live"].as<bool>();
realm["viewerCount"] = static_cast<Json::Int64>(r[0]["viewer_count"].as<int64_t>());
realm["username"] = r[0]["username"].as<std::string>();
realm["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get realm by name: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::getLiveRealms(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT r.name, r.viewer_count, u.username, u.avatar_url "
"FROM realms r JOIN users u ON r.user_id = u.id "
"WHERE r.is_live = true AND r.is_active = true "
"ORDER BY r.viewer_count DESC"
>> [callback](const Result& r) {
Json::Value resp(Json::arrayValue);
for (const auto& row : r) {
Json::Value realm;
realm["name"] = row["name"].as<std::string>();
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
realm["username"] = row["username"].as<std::string>();
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
resp.append(realm);
}
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get live realms: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::validateRealmKey(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT id FROM realms WHERE stream_key = $1 AND is_active = true"
<< key
>> [callback, key](const Result& r) {
bool valid = !r.empty();
if (valid) {
// Store in Redis
RedisHelper::storeKey("stream_key:" + key, "1", 86400);
}
Json::Value resp;
resp["valid"] = valid;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
Json::Value resp;
resp["valid"] = false;
callback(jsonResp(resp));
};
}
void RealmController::getRealmStats(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
// Public endpoint - no authentication required
int64_t id = std::stoll(realmId);
auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1"
<< id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found", k404NotFound));
return;
}
std::string streamKey = r[0]["stream_key"].as<std::string>();
StatsService::getInstance().getStreamStats(streamKey,
[callback](bool success, const StreamStats& stats) {
if (success) {
Json::Value json;
json["success"] = true;
auto& s = json["stats"];
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers);
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections);
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
s["bitrate"] = stats.bitrate;
s["codec"] = stats.codec;
s["resolution"] = stats.resolution;
s["fps"] = stats.fps;
s["is_live"] = stats.isLive;
// Protocol breakdown
auto& pc = s["protocol_connections"];
pc["webrtc"] = static_cast<Json::Int64>(stats.protocolConnections.webrtc);
pc["hls"] = static_cast<Json::Int64>(stats.protocolConnections.hls);
pc["llhls"] = static_cast<Json::Int64>(stats.protocolConnections.llhls);
pc["dash"] = static_cast<Json::Int64>(stats.protocolConnections.dash);
callback(jsonResp(json));
} else {
callback(jsonError("Failed to retrieve stats"));
}
});
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}

View file

@ -0,0 +1,71 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class RealmController : public HttpController<RealmController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(RealmController::getUserRealms, "/api/realms", Get);
ADD_METHOD_TO(RealmController::createRealm, "/api/realms", Post);
ADD_METHOD_TO(RealmController::getRealm, "/api/realms/{1}", Get);
ADD_METHOD_TO(RealmController::updateRealm, "/api/realms/{1}", Put);
ADD_METHOD_TO(RealmController::deleteRealm, "/api/realms/{1}", Delete);
ADD_METHOD_TO(RealmController::regenerateRealmKey, "/api/realms/{1}/regenerate-key", Post);
ADD_METHOD_TO(RealmController::getRealmByName, "/api/realms/by-name/{1}", Get);
ADD_METHOD_TO(RealmController::getLiveRealms, "/api/realms/live", Get);
ADD_METHOD_TO(RealmController::validateRealmKey, "/api/realms/validate/{1}", Get);
ADD_METHOD_TO(RealmController::issueRealmViewerToken, "/api/realms/{1}/viewer-token", Get);
ADD_METHOD_TO(RealmController::getRealmStreamKey, "/api/realms/{1}/stream-key", Get);
ADD_METHOD_TO(RealmController::getRealmStats, "/api/realms/{1}/stats", Get);
METHOD_LIST_END
void getUserRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void createRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void updateRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void deleteRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void regenerateRealmKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void getRealmByName(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmName);
void getLiveRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void validateRealmKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key);
void getRealmStats(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void issueRealmViewerToken(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void getRealmStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
};

View file

@ -0,0 +1,370 @@
#include "StreamController.h"
#include "../services/DatabaseService.h"
#include "../services/StatsService.h"
#include "../services/RedisHelper.h"
#include "../services/OmeClient.h"
#include "../services/AuthService.h"
#include <drogon/utils/Utilities.h>
#include <drogon/Cookie.h>
#include <random>
#include <sstream>
#include <iomanip>
#include <chrono>
using namespace drogon::orm;
// Helper functions at the top
namespace {
// JSON response helper - saves 6-8 lines per endpoint
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
auto r = HttpResponse::newHttpJsonResponse(j);
r->setStatusCode(c);
return r;
}
HttpResponsePtr jsonOk(const Json::Value& data) {
return jsonResp(data);
}
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value j;
j["success"] = false;
j["error"] = error;
return jsonResp(j, code);
}
// Quick JSON builder for common patterns
Json::Value json(std::initializer_list<std::pair<const char*, Json::Value>> items) {
Json::Value j;
for (const auto& [key, value] : items) {
j[key] = value;
}
return j;
}
UserInfo getUserFromRequest(const HttpRequestPtr &req) {
UserInfo user;
std::string auth = req->getHeader("Authorization");
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
return user;
}
std::string token = auth.substr(7);
AuthService::getInstance().validateToken(token, user);
return user;
}
}
// Static member definitions
std::mutex StreamWebSocketController::connectionsMutex_;
std::unordered_map<std::string, std::unordered_set<WebSocketConnectionPtr>> StreamWebSocketController::tokenConnections_;
std::unordered_set<WebSocketConnectionPtr> StreamWebSocketController::connections_;
void StreamController::health(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
callback(jsonOk(json({
{"status", "ok"},
{"timestamp", Json::Int64(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()
).count())}
})));
}
void StreamController::validateStreamKey(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key) {
// Now validate against realms table
auto dbClient = app().getDbClient();
*dbClient << "SELECT 1 FROM realms WHERE stream_key = $1 AND is_active = true"
<< key
>> [callback, key](const Result &r) {
bool valid = !r.empty();
if (valid) {
// Store in Redis
RedisHelper::storeKey("stream_key:" + key, "1", 86400);
}
callback(jsonOk(json({{"valid", valid}})));
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonOk(json({{"valid", false}})));
};
}
void StreamController::disconnectStream(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
// Check if user owns this stream or is admin
auto dbClient = app().getDbClient();
*dbClient << "SELECT user_id FROM realms WHERE stream_key = $1 AND is_active = true"
<< streamKey
>> [user, callback, streamKey](const Result& r) {
if (r.empty() || (r[0]["user_id"].as<int64_t>() != user.id && !user.isAdmin)) {
callback(jsonError("Forbidden", k403Forbidden));
return;
}
OmeClient::getInstance().disconnectStream(streamKey, [callback](bool success) {
if (success) {
callback(jsonOk(json({
{"success", true},
{"message", "Stream disconnected"}
})));
} else {
callback(jsonError("Failed to disconnect stream"));
}
});
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}
void StreamController::getStreamStats(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey) {
StatsService::getInstance().getStreamStats(streamKey,
[callback](bool success, const StreamStats& stats) {
if (success) {
Json::Value json;
json["success"] = true;
auto& s = json["stats"];
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers);
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections);
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
s["bitrate"] = stats.bitrate;
s["codec"] = stats.codec;
s["resolution"] = stats.resolution;
s["fps"] = stats.fps;
s["is_live"] = stats.isLive;
if (stats.totalBytesIn > 0) {
s["data_rate_in"] = stats.bitrate / 1000.0;
}
if (stats.totalBytesOut > 0) {
s["data_rate_out"] = stats.totalBytesOut / 1024.0 / 1024.0;
}
// Protocol breakdown
auto& pc = s["protocol_connections"];
pc["webrtc"] = static_cast<Json::Int64>(stats.protocolConnections.webrtc);
pc["hls"] = static_cast<Json::Int64>(stats.protocolConnections.hls);
pc["llhls"] = static_cast<Json::Int64>(stats.protocolConnections.llhls);
pc["dash"] = static_cast<Json::Int64>(stats.protocolConnections.dash);
callback(jsonResp(json));
} else {
callback(jsonError("Failed to retrieve stream stats"));
}
});
}
void StreamController::getActiveStreams(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
OmeClient::getInstance().getActiveStreams([callback](bool success, const Json::Value& omeResponse) {
if (success) {
LOG_INFO << "Active streams: " << omeResponse["response"].toStyledString();
callback(jsonOk(json({
{"success", true},
{"streams", omeResponse["response"]}
})));
} else {
callback(jsonError("Failed to get active streams from OME"));
}
});
}
void StreamController::issueViewerToken(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey) {
// Validate against realms
auto dbClient = app().getDbClient();
*dbClient << "SELECT 1 FROM realms WHERE stream_key = $1 AND is_active = true"
<< streamKey
>> [callback, streamKey](const Result& r) {
if (r.empty()) {
callback(jsonResp({}, k404NotFound));
return;
}
auto bytes = drogon::utils::genRandomString(32);
std::string token = drogon::utils::base64Encode(
(const unsigned char*)bytes.data(), bytes.length()
);
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 30,
[callback, token](bool stored) {
if (!stored) {
callback(jsonResp({}, k500InternalServerError));
return;
}
auto resp = HttpResponse::newHttpResponse();
Cookie cookie("viewer_token", token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(false);
cookie.setMaxAge(300);
resp->addCookie(cookie);
Json::Value body;
body["success"] = true;
body["viewer_token"] = token;
body["expires_in"] = 30;
resp->setContentTypeCode(CT_APPLICATION_JSON);
resp->setBody(Json::FastWriter().write(body));
callback(resp);
}
);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonResp({}, k500InternalServerError));
};
}
void StreamController::heartbeat(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey) {
auto token = req->getCookie("viewer_token");
if (token.empty()) {
callback(jsonResp({}, k403Forbidden));
return;
}
RedisHelper::getKeyAsync("viewer_token:" + token,
[callback, streamKey, token](const std::string& storedStreamKey) {
if (storedStreamKey != streamKey) {
callback(jsonResp({}, k403Forbidden));
return;
}
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 30,
[callback](bool success) {
if (!success) {
callback(jsonResp({}, k500InternalServerError));
return;
}
callback(jsonOk(json({
{"success", true},
{"renewed", true}
})));
}
);
}
);
}
// WebSocket implementation
void StreamWebSocketController::handleNewMessage(const WebSocketConnectionPtr&,
std::string &&message,
const WebSocketMessageType &type) {
if (type == WebSocketMessageType::Text) {
Json::Value msg;
Json::Reader reader;
if (reader.parse(message, msg) && msg["type"].asString() == "subscribe") {
LOG_INFO << "Client subscribed to stream updates";
}
}
}
void StreamWebSocketController::handleNewConnection(const HttpRequestPtr &req,
const WebSocketConnectionPtr& wsConnPtr) {
LOG_INFO << "New WebSocket connection established";
auto token = req->getCookie("viewer_token");
if (token.empty()) {
LOG_WARN << "WebSocket connection without viewer token";
wsConnPtr->shutdown();
return;
}
RedisHelper::getKeyAsync("viewer_token:" + token,
[wsConnPtr, token](const std::string& streamKey) {
if (streamKey.empty()) {
LOG_WARN << "Invalid viewer token";
wsConnPtr->shutdown();
return;
}
std::lock_guard<std::mutex> lock(connectionsMutex_);
tokenConnections_[token].insert(wsConnPtr);
connections_.insert(wsConnPtr);
LOG_INFO << "WebSocket authenticated for stream: " << streamKey;
}
);
}
void StreamWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) {
LOG_INFO << "WebSocket connection closed";
std::lock_guard<std::mutex> lock(connectionsMutex_);
std::string tokenToDelete;
for (auto& [token, conns] : tokenConnections_) {
if (conns.erase(wsConnPtr)) {
if (conns.empty()) {
tokenToDelete = token;
}
break;
}
}
connections_.erase(wsConnPtr);
if (!tokenToDelete.empty()) {
tokenConnections_.erase(tokenToDelete);
RedisHelper::deleteKeyAsync("viewer_token:" + tokenToDelete,
[tokenToDelete](bool success) {
if (success) {
LOG_INFO << "Deleted viewer token on disconnect: " << tokenToDelete;
} else {
LOG_WARN << "Failed to delete viewer token: " << tokenToDelete;
}
}
);
}
}
void StreamWebSocketController::broadcastKeyUpdate(const std::string& userId, const std::string& newKey) {
Json::Value msg;
msg["type"] = "key_regenerated";
msg["user_id"] = userId;
msg["stream_key"] = newKey;
auto msgStr = Json::FastWriter().write(msg);
std::lock_guard<std::mutex> lock(connectionsMutex_);
for (const auto& conn : connections_) {
if (conn->connected()) {
conn->send(msgStr);
}
}
}
void StreamWebSocketController::broadcastStatsUpdate(const Json::Value& stats) {
std::string jsonStr = Json::FastWriter().write(stats);
std::lock_guard<std::mutex> lock(connectionsMutex_);
for (const auto& conn : connections_) {
if (conn->connected()) {
conn->send(jsonStr);
}
}
}

View file

@ -0,0 +1,72 @@
#pragma once
#include <drogon/HttpController.h>
#include <drogon/WebSocketController.h>
#include <memory>
#include <unordered_set>
#include <unordered_map>
#include <mutex>
using namespace drogon;
class StreamController : public HttpController<StreamController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(StreamController::health, "/api/health", Get);
ADD_METHOD_TO(StreamController::validateStreamKey, "/api/stream/validate/{1}", Get);
ADD_METHOD_TO(StreamController::disconnectStream, "/api/stream/disconnect/{1}", Post);
ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get);
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post);
METHOD_LIST_END
void health(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void validateStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key);
void disconnectStream(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamId);
void getStreamStats(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
void getActiveStreams(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void issueViewerToken(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
void heartbeat(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
};
class StreamWebSocketController : public WebSocketController<StreamWebSocketController> {
public:
void handleNewMessage(const WebSocketConnectionPtr& wsConnPtr,
std::string &&message,
const WebSocketMessageType &type) override;
void handleNewConnection(const HttpRequestPtr &req,
const WebSocketConnectionPtr& wsConnPtr) override;
void handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) override;
static void broadcastKeyUpdate(const std::string& userId, const std::string& newKey);
static void broadcastStatsUpdate(const Json::Value& stats);
WS_PATH_LIST_BEGIN
WS_PATH_ADD("/ws/stream");
WS_PATH_LIST_END
private:
static std::mutex connectionsMutex_;
static std::unordered_map<std::string, std::unordered_set<WebSocketConnectionPtr>> tokenConnections_;
static std::unordered_set<WebSocketConnectionPtr> connections_;
};

View file

@ -0,0 +1,614 @@
#include "UserController.h"
#include "../services/DatabaseService.h"
#include <drogon/MultiPart.h>
#include <fstream>
#include <random>
#include <sstream>
#include <iomanip>
#include <filesystem>
using namespace drogon::orm;
namespace {
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
auto r = HttpResponse::newHttpJsonResponse(j);
r->setStatusCode(c);
return r;
}
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value j;
j["success"] = false;
j["error"] = error;
return jsonResp(j, code);
}
std::string generateRandomFilename(const std::string& extension) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(0, 255);
std::stringstream ss;
for (int i = 0; i < 16; ++i) {
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
}
return ss.str() + "." + extension;
}
bool ensureDirectoryExists(const std::string& path) {
try {
std::filesystem::create_directories(path);
// Set permissions to 755
std::filesystem::permissions(path,
std::filesystem::perms::owner_all |
std::filesystem::perms::group_read | std::filesystem::perms::group_exec |
std::filesystem::perms::others_read | std::filesystem::perms::others_exec
);
return true;
} catch (const std::exception& e) {
LOG_ERROR << "Failed to create directory " << path << ": " << e.what();
return false;
}
}
}
UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
UserInfo user;
std::string auth = req->getHeader("Authorization");
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
return user;
}
std::string token = auth.substr(7);
AuthService::getInstance().validateToken(token, user);
return user;
}
void UserController::register_(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string username = (*json)["username"].asString();
std::string password = (*json)["password"].asString();
std::string publicKey = (*json)["publicKey"].asString();
std::string fingerprint = (*json)["fingerprint"].asString();
if (username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing required fields"));
return;
}
AuthService::getInstance().registerUser(username, password, publicKey, fingerprint,
[callback](bool success, const std::string& error, int64_t userId) {
if (success) {
Json::Value resp;
resp["success"] = true;
resp["userId"] = static_cast<Json::Int64>(userId);
callback(jsonResp(resp));
} else {
callback(jsonError(error));
}
});
}
void UserController::login(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string username = (*json)["username"].asString();
std::string password = (*json)["password"].asString();
if (username.empty() || password.empty()) {
callback(jsonError("Missing credentials"));
return;
}
AuthService::getInstance().loginUser(username, password,
[callback](bool success, const std::string& token, const UserInfo& user) {
if (success) {
Json::Value resp;
resp["success"] = true;
resp["token"] = token;
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
resp["user"]["username"] = user.username;
resp["user"]["isAdmin"] = user.isAdmin;
resp["user"]["isStreamer"] = user.isStreamer;
resp["user"]["isPgpOnly"] = user.isPgpOnly;
resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl;
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
callback(jsonResp(resp));
} else {
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
}
});
}
void UserController::pgpChallenge(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string username = (*json)["username"].asString();
if (username.empty()) {
callback(jsonError("Username required"));
return;
}
AuthService::getInstance().initiatePgpLogin(username,
[callback](bool success, const std::string& challenge, const std::string& publicKey) {
if (success) {
Json::Value resp;
resp["success"] = true;
resp["challenge"] = challenge;
resp["publicKey"] = publicKey;
callback(jsonResp(resp));
} else {
callback(jsonError("User not found or PGP not enabled", k404NotFound));
}
});
}
void UserController::pgpVerify(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string username = (*json)["username"].asString();
std::string signature = (*json)["signature"].asString();
std::string challenge = (*json)["challenge"].asString();
if (username.empty() || signature.empty() || challenge.empty()) {
callback(jsonError("Missing required fields"));
return;
}
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
[callback](bool success, const std::string& token, const UserInfo& user) {
if (success) {
Json::Value resp;
resp["success"] = true;
resp["token"] = token;
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
resp["user"]["username"] = user.username;
resp["user"]["isAdmin"] = user.isAdmin;
resp["user"]["isStreamer"] = user.isStreamer;
resp["user"]["isPgpOnly"] = user.isPgpOnly;
resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl;
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
callback(jsonResp(resp));
} else {
callback(jsonError("Invalid signature", k401Unauthorized));
}
});
}
void UserController::getCurrentUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
// Get fresh user data from database
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
"FROM users WHERE id = $1"
<< user.id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("User not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
resp["user"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
resp["user"]["username"] = r[0]["username"].as<std::string>();
resp["user"]["isAdmin"] = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
resp["user"]["isStreamer"] = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
resp["user"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
resp["user"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get user data: " << e.base().what();
callback(jsonError("Database error"));
};
}
void UserController::updateProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string bio = (*json)["bio"].asString();
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET bio = $1 WHERE id = $2"
<< bio << user.id
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Profile updated successfully";
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update profile: " << e.base().what();
callback(jsonError("Failed to update profile"));
};
}
void UserController::updatePassword(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string oldPassword = (*json)["oldPassword"].asString();
std::string newPassword = (*json)["newPassword"].asString();
if (oldPassword.empty() || newPassword.empty()) {
callback(jsonError("Missing passwords"));
return;
}
AuthService::getInstance().updatePassword(user.id, oldPassword, newPassword,
[callback](bool success, const std::string& error) {
if (success) {
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
} else {
callback(jsonError(error));
}
});
}
void UserController::togglePgpOnly(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
bool enable = (*json)["enable"].asBool();
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at"
<< enable << user.id
>> [callback, enable](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["pgpOnly"] = enable;
// Return the timestamp if it was just enabled
if (enable && !r.empty() && !r[0]["pgp_only_enabled_at"].isNull()) {
resp["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].as<std::string>();
}
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update PGP setting: " << e.base().what();
callback(jsonError("Failed to update setting"));
};
}
void UserController::addPgpKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
std::string publicKey = (*json)["publicKey"].asString();
std::string fingerprint = (*json)["fingerprint"].asString();
if (publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing key data"));
return;
}
auto dbClient = app().getDbClient();
// Check if fingerprint already exists
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
<< fingerprint
>> [dbClient, user, publicKey, fingerprint, callback](const Result& r) {
if (!r.empty()) {
callback(jsonError("This PGP key is already registered"));
return;
}
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
<< user.id << publicKey << fingerprint
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to add PGP key: " << e.base().what();
callback(jsonError("Failed to add PGP key"));
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}
void UserController::getPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT public_key, fingerprint, created_at FROM pgp_keys "
"WHERE user_id = $1 ORDER BY created_at DESC"
<< user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value keys(Json::arrayValue);
for (const auto& row : r) {
Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>();
key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key);
}
resp["keys"] = keys;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get PGP keys: " << e.base().what();
callback(jsonError("Failed to get PGP keys"));
};
}
void UserController::uploadAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
MultiPartParser parser;
parser.parse(req);
if (parser.getFiles().empty()) {
callback(jsonError("No file uploaded"));
return;
}
const auto& file = parser.getFiles()[0];
// Validate file size (250KB max)
if (file.fileLength() > 250 * 1024) {
callback(jsonError("File too large (max 250KB)"));
return;
}
// Validate file type
std::string ext = std::string(file.getFileExtension());
if (ext != "jpg" && ext != "jpeg" && ext != "png" && ext != "gif") {
callback(jsonError("Invalid file type (jpg, png, gif only)"));
return;
}
// Ensure uploads directory exists
const std::string uploadDir = "/app/uploads/avatars";
if (!ensureDirectoryExists(uploadDir)) {
callback(jsonError("Failed to create upload directory"));
return;
}
// Generate unique filename using hex string
std::string filename = generateRandomFilename(ext);
// Build the full file path
std::string fullPath = uploadDir + "/" + filename;
// Ensure the file doesn't already exist (extremely unlikely with random names)
if (std::filesystem::exists(fullPath)) {
LOG_WARN << "File already exists, regenerating name";
filename = generateRandomFilename(ext);
fullPath = uploadDir + "/" + filename;
}
try {
// Get the uploaded file data and size
const char* fileData = file.fileData();
size_t fileSize = file.fileLength();
if (!fileData || fileSize == 0) {
LOG_ERROR << "Empty file data";
callback(jsonError("Empty file uploaded"));
return;
}
// Write file data directly to avoid directory creation issues
std::ofstream ofs(fullPath, std::ios::binary);
if (!ofs) {
LOG_ERROR << "Failed to open file for writing: " << fullPath;
callback(jsonError("Failed to create file"));
return;
}
ofs.write(fileData, fileSize);
ofs.close();
if (!ofs) {
LOG_ERROR << "Failed to write file data";
callback(jsonError("Failed to write file"));
return;
}
// Verify it's actually a file
if (!std::filesystem::is_regular_file(fullPath)) {
LOG_ERROR << "Created path is not a regular file: " << fullPath;
std::filesystem::remove_all(fullPath); // Clean up
callback(jsonError("Failed to save avatar correctly"));
return;
}
// Set file permissions to 644
std::filesystem::permissions(fullPath,
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write |
std::filesystem::perms::group_read |
std::filesystem::perms::others_read
);
LOG_INFO << "Avatar saved successfully to: " << fullPath;
LOG_INFO << "File size: " << std::filesystem::file_size(fullPath) << " bytes";
} catch (const std::exception& e) {
LOG_ERROR << "Exception while saving avatar: " << e.what();
// Clean up any partial files/directories
if (std::filesystem::exists(fullPath)) {
std::filesystem::remove_all(fullPath);
}
callback(jsonError("Failed to save avatar"));
return;
}
// Store as proper URL path
std::string avatarUrl = "/uploads/avatars/" + filename;
// Update database with the URL
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET avatar_url = $1 WHERE id = $2"
<< avatarUrl << user.id
>> [callback, avatarUrl](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["avatarUrl"] = avatarUrl;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update avatar: " << e.base().what();
callback(jsonError("Failed to update avatar"));
};
}
void UserController::getProfile(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) {
// Public endpoint - no authentication required
auto dbClient = app().getDbClient();
*dbClient << "SELECT u.username, u.bio, u.avatar_url, u.created_at, "
"u.is_pgp_only, u.pgp_only_enabled_at "
"FROM users u WHERE u.username = $1"
<< username
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("User not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
resp["profile"]["username"] = r[0]["username"].as<std::string>();
resp["profile"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
resp["profile"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
resp["profile"]["createdAt"] = r[0]["created_at"].as<std::string>();
resp["profile"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
resp["profile"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}
void UserController::getUserPgpKeys(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) {
// Public endpoint - no authentication required
auto dbClient = app().getDbClient();
*dbClient << "SELECT pk.public_key, pk.fingerprint, pk.created_at "
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
"WHERE u.username = $1 ORDER BY pk.created_at DESC"
<< username
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value keys(Json::arrayValue);
for (const auto& row : r) {
Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>();
key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key);
}
resp["keys"] = keys;
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get user PGP keys: " << e.base().what();
callback(jsonError("Failed to get PGP keys"));
};
}

View file

@ -0,0 +1,67 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class UserController : public HttpController<UserController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
ADD_METHOD_TO(UserController::updateProfile, "/api/user/profile", Put);
ADD_METHOD_TO(UserController::updatePassword, "/api/user/password", Put);
ADD_METHOD_TO(UserController::togglePgpOnly, "/api/user/pgp-only", Put);
ADD_METHOD_TO(UserController::addPgpKey, "/api/user/pgp-key", Post);
ADD_METHOD_TO(UserController::getPgpKeys, "/api/user/pgp-keys", Get);
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
METHOD_LIST_END
void register_(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void login(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void pgpChallenge(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void pgpVerify(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getCurrentUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updatePassword(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void togglePgpOnly(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void addPgpKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
void getUserPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
};

105
backend/src/main.cpp Normal file
View file

@ -0,0 +1,105 @@
#include <drogon/drogon.h>
#include <drogon/HttpAppFramework.h>
#include "controllers/StreamController.h"
#include "controllers/UserController.h"
#include "controllers/AdminController.h"
#include "controllers/RealmController.h"
#include "services/DatabaseService.h"
#include "services/StatsService.h"
#include "services/AuthService.h"
#include <exception>
#include <csignal>
#include <sys/stat.h>
using namespace drogon;
int main() {
// Simplified signal handlers
signal(SIGSEGV, [](int s){ LOG_ERROR << "Signal " << s; exit(s); });
signal(SIGABRT, [](int s){ LOG_ERROR << "Signal " << s; exit(s); });
try {
LOG_INFO << "Starting streaming backend server...";
// Create upload directories
mkdir("./uploads", 0755);
mkdir("./uploads/avatars", 0755);
// Initialize DatabaseService
LOG_INFO << "Initializing DatabaseService...";
DatabaseService::getInstance().initialize();
// Load config
LOG_INFO << "Loading configuration...";
app().loadConfigFile("config.json");
// Register a pre-routing advice to handle CORS
app().registerPreRoutingAdvice([](const HttpRequestPtr &req,
AdviceCallback &&acb,
AdviceChainCallback &&accb) {
// Handle CORS preflight requests
if (req->getMethod() == Options) {
auto resp = HttpResponse::newHttpResponse();
resp->setStatusCode(k204NoContent);
// Get origin from request
std::string origin = req->getHeader("Origin");
if (origin.empty()) {
origin = "*";
}
resp->addHeader("Access-Control-Allow-Origin", origin);
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
resp->addHeader("Access-Control-Allow-Credentials", "true");
resp->addHeader("Access-Control-Max-Age", "86400");
acb(resp);
return;
}
accb();
});
// Register post-handling advice to add CORS headers to all responses
app().registerPostHandlingAdvice([](const HttpRequestPtr &req,
const HttpResponsePtr &resp) {
// Get origin from request
std::string origin = req->getHeader("Origin");
if (origin.empty()) {
origin = "*";
}
resp->addHeader("Access-Control-Allow-Origin", origin);
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
resp->addHeader("Access-Control-Allow-Credentials", "true");
});
// Register beginning advice to initialize StatsService after app starts
app().registerBeginningAdvice([]() {
LOG_INFO << "Application started successfully";
// Initialize StatsService after app is running
LOG_INFO << "Initializing StatsService...";
StatsService::getInstance().initialize();
});
app().setTermSignalHandler([]() {
LOG_INFO << "Received termination signal, shutting down...";
StatsService::getInstance().shutdown();
app().quit();
});
// Start the application
LOG_INFO << "Starting Drogon framework...";
app().run();
} catch (const std::exception& e) {
LOG_ERROR << "Exception caught in main: " << e.what();
return 1;
} catch (...) {
LOG_ERROR << "Unknown exception caught in main";
return 1;
}
return 0;
}

View file

@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <chrono>
struct Realm {
int64_t id;
int64_t userId;
std::string name;
std::string streamKey;
bool isActive;
bool isLive;
int64_t viewerCount;
std::chrono::system_clock::time_point createdAt;
std::chrono::system_clock::time_point updatedAt;
};

View file

@ -0,0 +1,12 @@
#pragma once
#include <string>
#include <chrono>
struct StreamKey {
int64_t id;
int64_t user_id;
std::string key;
bool is_active;
std::chrono::system_clock::time_point created_at;
std::chrono::system_clock::time_point updated_at;
};

View file

@ -0,0 +1,347 @@
#include "AuthService.h"
#include "DatabaseService.h"
#include "RedisHelper.h"
#include <drogon/utils/Utilities.h>
#include <regex>
#include <random>
using namespace drogon;
using namespace drogon::orm;
bool AuthService::validatePassword(const std::string& password, std::string& error) {
if (password.length() < 8) {
error = "Password must be at least 8 characters long";
return false;
}
if (!std::regex_search(password, std::regex("[0-9]"))) {
error = "Password must contain at least one number";
return false;
}
if (!std::regex_search(password, std::regex("[!@#$%^&*(),.?\":{}|<>]"))) {
error = "Password must contain at least one symbol";
return false;
}
return true;
}
void AuthService::registerUser(const std::string& username, const std::string& password,
const std::string& publicKey, const std::string& fingerprint,
std::function<void(bool, const std::string&, int64_t)> callback) {
// Validate username
if (username.length() < 3 || username.length() > 30) {
callback(false, "Username must be between 3 and 30 characters", 0);
return;
}
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
callback(false, "Username can only contain letters, numbers, and underscores", 0);
return;
}
// Validate password
std::string error;
if (!validatePassword(password, error)) {
callback(false, error, 0);
return;
}
auto dbClient = app().getDbClient();
// Check if username exists
*dbClient << "SELECT id FROM users WHERE username = $1"
<< username
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r) {
if (!r.empty()) {
callback(false, "Username already exists", 0);
return;
}
// Check if fingerprint exists
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
<< fingerprint
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r2) {
if (!r2.empty()) {
callback(false, "This PGP key is already registered", 0);
return;
}
// Hash password
std::string hash = BCrypt::generateHash(password);
// Begin transaction
auto trans = dbClient->newTransaction();
// Insert user with explicit false values for booleans
*trans << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only) VALUES ($1, $2, false, false, false) RETURNING id"
<< username << hash
>> [trans, publicKey, fingerprint, callback](const Result& r3) {
if (r3.empty()) {
callback(false, "Failed to create user", 0);
return;
}
int64_t userId = r3[0]["id"].as<int64_t>();
// Insert PGP key
*trans << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
<< userId << publicKey << fingerprint
>> [trans, callback, userId](const Result&) {
// Transaction commits automatically
callback(true, "", userId);
}
>> [trans, callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
callback(false, "Failed to save PGP key", 0);
};
}
>> [trans, callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to insert user: " << e.base().what();
callback(false, "Registration failed", 0);
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "Database error", 0);
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "Database error", 0);
};
}
void AuthService::loginUser(const std::string& username, const std::string& password,
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
"FROM users WHERE username = $1"
<< username
>> [password, callback, this](const Result& r) {
if (r.empty()) {
callback(false, "", UserInfo{});
return;
}
// Check if PGP-only is enabled BEFORE password validation
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
if (isPgpOnly) {
// Return a specific error for PGP-only accounts
callback(false, "PGP-only login enabled for this account", UserInfo{});
return;
}
std::string hash = r[0]["password_hash"].as<std::string>();
if (!BCrypt::validatePassword(password, hash)) {
callback(false, "", UserInfo{});
return;
}
UserInfo user;
user.id = r[0]["id"].as<int64_t>();
user.username = r[0]["username"].as<std::string>();
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
user.isPgpOnly = isPgpOnly;
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
std::string token = generateToken(user);
callback(true, token, user);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "", UserInfo{});
};
}
void AuthService::initiatePgpLogin(const std::string& username,
std::function<void(bool, const std::string&, const std::string&)> callback) {
auto dbClient = app().getDbClient();
// Generate random challenge
auto bytes = drogon::utils::genRandomString(32);
std::string challenge = drogon::utils::base64Encode(
reinterpret_cast<const unsigned char*>(bytes.data()), bytes.length()
);
// Store challenge in Redis with 5 minute TTL
RedisHelper::storeKeyAsync("pgp_challenge:" + username, challenge, 300,
[dbClient, username, challenge, callback](bool stored) {
if (!stored) {
callback(false, "", "");
return;
}
// Get user's latest public key
*dbClient << "SELECT pk.public_key FROM pgp_keys pk "
"JOIN users u ON pk.user_id = u.id "
"WHERE u.username = $1 "
"ORDER BY pk.created_at DESC LIMIT 1"
<< username
>> [callback, challenge](const Result& r) {
if (r.empty()) {
callback(false, "", "");
return;
}
std::string publicKey = r[0]["public_key"].as<std::string>();
callback(true, challenge, publicKey);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "", "");
};
}
);
}
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
const std::string& challenge,
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
// Get stored challenge from Redis
RedisHelper::getKeyAsync("pgp_challenge:" + username,
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
if (storedChallenge.empty() || storedChallenge != challenge) {
callback(false, "", UserInfo{});
return;
}
// Delete challenge after use
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
// In a real implementation, you would verify the signature here
// For now, we'll trust the client-side verification
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
"FROM users WHERE username = $1"
<< username
>> [callback, this](const Result& r) {
if (r.empty()) {
callback(false, "", UserInfo{});
return;
}
UserInfo user;
user.id = r[0]["id"].as<int64_t>();
user.username = r[0]["username"].as<std::string>();
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
std::string token = generateToken(user);
callback(true, token, user);
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "", UserInfo{});
};
}
);
}
std::string AuthService::generateToken(const UserInfo& user) {
if (jwtSecret_.empty()) {
const char* envSecret = std::getenv("JWT_SECRET");
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
}
auto token = jwt::create()
.set_issuer("streaming-app")
.set_type("JWS")
.set_issued_at(std::chrono::system_clock::now())
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
.set_payload_claim("username", jwt::claim(user.username))
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
.sign(jwt::algorithm::hs256{jwtSecret_});
return token;
}
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
if (jwtSecret_.empty()) {
const char* envSecret = std::getenv("JWT_SECRET");
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
}
try {
auto decoded = jwt::decode(token);
auto verifier = jwt::verify()
.allow_algorithm(jwt::algorithm::hs256{jwtSecret_})
.with_issuer("streaming-app");
verifier.verify(decoded);
userInfo.id = std::stoll(decoded.get_payload_claim("user_id").as_string());
userInfo.username = decoded.get_payload_claim("username").as_string();
userInfo.isAdmin = decoded.get_payload_claim("is_admin").as_string() == "1";
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
return true;
} catch (const std::exception& e) {
LOG_DEBUG << "Token validation failed: " << e.what();
return false;
}
}
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
const std::string& newPassword,
std::function<void(bool, const std::string&)> callback) {
// Validate new password
std::string error;
if (!validatePassword(newPassword, error)) {
callback(false, error);
return;
}
auto dbClient = app().getDbClient();
// Verify old password
*dbClient << "SELECT password_hash FROM users WHERE id = $1"
<< userId
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
if (r.empty()) {
callback(false, "User not found");
return;
}
std::string hash = r[0]["password_hash"].as<std::string>();
if (!BCrypt::validatePassword(oldPassword, hash)) {
callback(false, "Incorrect password");
return;
}
// Update password
std::string newHash = BCrypt::generateHash(newPassword);
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
<< newHash << userId
>> [callback](const Result&) {
callback(true, "");
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update password: " << e.base().what();
callback(false, "Failed to update password");
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "Database error");
};
}

View file

@ -0,0 +1,63 @@
#pragma once
#include <drogon/drogon.h>
#include <bcrypt/BCrypt.hpp>
#include <jwt-cpp/jwt.h>
#include <string>
#include <memory>
struct UserInfo {
int64_t id;
std::string username;
bool isAdmin;
bool isStreamer;
bool isPgpOnly;
std::string bio;
std::string avatarUrl;
std::string pgpOnlyEnabledAt;
};
class AuthService {
public:
static AuthService& getInstance() {
static AuthService instance;
return instance;
}
// User registration
void registerUser(const std::string& username, const std::string& password,
const std::string& publicKey, const std::string& fingerprint,
std::function<void(bool success, const std::string& error, int64_t userId)> callback);
// User login with password
void loginUser(const std::string& username, const std::string& password,
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
// User login with PGP (returns challenge)
void initiatePgpLogin(const std::string& username,
std::function<void(bool success, const std::string& challenge, const std::string& publicKey)> callback);
// Verify PGP signature
void verifyPgpLogin(const std::string& username, const std::string& signature, const std::string& challenge,
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
// Validate JWT token
bool validateToken(const std::string& token, UserInfo& userInfo);
// Update password
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword,
std::function<void(bool success, const std::string& error)> callback);
// Check password requirements
bool validatePassword(const std::string& password, std::string& error);
// Generate JWT token
std::string generateToken(const UserInfo& user);
private:
AuthService() = default;
~AuthService() = default;
AuthService(const AuthService&) = delete;
AuthService& operator=(const AuthService&) = delete;
std::string jwtSecret_;
};

View file

@ -0,0 +1,76 @@
#pragma once
#include <drogon/drogon.h>
namespace middleware {
class CorsMiddleware {
public:
struct Config {
std::vector<std::string> allowOrigins = {"*"};
std::vector<std::string> allowMethods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"};
std::vector<std::string> allowHeaders = {"Content-Type", "Authorization"};
bool allowCredentials = true;
int maxAge = 86400;
};
static void enable(const Config& config = {}) {
using namespace drogon;
auto cfg = std::make_shared<Config>(config);
auto addHeaders = [cfg](const HttpResponsePtr &resp, const HttpRequestPtr &req) {
std::string origin = req->getHeader("Origin");
// Check if origin is allowed
bool allowed = false;
for (const auto& allowedOrigin : cfg->allowOrigins) {
if (allowedOrigin == "*" || allowedOrigin == origin) {
allowed = true;
break;
}
}
if (allowed) {
resp->addHeader("Access-Control-Allow-Origin", origin.empty() ? "*" : origin);
resp->addHeader("Access-Control-Allow-Methods", joinStrings(cfg->allowMethods, ", "));
resp->addHeader("Access-Control-Allow-Headers", joinStrings(cfg->allowHeaders, ", "));
if (cfg->allowCredentials) {
resp->addHeader("Access-Control-Allow-Credentials", "true");
}
}
};
// Handle preflight requests
app().registerPreRoutingAdvice([cfg, addHeaders](const HttpRequestPtr &req,
AdviceCallback &&acb,
AdviceChainCallback &&accb) {
if (req->getMethod() == Options) {
auto resp = HttpResponse::newHttpResponse();
resp->setStatusCode(k204NoContent);
addHeaders(resp, req);
resp->addHeader("Access-Control-Max-Age", std::to_string(cfg->maxAge));
acb(resp);
return;
}
accb();
});
// Add CORS headers to all responses
app().registerPostHandlingAdvice([addHeaders](const HttpRequestPtr &req,
const HttpResponsePtr &resp) {
addHeaders(resp, req);
});
}
private:
static std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter) {
std::string result;
for (size_t i = 0; i < strings.size(); ++i) {
result += strings[i];
if (i < strings.size() - 1) result += delimiter;
}
return result;
}
};
} // namespace middleware

View file

@ -0,0 +1,120 @@
#include "DatabaseService.h"
#include "../services/RedisHelper.h"
#include <drogon/orm/DbClient.h>
#include <random>
#include <sstream>
#include <iomanip>
using namespace drogon;
using namespace drogon::orm;
namespace {
void storeKeyInRedis(const std::string& streamKey) {
// Store the stream key in Redis for validation (24 hour TTL)
bool stored = RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
if (stored) {
LOG_INFO << "Stored stream key in Redis: " << streamKey;
} else {
LOG_ERROR << "Failed to store key in Redis: " << streamKey;
}
}
std::string generateStreamKey() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(0, 255);
std::stringstream ss;
for (int i = 0; i < 16; ++i) {
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
}
return ss.str();
}
}
void DatabaseService::initialize() {
LOG_INFO << "Initializing Database Service...";
}
void DatabaseService::getUserStreamKey(int64_t userId,
std::function<void(bool, const std::string&)> callback) {
auto dbClient = drogon::app().getDbClient();
*dbClient << "SELECT key FROM stream_keys WHERE user_id = $1 AND is_active = true"
<< userId
>> [callback, userId, dbClient](const Result &r) {
if (!r.empty()) {
std::string key = r[0]["key"].as<std::string>();
// Also store in Redis when retrieved
storeKeyInRedis(key);
callback(true, key);
} else {
// Generate new key for user
std::string newKey = generateStreamKey();
*dbClient << "INSERT INTO stream_keys (user_id, key, is_active) VALUES ($1, $2, true)"
<< userId << newKey
>> [callback, newKey](const Result &) {
storeKeyInRedis(newKey);
callback(true, newKey);
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Failed to create stream key: " << e.base().what();
callback(false, "");
};
}
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "");
};
}
void DatabaseService::updateUserStreamKey(int64_t userId,
const std::string& newKey,
std::function<void(bool)> callback) {
auto dbClient = drogon::app().getDbClient();
// Execute as separate queries instead of transaction for simplicity
*dbClient << "UPDATE stream_keys SET is_active = false WHERE user_id = $1"
<< userId
>> [dbClient, userId, newKey, callback](const Result &) {
// Insert new key
*dbClient << "INSERT INTO stream_keys (user_id, key, is_active) VALUES ($1, $2, true)"
<< userId << newKey
>> [callback, newKey](const Result &) {
// Store new key in Redis
storeKeyInRedis(newKey);
callback(true);
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Failed to insert new key: " << e.base().what();
callback(false);
};
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Failed to deactivate old keys: " << e.base().what();
callback(false);
};
}
void DatabaseService::validateStreamKey(const std::string& key,
std::function<void(bool)> callback) {
auto dbClient = drogon::app().getDbClient();
*dbClient << "SELECT 1 FROM stream_keys WHERE key = $1 AND is_active = true"
<< key
>> [callback, key](const Result &r) {
bool valid = !r.empty();
if (valid) {
// Also store in Redis when validated
storeKeyInRedis(key);
}
callback(valid);
}
>> [callback](const DrogonDbException &e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false);
};
}

View file

@ -0,0 +1,30 @@
#pragma once
#include <drogon/drogon.h>
#include <functional>
#include <memory>
class DatabaseService {
public:
static DatabaseService& getInstance() {
static DatabaseService instance;
return instance;
}
void initialize();
void getUserStreamKey(int64_t userId,
std::function<void(bool, const std::string&)> callback);
void updateUserStreamKey(int64_t userId,
const std::string& newKey,
std::function<void(bool)> callback);
void validateStreamKey(const std::string& key,
std::function<void(bool)> callback);
private:
DatabaseService() = default;
~DatabaseService() = default;
DatabaseService(const DatabaseService&) = delete;
DatabaseService& operator=(const DatabaseService&) = delete;
};

View file

@ -0,0 +1,176 @@
#pragma once
#include <drogon/HttpClient.h>
#include <drogon/utils/Utilities.h>
#include <functional>
#include <string>
// TODO: Consider implementing OME webhooks for real-time updates instead of polling
// OME supports webhooks for stream events (start/stop/etc) which would be more efficient
// than polling. See: https://airensoft.gitbook.io/ovenmediaengine/access-control/admission-webhooks
class OmeClient {
public:
static OmeClient& getInstance() {
static OmeClient instance;
return instance;
}
// Get list of active streams
void getActiveStreams(std::function<void(bool, const Json::Value&)> callback) {
auto request = createRequest(drogon::Get, "/v1/vhosts/default/apps/app/streams");
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
if (result == drogon::ReqResult::Ok && response && response->getStatusCode() == drogon::k200OK) {
try {
Json::Value json = *response->getJsonObject();
callback(true, json);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse OME response: " << e.what();
Json::Value empty;
callback(false, empty);
}
} else {
LOG_ERROR << "Failed to get active streams from OME";
Json::Value empty;
callback(false, empty);
}
});
}
// Get stats for a specific stream
void getStreamStats(const std::string& streamKey,
std::function<void(bool, const Json::Value&)> callback) {
std::string path = "/v1/stats/current/vhosts/default/apps/app/streams/" + streamKey;
auto request = createRequest(drogon::Get, path);
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
if (result == drogon::ReqResult::Ok && response) {
if (response->getStatusCode() == drogon::k200OK) {
try {
Json::Value json = *response->getJsonObject();
callback(true, json);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse stats response: " << e.what();
Json::Value empty;
callback(false, empty);
}
} else {
// Not found or error - return empty but success (stream offline)
Json::Value empty;
callback(true, empty);
}
} else {
LOG_ERROR << "Request to OME failed";
Json::Value empty;
callback(false, empty);
}
});
}
// Get detailed stream info including track metadata (resolution, codec, etc.)
void getStreamInfo(const std::string& streamKey,
std::function<void(bool, const Json::Value&)> callback) {
std::string path = "/v1/vhosts/default/apps/app/streams/" + streamKey;
auto request = createRequest(drogon::Get, path);
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
if (result == drogon::ReqResult::Ok && response) {
if (response->getStatusCode() == drogon::k200OK) {
try {
Json::Value json = *response->getJsonObject();
callback(true, json);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse stream info response: " << e.what();
Json::Value empty;
callback(false, empty);
}
} else {
// Stream not found or error
Json::Value empty;
callback(false, empty);
}
} else {
LOG_ERROR << "Stream info request to OME failed";
Json::Value empty;
callback(false, empty);
}
});
}
// Disconnect a stream
void disconnectStream(const std::string& streamId,
std::function<void(bool)> callback) {
std::string path = "/v1/vhosts/default/apps/app/streams/" + streamId;
auto request = createRequest(drogon::Delete, path);
getClient()->sendRequest(request, [callback](drogon::ReqResult result, const drogon::HttpResponsePtr& response) {
bool success = (result == drogon::ReqResult::Ok &&
response &&
response->getStatusCode() == drogon::k200OK);
callback(success);
});
}
private:
OmeClient() = default;
~OmeClient() = default;
OmeClient(const OmeClient&) = delete;
OmeClient& operator=(const OmeClient&) = delete;
std::string getBaseUrl() {
// Check environment variable first
const char* envUrl = std::getenv("OME_API_URL");
if (envUrl) {
return std::string(envUrl);
}
// Try to get from Drogon config
try {
const auto& config = drogon::app().getCustomConfig();
if (config.isMember("ome") && config["ome"].isMember("api_url")) {
return config["ome"]["api_url"].asString();
}
} catch (...) {
// Config not available
}
return "http://ovenmediaengine:8081"; // Default
}
std::string getApiToken() {
// Check environment variable first
const char* envToken = std::getenv("OME_API_TOKEN");
if (envToken) {
return std::string(envToken);
}
// Try to get from Drogon config
try {
const auto& config = drogon::app().getCustomConfig();
if (config.isMember("ome") && config["ome"].isMember("api_token")) {
return config["ome"]["api_token"].asString();
}
} catch (...) {
// Config not available
}
return "your-api-token"; // Default
}
drogon::HttpClientPtr getClient() {
return drogon::HttpClient::newHttpClient(getBaseUrl());
}
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path) {
auto request = drogon::HttpRequest::newHttpRequest();
request->setMethod(method);
request->setPath(path);
// Add authorization header (OME uses Basic auth with token as username)
const auto token = getApiToken();
const auto b64 = drogon::utils::base64Encode(token);
request->addHeader("Authorization", std::string("Basic ") + b64);
return request;
}
};

View file

@ -0,0 +1,311 @@
#include "RedisHelper.h"
#include <chrono>
#include <cstdlib>
#include <thread>
#include <algorithm>
#include <cstring>
namespace services {
RedisHelper &RedisHelper::instance() {
static RedisHelper inst;
return inst;
}
RedisHelper::RedisHelper() : _initialized(false) {
LOG_INFO << "RedisHelper created (connection will be established on first use)";
}
RedisHelper::~RedisHelper() = default;
void RedisHelper::ensureConnected() {
if (_initialized) return;
std::lock_guard<std::mutex> lock(_initMutex);
if (_initialized) return; // Double-check
try {
sw::redis::ConnectionOptions opts;
opts.host = getRedisHost();
opts.port = getRedisPort();
const char* envPass = std::getenv("REDIS_PASS");
if (envPass && strlen(envPass) > 0) {
opts.password = envPass;
}
opts.socket_timeout = std::chrono::milliseconds(1000);
opts.connect_timeout = std::chrono::milliseconds(1000);
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port;
_redis = std::make_unique<sw::redis::Redis>(opts);
_redis->ping();
_initialized = true;
LOG_INFO << "Redis connection established successfully";
} catch (const sw::redis::Error& e) {
LOG_ERROR << "Failed to connect to Redis: " << e.what();
throw;
}
}
std::string RedisHelper::getRedisHost() const {
const char* envHost = std::getenv("REDIS_HOST");
if (envHost) return std::string(envHost);
try {
const auto& config = drogon::app().getCustomConfig();
if (config.isMember("redis") && config["redis"].isMember("host")) {
return config["redis"]["host"].asString();
}
} catch (...) {}
return "redis";
}
int RedisHelper::getRedisPort() const {
const char* envPort = std::getenv("REDIS_PORT");
if (envPort) {
try {
return std::stoi(envPort);
} catch (...) {}
}
try {
const auto& config = drogon::app().getCustomConfig();
if (config.isMember("redis") && config["redis"].isMember("port")) {
return config["redis"]["port"].asInt();
}
} catch (...) {}
return 6379;
}
void RedisHelper::executeInThreadPool(std::function<void()> task) {
auto loop = drogon::app().getLoop();
if (!loop) {
LOG_ERROR << "Event loop not available, executing task synchronously";
try {
task();
} catch (const std::exception& e) {
LOG_ERROR << "Error executing task: " << e.what();
}
return;
}
loop->queueInLoop([task = std::move(task)]() {
std::thread([task]() {
try {
task();
} catch (const std::exception& e) {
LOG_ERROR << "Error in thread pool task: " << e.what();
}
}).detach();
});
}
// Define a macro to generate async methods
#define REDIS_ASYNC_IMPL(method, return_type, operation) \
void RedisHelper::method##Async(const std::string &key, \
std::function<void(return_type)> callback) { \
executeAsync<return_type>( \
[this, key]() { \
return _redis->operation; \
}, \
std::move(callback) \
); \
}
// Specialized async methods using the template
void RedisHelper::setexAsync(const std::string &key,
const std::string &value,
long ttlSeconds,
std::function<void(bool)> callback) {
executeAsync<bool>(
[this, key, value, ttlSeconds]() {
_redis->setex(key, ttlSeconds, value);
return true;
},
std::move(callback)
);
}
void RedisHelper::getAsync(const std::string &key,
std::function<void(sw::redis::OptionalString)> callback) {
executeAsync<sw::redis::OptionalString>(
[this, key]() {
return _redis->get(key);
},
std::move(callback)
);
}
void RedisHelper::delAsync(const std::string &key,
std::function<void(bool)> callback) {
executeAsync<bool>(
[this, key]() {
return _redis->del(key) > 0;
},
std::move(callback)
);
}
void RedisHelper::saddAsync(const std::string &setName,
const std::string &value,
std::function<void(bool)> callback) {
executeAsync<bool>(
[this, setName, value]() {
return _redis->sadd(setName, value) > 0;
},
std::move(callback)
);
}
void RedisHelper::sremAsync(const std::string &setName,
const std::string &value,
std::function<void(bool)> callback) {
executeAsync<bool>(
[this, setName, value]() {
return _redis->srem(setName, value) > 0;
},
std::move(callback)
);
}
void RedisHelper::smembersAsync(const std::string &setName,
std::function<void(std::vector<std::string>)> callback) {
executeAsync<std::vector<std::string>>(
[this, setName]() {
std::vector<std::string> members;
_redis->smembers(setName, std::back_inserter(members));
return members;
},
std::move(callback)
);
}
void RedisHelper::keysAsync(const std::string &pattern,
std::function<void(std::vector<std::string>)> callback) {
executeAsync<std::vector<std::string>>(
[this, pattern]() {
std::vector<std::string> keys;
_redis->keys(pattern, std::back_inserter(keys));
return keys;
},
std::move(callback)
);
}
void RedisHelper::expireAsync(const std::string &key,
long ttlSeconds,
std::function<void(bool)> callback) {
executeAsync<bool>(
[this, key, ttlSeconds]() {
return _redis->expire(key, ttlSeconds);
},
std::move(callback)
);
}
// Sync versions for compatibility
std::unique_ptr<sw::redis::Redis> RedisHelper::getConnection() {
ensureConnected();
sw::redis::ConnectionOptions opts;
opts.host = getRedisHost();
opts.port = getRedisPort();
const char* envPass = std::getenv("REDIS_PASS");
if (envPass && strlen(envPass) > 0) {
opts.password = envPass;
}
opts.socket_timeout = std::chrono::milliseconds(200);
opts.connect_timeout = std::chrono::milliseconds(200);
return std::make_unique<sw::redis::Redis>(opts);
}
bool RedisHelper::storeKey(const std::string &key, const std::string &value, int ttl) {
try {
ensureConnected();
if (ttl > 0) {
_redis->setex(key, ttl, value);
} else {
_redis->set(key, value);
}
return true;
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis SET error: " << e.what();
return false;
}
}
std::string RedisHelper::getKey(const std::string &key) {
try {
ensureConnected();
auto val = _redis->get(key);
return val.has_value() ? val.value() : "";
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis GET error: " << e.what();
return "";
}
}
bool RedisHelper::deleteKey(const std::string &key) {
try {
ensureConnected();
return _redis->del(key) > 0;
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis DEL error: " << e.what();
return false;
}
}
bool RedisHelper::addToSet(const std::string &setName, const std::string &value) {
try {
ensureConnected();
return _redis->sadd(setName, value) > 0;
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis SADD error: " << e.what();
return false;
}
}
bool RedisHelper::removeFromSet(const std::string &setName, const std::string &value) {
try {
ensureConnected();
return _redis->srem(setName, value) > 0;
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis SREM error: " << e.what();
return false;
}
}
// Deprecated command executor - simplified
void RedisHelper::executeAsync(const std::string &command,
std::function<void(bool, const std::string&)> callback) {
// For the single use case in the code (EXPIRE), handle it directly
std::istringstream iss(command);
std::string op, key;
long ttl;
iss >> op >> key >> ttl;
if (op == "EXPIRE" || op == "expire") {
expireAsync(key, ttl, [callback](bool success) {
callback(success, success ? "1" : "0");
});
} else {
if (auto loop = drogon::app().getLoop()) {
loop->queueInLoop([callback]() {
callback(false, "Unsupported command in executeAsync. Use specific async methods.");
});
} else {
callback(false, "Unsupported command in executeAsync. Use specific async methods.");
}
}
}
} // namespace services

View file

@ -0,0 +1,190 @@
#pragma once
#include <sw/redis++/redis++.h>
#include <memory>
#include <string>
#include <functional>
#include <mutex>
#include <drogon/drogon.h>
namespace services {
class RedisHelper {
public:
// Singleton accessor
static RedisHelper &instance();
// Generic async execute wrapper
template<typename Result, typename Callback>
void executeAsync(std::function<Result()> redisOp, Callback&& callback) {
executeInThreadPool([this, redisOp = std::move(redisOp),
callback = std::forward<Callback>(callback)]() {
try {
ensureConnected();
auto result = redisOp();
if (auto loop = drogon::app().getLoop()) {
loop->queueInLoop([callback, result = std::move(result)]() {
callback(std::move(result));
});
} else {
callback(std::move(result));
}
} catch (const sw::redis::Error &e) {
LOG_ERROR << "Redis operation error: " << e.what();
if (auto loop = drogon::app().getLoop()) {
loop->queueInLoop([callback]() {
callback(Result{});
});
} else {
callback(Result{});
}
}
});
}
// Async SETEX
void setexAsync(const std::string &key,
const std::string &value,
long ttlSeconds,
std::function<void(bool)> callback);
// Async GET
void getAsync(const std::string &key,
std::function<void(sw::redis::OptionalString)> callback);
// Async DEL
void delAsync(const std::string &key,
std::function<void(bool)> callback);
// Async SADD
void saddAsync(const std::string &setName,
const std::string &value,
std::function<void(bool)> callback);
// Async SREM
void sremAsync(const std::string &setName,
const std::string &value,
std::function<void(bool)> callback);
// Async SMEMBERS
void smembersAsync(const std::string &setName,
std::function<void(std::vector<std::string>)> callback);
// Async KEYS
void keysAsync(const std::string &pattern,
std::function<void(std::vector<std::string>)> callback);
// Async EXPIRE
void expireAsync(const std::string &key,
long ttlSeconds,
std::function<void(bool)> callback);
// Sync versions for compatibility
std::unique_ptr<sw::redis::Redis> getConnection();
bool storeKey(const std::string &key, const std::string &value, int ttl = 0);
std::string getKey(const std::string &key);
bool deleteKey(const std::string &key);
bool addToSet(const std::string &setName, const std::string &value);
bool removeFromSet(const std::string &setName, const std::string &value);
// Compatibility wrappers - keep for backward compatibility
static void storeKeyAsync(const std::string &key, const std::string &value, int ttl,
std::function<void(bool)> callback) {
instance().setexAsync(key, value, ttl, callback);
}
static void getKeyAsync(const std::string &key,
std::function<void(const std::string&)> callback) {
instance().getAsync(key, [callback](sw::redis::OptionalString val) {
callback(val.has_value() ? val.value() : "");
});
}
static void deleteKeyAsync(const std::string &key,
std::function<void(bool)> callback) {
instance().delAsync(key, callback);
}
// Execute arbitrary command asynchronously (deprecated)
void executeAsync(const std::string &command,
std::function<void(bool, const std::string&)> callback);
private:
RedisHelper();
~RedisHelper();
RedisHelper(const RedisHelper &) = delete;
RedisHelper &operator=(const RedisHelper &) = delete;
void ensureConnected();
void executeInThreadPool(std::function<void()> task);
std::string getRedisHost() const;
int getRedisPort() const;
std::unique_ptr<sw::redis::Redis> _redis;
bool _initialized;
std::mutex _initMutex;
};
} // namespace services
// Compatibility layer for existing code
class RedisHelper {
public:
using RedisConnectionPtr = std::unique_ptr<sw::redis::Redis>;
static RedisConnectionPtr getConnection() {
return services::RedisHelper::instance().getConnection();
}
static std::string getRedisHost() {
const char* envHost = std::getenv("REDIS_HOST");
return envHost ? std::string(envHost) : "redis";
}
static int getRedisPort() {
const char* envPort = std::getenv("REDIS_PORT");
return envPort ? std::stoi(envPort) : 6379;
}
static bool storeKey(const std::string& key, const std::string& value, int ttl = 0) {
return services::RedisHelper::instance().storeKey(key, value, ttl);
}
static std::string getKey(const std::string& key) {
return services::RedisHelper::instance().getKey(key);
}
static bool deleteKey(const std::string& key) {
return services::RedisHelper::instance().deleteKey(key);
}
static bool addToSet(const std::string& setName, const std::string& value) {
return services::RedisHelper::instance().addToSet(setName, value);
}
static bool removeFromSet(const std::string& setName, const std::string& value) {
return services::RedisHelper::instance().removeFromSet(setName, value);
}
static void storeKeyAsync(const std::string& key, const std::string& value, int ttl,
std::function<void(bool)> callback) {
services::RedisHelper::storeKeyAsync(key, value, ttl, callback);
}
static void getKeyAsync(const std::string& key,
std::function<void(const std::string&)> callback) {
services::RedisHelper::getKeyAsync(key, callback);
}
static void deleteKeyAsync(const std::string& key,
std::function<void(bool)> callback) {
services::RedisHelper::deleteKeyAsync(key, callback);
}
static void executeAsync(const std::string& command,
std::function<void(bool, const std::string&)> callback) {
services::RedisHelper::instance().executeAsync(command, callback);
}
};

View file

@ -0,0 +1,356 @@
#include "StatsService.h"
#include "../controllers/StreamController.h"
#include "../services/RedisHelper.h"
#include "../services/OmeClient.h"
#include <drogon/HttpClient.h>
#include <drogon/utils/Utilities.h>
using namespace drogon;
// Macro to simplify JSON integer assignments
#define JSON_INT(json, field, value) json[field] = static_cast<Json::Int64>(value)
StatsService::~StatsService() {
shutdown();
}
void StatsService::initialize() {
LOG_INFO << "Initializing Stats Service...";
running_ = true;
drogon::app().registerBeginningAdvice([this]() {
LOG_INFO << "Starting stats polling timer...";
if (auto loop = drogon::app().getLoop()) {
try {
timerId_ = loop->runEvery(
pollInterval_.count(),
[this]() {
if (!running_) return;
try {
pollOmeStats();
} catch (const std::exception& e) {
LOG_ERROR << "Error in stats polling: " << e.what();
}
}
);
LOG_INFO << "Stats polling timer started with " << pollInterval_.count() << "s interval";
} catch (const std::exception& e) {
LOG_ERROR << "Failed to create stats timer: " << e.what();
}
}
});
}
void StatsService::shutdown() {
LOG_INFO << "Shutting down Stats Service...";
running_ = false;
if (timerId_.has_value()) {
if (auto loop = drogon::app().getLoop()) {
loop->invalidateTimer(timerId_.value());
}
timerId_.reset();
}
}
int64_t StatsService::getUniqueViewerCount(const std::string& streamKey) {
try {
auto redis = services::RedisHelper::instance().getConnection();
if (!redis) return 0;
std::vector<std::string> keys;
redis->keys("viewer_token:*", std::back_inserter(keys));
return std::count_if(keys.begin(), keys.end(), [&redis, &streamKey](const auto& tokenKey) {
auto storedKey = redis->get(tokenKey);
return storedKey.has_value() && storedKey.value() == streamKey;
});
} catch (const std::exception& e) {
LOG_ERROR << "Error getting unique viewer count: " << e.what();
return 0;
}
}
void StatsService::pollOmeStats() {
// Get active streams from OME
OmeClient::getInstance().getActiveStreams([this](bool success, const Json::Value& json) {
if (success && json["response"].isArray()) {
for (const auto& stream : json["response"]) {
if (stream.isString()) {
updateStreamStats(stream.asString());
}
}
}
});
// Poll known stream keys from Redis
services::RedisHelper::instance().keysAsync("stream_key:*",
[this](const std::vector<std::string>& keys) {
for (const auto& key : keys) {
if (auto pos = key.find(':'); pos != std::string::npos) {
updateStreamStats(key.substr(pos + 1));
}
}
}
);
}
void StatsService::updateStreamStats(const std::string& streamKey) {
fetchStatsFromOme(streamKey, [this, streamKey](bool success, const StreamStats& stats) {
if (success) {
StreamStats updatedStats = stats;
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
storeStatsInRedis(streamKey, updatedStats);
// Update realm in database
updateRealmLiveStatus(streamKey, updatedStats);
// Only broadcast if stream has meaningful data
if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) {
Json::Value msg;
msg["type"] = "stats_update";
msg["stream_key"] = streamKey;
auto& s = msg["stats"];
JSON_INT(s, "connections", updatedStats.uniqueViewers);
JSON_INT(s, "raw_connections", updatedStats.currentConnections);
s["bitrate"] = updatedStats.bitrate;
s["resolution"] = updatedStats.resolution;
s["fps"] = updatedStats.fps;
s["codec"] = updatedStats.codec;
s["is_live"] = updatedStats.isLive;
JSON_INT(s, "bytes_in", updatedStats.totalBytesIn);
JSON_INT(s, "bytes_out", updatedStats.totalBytesOut);
// Protocol breakdown
auto& pc = s["protocol_connections"];
JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc);
JSON_INT(pc, "hls", updatedStats.protocolConnections.hls);
JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls);
JSON_INT(pc, "dash", updatedStats.protocolConnections.dash);
StreamWebSocketController::broadcastStatsUpdate(msg);
}
}
});
}
void StatsService::updateRealmLiveStatus(const std::string& streamKey, const StreamStats& stats) {
auto dbClient = app().getDbClient();
// Update realm's live status and viewer count
*dbClient << "UPDATE realms SET is_live = $1, viewer_count = $2 WHERE stream_key = $3"
<< stats.isLive << stats.uniqueViewers << streamKey
>> [streamKey, stats](const orm::Result&) {
LOG_DEBUG << "Updated realm status for stream " << streamKey
<< " - Live: " << stats.isLive
<< ", Viewers: " << stats.uniqueViewers;
}
>> [streamKey](const orm::DrogonDbException& e) {
LOG_ERROR << "Failed to update realm status for " << streamKey
<< ": " << e.base().what();
};
}
void StatsService::fetchStatsFromOme(const std::string& streamKey,
std::function<void(bool, const StreamStats&)> callback) {
LOG_DEBUG << "Fetching stats for stream: " << streamKey;
OmeClient::getInstance().getStreamStats(streamKey, [this, callback, streamKey](bool success, const Json::Value& json) {
StreamStats stats;
if (success && json.isMember("response") && !json["response"].isNull()) {
try {
const auto& data = json["response"];
// Parse connections
if (data.isMember("connections")) {
const auto& conns = data["connections"];
int64_t totalConns = 0;
for (const auto& protocolName : conns.getMemberNames()) {
int64_t count = conns[protocolName].asInt64();
auto& pc = stats.protocolConnections;
if (protocolName == "webrtc") pc.webrtc = count;
else if (protocolName == "hls") pc.hls = count;
else if (protocolName == "llhls") pc.llhls = count;
else if (protocolName == "dash") pc.dash = count;
totalConns += count;
}
stats.currentConnections = totalConns;
stats.totalConnections = totalConns;
}
// Bitrate
stats.bitrate = data.isMember("lastThroughputIn") ?
data["lastThroughputIn"].asDouble() :
(data.isMember("avgThroughputIn") ? data["avgThroughputIn"].asDouble() : 0);
// Byte counters
if (data.isMember("totalBytesIn")) stats.totalBytesIn = data["totalBytesIn"].asInt64();
if (data.isMember("totalBytesOut")) stats.totalBytesOut = data["totalBytesOut"].asInt64();
stats.isLive = (stats.bitrate > 0 || stats.currentConnections > 0);
LOG_DEBUG << "OME stats response: " << json.toStyledString();
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse stats: " << e.what();
stats.isLive = false;
}
} else {
stats.isLive = false;
}
stats.lastUpdated = std::chrono::system_clock::now();
// Now fetch stream info for resolution/codec/fps
OmeClient::getInstance().getStreamInfo(streamKey, [callback, stats](bool infoSuccess, const Json::Value& infoJson) mutable {
// Parse stream metadata if available
if (infoSuccess && infoJson.isMember("response") && infoJson["response"].isMember("tracks")) {
try {
for (const auto& track : infoJson["response"]["tracks"]) {
if (track["type"].asString() == "video") {
if (track.isMember("codec")) {
stats.codec = track["codec"].asString();
}
if (track.isMember("width") && track.isMember("height")) {
stats.resolution = std::to_string(track["width"].asInt()) + "x" +
std::to_string(track["height"].asInt());
}
if (track.isMember("framerate")) {
stats.fps = track["framerate"].asDouble();
}
break;
}
}
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse stream info: " << e.what();
}
}
callback(true, stats);
});
});
}
void StatsService::storeStatsInRedis(const std::string& streamKey, const StreamStats& stats) {
Json::Value json;
JSON_INT(json, "connections", stats.currentConnections);
JSON_INT(json, "unique_viewers", stats.uniqueViewers);
JSON_INT(json, "total_connections", stats.totalConnections);
JSON_INT(json, "bytes_in", stats.totalBytesIn);
JSON_INT(json, "bytes_out", stats.totalBytesOut);
json["bitrate"] = stats.bitrate;
json["codec"] = stats.codec;
json["resolution"] = stats.resolution;
json["fps"] = stats.fps;
json["is_live"] = stats.isLive;
JSON_INT(json, "last_updated",
std::chrono::duration_cast<std::chrono::seconds>(
stats.lastUpdated.time_since_epoch()
).count()
);
// Protocol connections
Json::Value pc;
JSON_INT(pc, "webrtc", stats.protocolConnections.webrtc);
JSON_INT(pc, "hls", stats.protocolConnections.hls);
JSON_INT(pc, "llhls", stats.protocolConnections.llhls);
JSON_INT(pc, "dash", stats.protocolConnections.dash);
json["protocol_connections"] = pc;
// Store connection drop timestamp if recent
auto timeSinceDrop = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now() - stats.lastConnectionDrop).count();
if (timeSinceDrop < 60) {
JSON_INT(json, "last_connection_drop",
std::chrono::duration_cast<std::chrono::seconds>(
stats.lastConnectionDrop.time_since_epoch()
).count()
);
}
RedisHelper::storeKey("stream_stats:" + streamKey, Json::FastWriter().write(json), 10);
}
void StatsService::getStreamStats(const std::string& streamKey,
std::function<void(bool, const StreamStats&)> callback) {
std::string jsonStr = RedisHelper::getKey("stream_stats:" + streamKey);
if (jsonStr.empty()) {
// Fetch fresh stats from OME and populate uniqueViewers
LOG_DEBUG << "No cached stats, fetching from OME for " << streamKey;
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
if (success) {
StreamStats updatedStats = stats;
// FIX: Set uniqueViewers on cache miss!
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
callback(true, updatedStats);
} else {
callback(false, stats);
}
});
return;
}
try {
Json::Value json;
Json::Reader reader;
if (reader.parse(jsonStr, json)) {
StreamStats stats;
stats.currentConnections = json["connections"].asInt64();
stats.uniqueViewers = json["unique_viewers"].asInt64();
stats.totalConnections = json["total_connections"].asInt64();
stats.totalBytesIn = json["bytes_in"].asInt64();
stats.totalBytesOut = json["bytes_out"].asInt64();
stats.bitrate = json["bitrate"].asDouble();
stats.codec = json["codec"].asString();
stats.resolution = json["resolution"].asString();
stats.fps = json["fps"].asDouble();
stats.isLive = json["is_live"].asBool();
// Parse protocol connections
if (json.isMember("protocol_connections")) {
const auto& pc = json["protocol_connections"];
stats.protocolConnections.webrtc = pc["webrtc"].asInt64();
stats.protocolConnections.hls = pc["hls"].asInt64();
stats.protocolConnections.llhls = pc["llhls"].asInt64();
stats.protocolConnections.dash = pc["dash"].asInt64();
}
stats.lastUpdated = std::chrono::system_clock::time_point(
std::chrono::seconds(json["last_updated"].asInt64())
);
callback(true, stats);
LOG_DEBUG << "Retrieved cached stats for " << streamKey;
} else {
// Fallback to fresh fetch if cached data is corrupted
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
if (success) {
StreamStats updatedStats = stats;
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
callback(true, updatedStats);
} else {
callback(false, stats);
}
});
}
} catch (const std::exception& e) {
LOG_ERROR << "Failed to parse cached stats: " << e.what();
// Fallback to fresh fetch
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
if (success) {
StreamStats updatedStats = stats;
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
callback(true, updatedStats);
} else {
callback(false, stats);
}
});
}
}

View file

@ -0,0 +1,72 @@
#pragma once
#include <drogon/drogon.h>
#include <trantor/net/EventLoop.h>
#include <memory>
#include <string>
#include <chrono>
#include <atomic>
#include <optional>
struct StreamStats {
int64_t currentConnections = 0; // Raw connection count from OME
int64_t uniqueViewers = 0; // Unique viewer tokens
int64_t totalConnections = 0;
int64_t totalBytesIn = 0;
int64_t totalBytesOut = 0;
double bitrate = 0.0;
std::string codec;
std::string resolution;
double fps = 0.0;
bool isLive = false;
std::chrono::system_clock::time_point lastUpdated;
// Protocol-specific connections
struct ProtocolConnections {
int64_t webrtc = 0;
int64_t hls = 0;
int64_t llhls = 0;
int64_t dash = 0;
} protocolConnections;
// Connection history for deduplication
std::chrono::system_clock::time_point lastConnectionDrop;
int64_t previousTotalConnections = 0;
};
class StatsService {
public:
static StatsService& getInstance() {
static StatsService instance;
return instance;
}
void initialize();
void shutdown();
// Get cached stats from Redis
void getStreamStats(const std::string& streamKey,
std::function<void(bool, const StreamStats&)> callback);
// Force update stats for a specific stream
void updateStreamStats(const std::string& streamKey);
// Get unique viewer count for a stream
int64_t getUniqueViewerCount(const std::string& streamKey);
private:
StatsService() = default;
~StatsService();
StatsService(const StatsService&) = delete;
StatsService& operator=(const StatsService&) = delete;
bool getPreviousStats(const std::string& streamKey, StreamStats& stats);
void pollOmeStats();
void storeStatsInRedis(const std::string& streamKey, const StreamStats& stats);
void fetchStatsFromOme(const std::string& streamKey,
std::function<void(bool, const StreamStats&)> callback);
void updateRealmLiveStatus(const std::string& streamKey, const StreamStats& stats);
std::atomic<bool> running_{false};
std::optional<trantor::TimerId> timerId_;
std::chrono::seconds pollInterval_{2}; // Poll every 2 seconds
};

110
database/init.sql Normal file
View file

@ -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();

0
docker Normal file
View file

134
docker-compose.yml Normal file
View file

@ -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

10
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

41
frontend/Dockerfile Normal file
View file

@ -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"]

31
frontend/package.json Normal file
View file

@ -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"
}

323
frontend/src/app.css Normal file
View file

@ -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;
}

23
frontend/src/app.d.ts vendored Normal file
View file

@ -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 {};

29
frontend/src/app.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Live Streaming Platform</title>
<style>
/* Global reset for better player scaling */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

36
frontend/src/lib/api.js Normal file
View file

@ -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}`);
}

99
frontend/src/lib/pgp.js Normal file
View file

@ -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');
}

View file

@ -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
);

View file

@ -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));
}
}

View file

@ -0,0 +1,228 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth';
import { page } from '$app/stores';
import '../app.css';
let showDropdown = false;
// Close dropdown when route changes
$: if ($page) {
showDropdown = false;
}
onMount(() => {
auth.init();
// Close dropdown when clicking outside
const handleClickOutside = (event) => {
if (!event.target.closest('.user-menu')) {
showDropdown = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
function toggleDropdown() {
showDropdown = !showDropdown;
}
</script>
<style>
.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;
}
.nav-links {
display: flex;
gap: 1rem;
align-items: center;
}
.nav-link {
color: var(--white);
text-decoration: none;
transition: color 0.2s;
padding: 0.5rem 1rem;
}
.nav-link:hover {
color: var(--primary);
}
.user-menu {
position: relative;
}
.user-avatar-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gray);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--white);
font-weight: 600;
transition: border-color 0.2s;
padding: 0;
overflow: hidden;
}
.user-avatar-btn:hover {
border-color: var(--primary);
}
.user-avatar-btn img {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
}
.dropdown {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.dropdown-username {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--white);
text-decoration: none;
display: block;
transition: color 0.2s;
}
.dropdown-username:hover {
color: var(--primary);
}
.dropdown-role {
font-size: 0.85rem;
color: var(--gray);
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--white);
text-decoration: none;
transition: background 0.2s;
}
.dropdown-item:hover {
background: rgba(86, 29, 94, 0.2);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.dropdown-item.logout {
color: var(--error);
}
</style>
<nav class="nav">
<div class="nav-container">
<a href="/" class="nav-brand">Stream</a>
{#if !$auth.loading}
{#if $isAuthenticated}
<div class="user-menu">
<button class="user-avatar-btn" on:click={toggleDropdown}>
{#if $auth.user.avatarUrl}
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
{:else}
{$auth.user.username.charAt(0).toUpperCase()}
{/if}
</button>
{#if showDropdown}
<div class="dropdown">
<div class="dropdown-header">
<a href="/profile/{$auth.user.username}" class="dropdown-username">
{$auth.user.username}
</a>
<div class="dropdown-role">
{#if $isAdmin}
Admin
{:else if $isStreamer}
Streamer
{:else}
User
{/if}
</div>
</div>
<a href="/settings" class="dropdown-item">
Settings
</a>
{#if $isStreamer}
<a href="/my-realms" class="dropdown-item">
My Realms
</a>
{/if}
{#if $isAdmin}
<a href="/admin" class="dropdown-item">
Admin
</a>
{/if}
<div class="dropdown-divider"></div>
<button class="dropdown-item logout" on:click={() => auth.logout()}>
Logout
</button>
</div>
{/if}
</div>
{:else}
<div class="nav-links">
<a href="/login" class="nav-link">Login</a>
</div>
{/if}
{/if}
</div>
</nav>
<slot />

View file

@ -0,0 +1,204 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let streams = [];
let interval;
let loading = true;
async function loadStreams() {
if (!browser) return;
try {
const res = await fetch('/api/realms/live');
if (res.ok) {
streams = await res.json();
}
} catch (e) {
console.error('Failed to load streams:', e);
} finally {
loading = false;
}
}
onMount(() => {
loadStreams();
// Refresh every 10 seconds
interval = setInterval(loadStreams, 10000);
return () => {
if (interval) clearInterval(interval);
};
});
</script>
<style>
.hero {
text-align: center;
padding: 4rem 0;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #8b3a92);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.25rem;
color: var(--gray);
}
.stream-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.stream-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.stream-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(86, 29, 94, 0.3);
border-color: var(--primary);
}
.stream-thumbnail {
width: 100%;
height: 180px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.live-badge {
position: absolute;
top: 1rem;
left: 1rem;
background: #ff0000;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
}
.stream-info {
padding: 1.5rem;
}
.stream-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.stream-meta {
display: flex;
align-items: center;
gap: 1rem;
color: var(--gray);
font-size: 0.9rem;
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.streamer-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray);
}
.viewer-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.viewer-count::before {
content: '•';
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
margin-right: 0.25rem;
}
.no-streams {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-streams-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
<div class="container">
<div class="hero">
<h1>Live Streams</h1>
<p>Watch your favorite streamers live</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading streams...</p>
</div>
{:else if streams.length === 0}
<div class="no-streams">
<div class="no-streams-icon">📺</div>
<h2>No streams live right now</h2>
<p>Check back later or become a streamer yourself!</p>
</div>
{:else}
<div class="stream-grid">
{#each streams as stream}
<a href={`/${stream.name}/live`} class="stream-card">
<div class="stream-thumbnail">
<div class="live-badge">LIVE</div>
<span style="font-size: 3rem; opacity: 0.3;">🎮</span>
</div>
<div class="stream-info">
<h3>{stream.name}</h3>
<div class="stream-meta">
<div class="streamer-info">
{#if stream.avatarUrl}
<img src={stream.avatarUrl} alt={stream.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<span>{stream.username}</span>
</div>
<div class="viewer-count">
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,669 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth';
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
import { goto } from '$app/navigation';
// Import CSS that's safe for SSR
import '@fortawesome/fontawesome-free/css/all.min.css';
import 'mdb-ui-kit/css/mdb.min.css';
// Browser-only imports
let Hls;
let OvenPlayer;
// Only import on client side
if (typeof window !== 'undefined') {
import('hls.js').then(module => {
Hls = module.default;
window.Hls = Hls;
});
import('ovenplayer').then(module => {
OvenPlayer = module.default;
window.OvenPlayer = OvenPlayer;
});
}
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
let player;
let realm = null;
let streamKey = '';
let loading = true;
let error = '';
let message = '';
let isStreaming = false;
let heartbeatInterval;
let viewerToken = null;
let statsInterval;
// Stats
let stats = {
connections: 0,
bitrate: 0,
resolution: 'N/A',
codec: 'N/A',
fps: 0,
isLive: false
};
onMount(async () => {
const realmName = $page.params.realm;
// Load realm info
await loadRealm(realmName);
if (!realm) {
error = 'Realm not found';
loading = false;
return;
}
// Get viewer token
const tokenObtained = await getViewerToken();
if (!tokenObtained) {
loading = false;
return;
}
// Get the actual stream key using the token
const keyObtained = await getStreamKey();
if (!keyObtained) {
loading = false;
return;
}
// Wait for dependencies
const checkDependencies = async () => {
let attempts = 0;
while ((!window.Hls || !window.OvenPlayer) && attempts < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!window.Hls || !window.OvenPlayer) {
console.error('Failed to load dependencies');
error = 'Failed to load player dependencies';
return false;
}
return true;
};
const depsLoaded = await checkDependencies();
if (!depsLoaded) {
loading = false;
return;
}
// Initialize player after a short delay
setTimeout(initializePlayer, 100);
// Start heartbeat
startHeartbeat();
// Start stats polling
startStatsPolling();
// Connect WebSocket
connectWebSocket((data) => {
if (data.type === 'stats_update' && data.stream_key === streamKey) {
updateStatsFromData(data.stats);
}
});
loading = false;
});
onDestroy(() => {
if (player) {
player.remove();
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
if (statsInterval) {
clearInterval(statsInterval);
}
disconnectWebSocket();
});
async function loadRealm(realmName) {
try {
const response = await fetch(`/api/realms/by-name/${realmName}`);
if (response.ok) {
const data = await response.json();
realm = data.realm;
// Get the stream key from the database
const keyResponse = await fetch(`/api/realms/${realm.id}`);
if (keyResponse.ok && keyResponse.status !== 404) {
const keyData = await keyResponse.json();
if (keyData.success && keyData.realm && keyData.realm.streamKey) {
streamKey = keyData.realm.streamKey;
}
} else {
// If we can't get the key directly, we'll need to rely on the backend
// to validate tokens against the realm
streamKey = 'realm-' + realm.id;
}
} else if (response.status === 404) {
error = 'Realm not found';
}
} catch (e) {
console.error('Failed to load realm:', e);
error = 'Failed to load realm';
}
}
async function getViewerToken() {
if (!realm) return;
try {
const response = await fetch(`/api/realms/${realm.id}/viewer-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
viewerToken = data.viewer_token;
console.log('Viewer token obtained');
// Now we need to get the actual stream key for the player
// This will be handled server-side via the token
return true;
} else {
console.error('Failed to get viewer token');
error = 'Failed to authenticate for stream';
return false;
}
} catch (e) {
console.error('Error getting viewer token:', e);
error = 'Failed to authenticate for stream';
return false;
}
}
async function getStreamKey() {
if (!realm || !viewerToken) return false;
try {
const response = await fetch(`/api/realms/${realm.id}/stream-key`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
streamKey = data.streamKey;
console.log('Stream key obtained');
return true;
} else {
console.error('Failed to get stream key');
error = 'Failed to get stream key';
return false;
}
} catch (e) {
console.error('Error getting stream key:', e);
error = 'Failed to get stream key';
return false;
}
}
function startHeartbeat() {
heartbeatInterval = setInterval(async () => {
if (streamKey && viewerToken) {
try {
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
console.error('Heartbeat failed, getting new token');
await getViewerToken();
}
} catch (error) {
console.error('Heartbeat error:', error);
}
}
}, 10000);
}
function startStatsPolling() {
statsInterval = setInterval(async () => {
if (realm) {
try {
const response = await fetch(`/api/realms/${realm.id}/stats`);
const data = await response.json();
if (data.success && data.stats) {
updateStatsFromData(data.stats);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
}, 2000);
}
function updateStatsFromData(data) {
stats = {
connections: data.connections || 0,
bitrate: data.bitrate || 0,
resolution: data.resolution || 'N/A',
codec: data.codec || 'N/A',
fps: data.fps || 0,
isLive: data.is_live || false
};
isStreaming = stats.isLive;
// Update viewer count in realm info if different
if (realm && realm.viewerCount !== stats.connections) {
realm.viewerCount = stats.connections;
}
}
function initializePlayer() {
const playerElement = document.getElementById('player');
if (!playerElement) {
console.error('Player element not found');
return;
}
if (!viewerToken || !streamKey) {
console.error('No viewer token or stream key, cannot initialize player');
return;
}
const sources = [];
if (streamKey) {
// Add all sources
sources.push(
{
type: 'webrtc',
file: `ws://localhost:3333/app/${streamKey}`,
label: 'WebRTC (Ultra Low Latency)'
},
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/llhls.m3u8`,
label: 'LLHLS (Low Latency)'
},
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8`,
label: 'HLS (Standard)'
}
);
}
const config = {
autoStart: true,
autoFallback: true,
controls: true,
showBigPlayButton: true,
watermark: false,
mute: false,
aspectRatio: "16:9",
sources: sources,
webrtcConfig: {
timeoutMaxRetry: 4,
connectionTimeout: 10000
},
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
xhrSetup: function(xhr, url) {
xhr.withCredentials = true;
}
}
};
try {
player = window.OvenPlayer.create('player', config);
player.on('error', (error) => {
console.error('Player error:', error);
isStreaming = false;
if (error.code === 403 || error.code === 401) {
getViewerToken().then(() => {
if (player) {
player.remove();
setTimeout(initializePlayer, 500);
}
});
}
});
player.on('stateChanged', (data) => {
if (data.newstate === 'playing') {
isStreaming = true;
message = '';
} else if (data.newstate === 'error' || data.newstate === 'idle') {
if (!stats.isLive) {
isStreaming = false;
}
}
});
player.on('play', () => {
isStreaming = true;
});
} catch (e) {
console.error('Failed to create player:', e);
error = 'Failed to initialize player';
}
}
function formatBitrate(bitrate) {
if (bitrate > 1000000) {
return (bitrate / 1000000).toFixed(2) + ' Mbps';
} else if (bitrate > 1000) {
return (bitrate / 1000).toFixed(0) + ' Kbps';
} else {
return bitrate + ' bps';
}
}
</script>
<style>
/* Fix the background color issue */
:global(body) {
background: var(--black) !important;
color: var(--white) !important;
}
.stream-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
display: grid;
grid-template-columns: 1fr 320px;
gap: 2rem;
background: var(--black);
color: var(--white);
}
@media (max-width: 1024px) {
.stream-container {
grid-template-columns: 1fr;
}
}
.player-section {
width: 100%;
}
.player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem;
}
.player-area {
position: relative;
}
.dummy-player {
padding-bottom: 56.25%; /* 16:9 aspect ratio */
background: #000;
}
.player-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
}
#player {
width: 100%;
height: 100%;
}
.stream-info-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.stream-header {
margin-bottom: 1.5rem;
}
.stream-header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: var(--white);
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.streamer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray);
}
.streamer-name {
font-weight: 600;
color: var(--white);
}
.viewer-count {
font-size: 0.9rem;
color: var(--gray);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.stats-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.stats-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--primary);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.status-indicator.active {
background: rgba(40, 167, 69, 0.2);
color: var(--success);
}
.status-indicator.inactive {
background: rgba(220, 53, 69, 0.2);
color: var(--error);
}
.stats-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: var(--gray);
font-size: 0.9rem;
}
.stat-value {
font-weight: 600;
font-family: monospace;
color: var(--white);
}
.offline-message {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
.offline-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-container {
text-align: center;
padding: 4rem 2rem;
color: var(--white);
}
.loading-container {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
</style>
{#if loading}
<div class="loading-container">
<p>Loading stream...</p>
</div>
{:else if error && !realm}
<div class="error-container">
<h1>Stream Not Found</h1>
<p style="color: var(--gray); margin-top: 1rem;">{error}</p>
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
</div>
{:else if realm}
<div class="stream-container">
<div class="player-section">
<div class="player-wrapper">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
</div>
</div>
<div class="stream-info-section">
<div class="stream-header">
<h1>{realm.name}</h1>
<div class="streamer-info">
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<div>
<div class="streamer-name">{realm.username}</div>
<div class="viewer-count">
{realm.viewerCount} {realm.viewerCount === 1 ? 'viewer' : 'viewers'}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="stats-section">
<h3>Stream Stats</h3>
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
{#if stats.isLive}
<span></span> Live
{:else}
<span></span> Offline
{/if}
</div>
{#if stats.isLive}
<div class="stats-list">
<div class="stat-item">
<span class="stat-label">Viewers</span>
<span class="stat-value">{stats.connections}</span>
</div>
<div class="stat-item">
<span class="stat-label">Bitrate</span>
<span class="stat-value">{formatBitrate(stats.bitrate)}</span>
</div>
{#if stats.resolution !== 'N/A'}
<div class="stat-item">
<span class="stat-label">Resolution</span>
<span class="stat-value">{stats.resolution}</span>
</div>
{/if}
{#if stats.fps > 0}
<div class="stat-item">
<span class="stat-label">Frame Rate</span>
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
</div>
{/if}
{#if stats.codec}
<div class="stat-item">
<span class="stat-label">Codec</span>
<span class="stat-value">{stats.codec}</span>
</div>
{/if}
</div>
{:else}
<div class="offline-message">
<div class="offline-icon">📺</div>
<p>Stream is currently offline</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if message}
<div class="message" style="position: fixed; top: 2rem; right: 2rem; padding: 1rem 2rem; background: var(--primary); color: white; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000;">
{message}
</div>
{/if}

View file

@ -0,0 +1,385 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin } from '$lib/stores/auth';
import { goto } from '$app/navigation';
let users = [];
let streams = [];
let loading = true;
let message = '';
let error = '';
let activeTab = 'users';
onMount(async () => {
await auth.init();
if (!$isAuthenticated) {
goto('/login');
return;
}
if (!$isAdmin) {
goto('/');
return;
}
await loadData();
});
async function loadData() {
loading = true;
await Promise.all([loadUsers(), loadStreams()]);
loading = false;
}
async function loadUsers() {
try {
const response = await fetch('/api/admin/users', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
users = data.users;
} else {
error = 'Failed to load users';
}
} catch (e) {
error = 'Error loading users';
console.error(e);
}
}
async function loadStreams() {
try {
const response = await fetch('/api/admin/streams', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
streams = data.streams || [];
} else {
error = 'Failed to load streams';
}
} catch (e) {
error = 'Error loading streams';
console.error(e);
}
}
async function promoteToStreamer(userId) {
try {
const response = await fetch(`/api/admin/users/${userId}/promote`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'User promoted to streamer';
await loadUsers();
} else {
error = 'Failed to promote user';
}
} catch (e) {
error = 'Error promoting user';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function demoteFromStreamer(userId) {
if (!confirm('Remove streamer privileges from this user? Their realms will remain but they cannot create new ones.')) {
return;
}
try {
const response = await fetch(`/api/admin/users/${userId}/demote`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'User demoted from streamer';
await loadUsers();
} else {
error = 'Failed to demote user';
}
} catch (e) {
error = 'Error demoting user';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function disconnectStream(streamKey) {
if (!confirm(`Disconnect stream ${streamKey}?`)) return;
try {
const response = await fetch(`/api/admin/streams/${streamKey}/disconnect`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'Stream disconnected';
await loadStreams();
} else {
error = 'Failed to disconnect stream';
}
} catch (e) {
error = 'Error disconnecting stream';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString();
}
</script>
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: #111;
border: 1px solid var(--border);
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--primary);
}
.stat-label {
color: var(--gray);
margin-top: 0.5rem;
}
.data-table {
width: 100%;
overflow-x: auto;
}
.data-table table {
width: 100%;
min-width: 800px;
}
.stream-key-cell {
font-family: monospace;
font-size: 0.9rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.role-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
</style>
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<button on:click={loadData} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{#if message}
<div class="success" style="margin-bottom: 1rem;">{message}</div>
{/if}
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<div class="stats-cards">
<div class="stat-card">
<div class="stat-value">{users.length}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value">{users.filter(u => u.isAdmin).length}</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-value">{users.filter(u => u.isStreamer).length}</div>
<div class="stat-label">Streamers</div>
</div>
<div class="stat-card">
<div class="stat-value">{streams.length}</div>
<div class="stat-label">Active Streams</div>
</div>
</div>
<div class="tab-container">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'users'}
on:click={() => activeTab = 'users'}
>
Users
</button>
<button
class="tab"
class:active={activeTab === 'streams'}
on:click={() => activeTab = 'streams'}
>
Active Streams
</button>
</div>
</div>
{#if loading}
<p>Loading...</p>
{:else if activeTab === 'users'}
<div class="card">
<div class="data-table">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Roles</th>
<th>Realms</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>
<a href="/profile/{user.username}" style="color: var(--primary);">
{user.username}
</a>
</td>
<td>
<div class="role-badges">
{#if user.isAdmin}
<span class="badge badge-admin">Admin</span>
{/if}
{#if user.isStreamer}
<span class="badge" style="background: #28a745;">Streamer</span>
{/if}
{#if !user.isAdmin && !user.isStreamer}
<span class="badge" style="background: #6c757d;">User</span>
{/if}
</div>
</td>
<td>{user.realmCount || 0}</td>
<td>{formatDate(user.createdAt)}</td>
<td>
<div class="actions">
<a href="/profile/{user.username}" class="btn btn-secondary action-btn">
View
</a>
{#if !user.isAdmin}
{#if !user.isStreamer}
<button
class="btn action-btn"
style="background: #28a745;"
on:click={() => promoteToStreamer(user.id)}
>
Make Streamer
</button>
{:else}
<button
class="btn btn-danger action-btn"
on:click={() => demoteFromStreamer(user.id)}
>
Remove Streamer
</button>
{/if}
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else if activeTab === 'streams'}
<div class="card">
{#if streams.length > 0}
<div class="data-table">
<table class="table">
<thead>
<tr>
<th>Realm</th>
<th>Streamer</th>
<th>Stream Key</th>
<th>Viewers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each streams as stream}
<tr>
<td>
<a href="/{stream.name}/live" style="color: var(--primary);">
{stream.name}
</a>
</td>
<td>{stream.username}</td>
<td class="stream-key-cell" title={stream.streamKey}>
{stream.streamKey}
</td>
<td>{stream.viewerCount}</td>
<td>
<button
class="btn btn-danger action-btn"
on:click={() => disconnectStream(stream.streamKey)}
>
Disconnect
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p style="color: var(--gray);">No active streams</p>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,534 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import * as pgp from '$lib/pgp';
import '../../app.css';
let mode = 'login';
let username = '';
let password = '';
let confirmPassword = '';
let error = '';
let loading = false;
let pgpLoading = false;
// PGP login
let pgpChallenge = '';
let pgpPublicKey = '';
let pgpSignature = '';
// For displaying generated keys
let showGeneratedKeys = false;
let generatedPrivateKey = '';
let generatedPublicKey = '';
// Show PGP command example
let showPgpExample = false;
onMount(async () => {
await auth.init();
if ($isAuthenticated) {
goto('/');
}
});
function validatePassword(pass) {
if (pass.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[0-9]/.test(pass)) {
return 'Password must contain at least one number';
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(pass)) {
return 'Password must contain at least one symbol';
}
return '';
}
async function handleLogin() {
error = '';
loading = true;
const result = await auth.login({ username, password });
if (!result.success) {
error = result.error;
// If it's a PGP-only error, automatically switch to PGP login
if (error && error.includes('PGP-only login enabled')) {
// Clear the error and initiate PGP login
error = '';
loading = false;
await initiatePgpLogin();
return;
}
}
loading = false;
}
async function handleRegister() {
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
const passwordError = validatePassword(password);
if (passwordError) {
error = passwordError;
return;
}
loading = true;
try {
// Generate PGP key pair
const keyPair = await pgp.generateKeyPair(username);
const result = await auth.register({
username,
password,
publicKey: keyPair.publicKey,
fingerprint: keyPair.fingerprint
});
if (result.success) {
// Store private key locally
pgp.storePrivateKey(keyPair.privateKey);
// Show keys
generatedPrivateKey = keyPair.privateKey;
generatedPublicKey = keyPair.publicKey;
showGeneratedKeys = true;
} else {
error = result.error;
}
} catch (e) {
error = 'Failed to generate PGP keys';
console.error(e);
}
loading = false;
}
async function initiatePgpLogin() {
error = '';
pgpLoading = true;
try {
const response = await fetch('/api/auth/pgp-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const data = await response.json();
if (response.ok && data.success) {
pgpChallenge = data.challenge;
pgpPublicKey = data.publicKey;
// Clear the signature field
pgpSignature = '';
} else {
error = data.error || 'User not found or PGP not enabled';
}
} catch (e) {
error = 'Failed to initiate PGP login';
console.error(e);
}
pgpLoading = false;
}
async function handlePgpLogin() {
error = '';
loading = true;
try {
if (!pgpSignature) {
error = 'Please provide the signed message';
loading = false;
return;
}
const result = await auth.loginWithPgp(username, pgpSignature, pgpChallenge);
if (!result.success) {
error = result.error;
}
} catch (e) {
error = 'Failed to verify signature';
console.error(e);
}
loading = false;
}
function resetPgpLogin() {
pgpChallenge = '';
pgpPublicKey = '';
pgpSignature = '';
error = '';
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
}
function downloadKey(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function proceedAfterKeys() {
// Auto-login after registration
loading = true;
const result = await auth.login({ username, password });
if (result.success) {
goto('/');
} else {
showGeneratedKeys = false;
mode = 'login';
error = 'Registration successful. Please login.';
}
loading = false;
}
</script>
<div class="auth-container">
{#if showGeneratedKeys}
<h1>Your PGP Keys</h1>
<p style="color: var(--error); margin-bottom: 1rem;">
<strong>Important:</strong> Save your private key securely. You will need it to login with PGP.
</p>
<div class="form-group">
<label>Public Key</label>
<textarea
readonly
rows="10"
value={generatedPublicKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${username}-public-key.asc`)}>
Download
</button>
</div>
</div>
<div class="form-group">
<label>Private Key</label>
<textarea
readonly
rows="10"
value={generatedPrivateKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
Download
</button>
</div>
</div>
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
{loading ? 'Logging in...' : 'Continue'}
</button>
{:else}
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
{#if mode === 'login' && !pgpChallenge}
<form on:submit|preventDefault={handleLogin}>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
bind:value={username}
required
disabled={loading}
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
disabled={loading}
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
OR
</div>
<button
type="button"
class="btn btn-secondary btn-block"
on:click={initiatePgpLogin}
disabled={loading || pgpLoading || !username}
>
{pgpLoading ? 'Loading...' : 'Login with PGP'}
</button>
</form>
{:else if mode === 'login' && pgpChallenge}
<form on:submit|preventDefault={handlePgpLogin}>
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
<div class="instruction-box">
<p><strong>Step 1:</strong> Copy this challenge text:</p>
<div class="pgp-key" style="margin: 0.5rem 0;">
{pgpChallenge}
</div>
<button
type="button"
class="btn btn-secondary"
style="margin-bottom: 1rem;"
on:click={() => copyToClipboard(pgpChallenge)}
>
Copy Challenge
</button>
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
Note: For security, we never handle your private keys. All signing must be done on your device.
</p>
</div>
<button
type="button"
class="btn-link"
style="margin-bottom: 1rem; font-size: 0.9rem;"
on:click={() => showPgpExample = !showPgpExample}
>
{showPgpExample ? 'Hide' : 'Show'} how to sign
</button>
{#if showPgpExample}
<div class="example-section">
<h4>Using GPG command line:</h4>
<pre class="command-example">
# Save the challenge to a file
echo "{pgpChallenge}" > challenge.txt
# Sign with your private key
gpg --armor --detach-sign challenge.txt
# This creates challenge.txt.asc with the signature
# Copy the entire contents including BEGIN/END lines</pre>
<h4>Using Kleopatra (Windows):</h4>
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
<li>Save the challenge text to a file</li>
<li>Right-click the file → Sign</li>
<li>Select your key and choose "Create detached signature"</li>
<li>Open the .asc file and copy its contents</li>
</ol>
<h4>Using GPG Suite (Mac):</h4>
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
<li>Save the challenge text to a file</li>
<li>Right-click the file → Services → OpenPGP: Sign File</li>
<li>Choose "Create Detached Signature"</li>
<li>Open the .sig file and copy its contents</li>
</ol>
</div>
{/if}
<div style="margin: 1.5rem 0; text-align: center; color: var(--gray);">
<strong>Step 3:</strong> Paste your signature below
</div>
<div class="form-group">
<label for="signature">Signed Message</label>
<textarea
id="signature"
bind:value={pgpSignature}
rows="10"
required
disabled={loading}
placeholder="-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2
iQEcBAABCAAGBQJe...
...
-----END PGP SIGNATURE-----"
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading || !pgpSignature}>
{loading ? 'Verifying...' : 'Verify and Login'}
</button>
<button
type="button"
class="btn btn-secondary btn-block"
style="margin-top: 1rem;"
on:click={resetPgpLogin}
disabled={loading}
>
Back
</button>
</form>
{:else}
<form on:submit|preventDefault={handleRegister}>
<div class="form-group">
<label for="reg-username">Username</label>
<input
type="text"
id="reg-username"
bind:value={username}
required
disabled={loading}
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only"
/>
</div>
<div class="form-group">
<label for="reg-password">Password</label>
<input
type="password"
id="reg-password"
bind:value={password}
required
disabled={loading}
/>
<small style="color: var(--gray);">
Must be 8+ characters with at least one number and symbol
</small>
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input
type="password"
id="confirm-password"
bind:value={confirmPassword}
required
disabled={loading}
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading}>
{loading ? 'Creating account...' : 'Register'}
</button>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
A PGP key pair will be generated for your account. You'll be able to save both keys after registration.
</p>
</form>
{/if}
<div style="margin-top: 2rem; text-align: center;">
{#if mode === 'login'}
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; }}>
Need an account? Register
</button>
{:else}
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; }}>
Already have an account? Login
</button>
{/if}
</div>
{/if}
</div>
<style>
.btn-link {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
}
.btn-link:hover {
opacity: 0.8;
}
.instruction-box {
background: rgba(86, 29, 94, 0.05);
border: 1px solid rgba(86, 29, 94, 0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.instruction-box p {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.example-section {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.example-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--primary);
}
.command-example {
background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
font-family: monospace;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre;
margin: 0.5rem 0 1rem 0;
}
.example-section ol {
padding-left: 1.5rem;
}
.example-section ol li {
margin-bottom: 0.25rem;
}
</style>

View file

@ -0,0 +1,504 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { auth, isAuthenticated, isStreamer } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
let realms = [];
let loading = true;
let error = '';
let message = '';
let showCreateModal = false;
let statsInterval;
// Create form
let newRealmName = '';
onMount(async () => {
await auth.init();
if (!$isAuthenticated) {
goto('/login');
return;
}
if (!$isStreamer) {
goto('/');
return;
}
await loadRealms();
// Start polling for stats
statsInterval = setInterval(async () => {
for (const realm of realms) {
if (realm.isLive) {
await updateRealmStats(realm.id);
}
}
}, 2000);
// Connect WebSocket for real-time updates
connectWebSocket((data) => {
if (data.type === 'stats_update') {
const realm = realms.find(r => r.streamKey === data.stream_key);
if (realm && data.stats) {
realm.isLive = data.stats.is_live;
realm.viewerCount = data.stats.connections || 0;
realm.stats = data.stats;
realms = realms;
}
}
});
});
onDestroy(() => {
if (statsInterval) {
clearInterval(statsInterval);
}
disconnectWebSocket();
});
async function loadRealms() {
loading = true;
try {
const response = await fetch('/api/realms', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
realms = data.realms;
// Load stats for each realm
for (const realm of realms) {
await updateRealmStats(realm.id);
}
} else {
error = 'Failed to load realms';
}
} catch (e) {
error = 'Error loading realms';
console.error(e);
} finally {
loading = false;
}
}
async function updateRealmStats(realmId) {
try {
const response = await fetch(`/api/realms/${realmId}/stats`, {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
const realm = realms.find(r => r.id === realmId);
if (realm && data.success && data.stats) {
realm.isLive = data.stats.is_live;
realm.viewerCount = data.stats.connections || 0;
realm.stats = data.stats;
realms = realms;
}
}
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
async function createRealm() {
error = '';
if (!validateRealmName(newRealmName)) {
error = 'Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only';
return;
}
try {
const response = await fetch('/api/realms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: newRealmName })
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Realm created successfully';
showCreateModal = false;
newRealmName = '';
await loadRealms();
} else {
error = data.error || 'Failed to create realm';
}
} catch (e) {
error = 'Error creating realm';
console.error(e);
}
setTimeout(() => { message = ''; }, 3000);
}
async function deleteRealm(realm) {
if (!confirm(`Delete realm "${realm.name}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/realms/${realm.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'Realm deleted successfully';
await loadRealms();
} else {
error = 'Failed to delete realm';
}
} catch (e) {
error = 'Error deleting realm';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function regenerateKey(realm) {
if (!confirm('Regenerate stream key? This will disconnect any active streams.')) {
return;
}
try {
const response = await fetch(`/api/realms/${realm.id}/regenerate-key`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Stream key regenerated successfully';
await loadRealms();
} else {
error = 'Failed to regenerate stream key';
}
} catch (e) {
error = 'Error regenerating stream key';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
function validateRealmName(name) {
return /^[a-z0-9-]{3,30}$/.test(name);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
message = 'Copied to clipboard!';
setTimeout(() => message = '', 2000);
}
function formatBitrate(bitrate) {
if (bitrate > 1000000) {
return (bitrate / 1000000).toFixed(2) + ' Mbps';
} else if (bitrate > 1000) {
return (bitrate / 1000).toFixed(0) + ' Kbps';
} else {
return bitrate + ' bps';
}
}
</script>
<style>
.realms-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.realms-grid {
display: grid;
gap: 2rem;
}
.realm-card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1.5rem;
}
.realm-title h3 {
margin: 0 0 0.5rem 0;
}
.realm-url {
color: var(--gray);
font-size: 0.9rem;
}
.realm-actions {
display: flex;
gap: 0.5rem;
}
.stream-info {
background: rgba(86, 29, 94, 0.1);
border: 1px solid rgba(86, 29, 94, 0.3);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.stream-info-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.stream-info-row:last-child {
margin-bottom: 0;
}
.stream-info-label {
font-weight: 600;
min-width: 100px;
}
.stream-key {
font-family: monospace;
background: rgba(0, 0, 0, 0.3);
padding: 0.25rem 0.5rem;
border-radius: 4px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.status-row {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 1rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.9rem;
}
.status-indicator.active {
background: rgba(40, 167, 69, 0.2);
color: var(--success);
}
.status-indicator.inactive {
background: rgba(220, 53, 69, 0.2);
color: var(--error);
}
.stats-mini {
display: flex;
gap: 2rem;
font-size: 0.9rem;
}
.stat-mini {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stat-mini-label {
color: var(--gray);
}
.form-hint {
font-size: 0.85rem;
color: var(--gray);
margin-top: 0.25rem;
}
.no-realms {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-realms-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
<div class="container">
<div class="realms-header">
<h1>My Realms</h1>
<button
class="btn"
on:click={() => showCreateModal = true}
disabled={realms.length >= 5}
>
Create Realm
</button>
</div>
{#if message}
<div class="success" style="margin-bottom: 1rem;">{message}</div>
{/if}
{#if error && !showCreateModal}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
{#if loading}
<p>Loading realms...</p>
{:else if realms.length === 0}
<div class="no-realms">
<div class="no-realms-icon">🏰</div>
<h2>No realms yet</h2>
<p>Create your first realm to start streaming!</p>
<button
class="btn"
style="margin-top: 1rem;"
on:click={() => showCreateModal = true}
>
Create Your First Realm
</button>
</div>
{:else}
<div class="realms-grid">
{#each realms as realm}
<div class="realm-card">
<div class="realm-header">
<div class="realm-title">
<h3>{realm.name}</h3>
<p class="realm-url">/{realm.name}/live</p>
</div>
<div class="realm-actions">
<button
class="btn btn-danger"
style="padding: 0.5rem 1rem;"
on:click={() => deleteRealm(realm)}
>
Delete
</button>
</div>
</div>
<div class="status-row">
<div class="status-indicator" class:active={realm.isLive} class:inactive={!realm.isLive}>
{#if realm.isLive}
<span></span> Live
{:else}
<span></span> Offline
{/if}
</div>
{#if realm.isLive && realm.stats}
<div class="stats-mini">
<div class="stat-mini">
<span class="stat-mini-label">Viewers:</span>
<span>{realm.viewerCount}</span>
</div>
<div class="stat-mini">
<span class="stat-mini-label">Bitrate:</span>
<span>{formatBitrate(realm.stats.bitrate)}</span>
</div>
{#if realm.stats.resolution !== 'N/A'}
<div class="stat-mini">
<span class="stat-mini-label">Resolution:</span>
<span>{realm.stats.resolution}</span>
</div>
{/if}
</div>
{/if}
</div>
<div class="stream-info">
<div class="stream-info-row">
<span class="stream-info-label">Stream Key:</span>
<span class="stream-key">{realm.streamKey}</span>
<button on:click={() => copyToClipboard(realm.streamKey)}>Copy</button>
</div>
<div class="stream-info-row">
<span class="stream-info-label">RTMP URL:</span>
<span class="stream-key">rtmp://localhost:1935/app/{realm.streamKey}</span>
<button on:click={() => copyToClipboard(`rtmp://localhost:1935/app/${realm.streamKey}`)}>Copy</button>
</div>
<div class="stream-info-row">
<span class="stream-info-label">SRT URL:</span>
<span class="stream-key">srt://localhost:9999?streamid={encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}</span>
<button on:click={() => copyToClipboard(`srt://localhost:9999?streamid=${encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}`)}>Copy</button>
</div>
</div>
<button
class="btn btn-danger"
style="width: 100%;"
on:click={() => regenerateKey(realm)}
>
Regenerate Stream Key
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Realm Modal -->
{#if showCreateModal}
<div class="modal" on:click={() => showCreateModal = false}>
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>Create New Realm</h2>
<button class="modal-close" on:click={() => showCreateModal = false}>×</button>
</div>
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<form on:submit|preventDefault={createRealm}>
<div class="form-group">
<label for="realm-name">Realm Name</label>
<input
type="text"
id="realm-name"
bind:value={newRealmName}
required
pattern="[a-z0-9-]{3,30}"
placeholder="my-awesome-realm"
/>
<p class="form-hint">
3-30 characters, lowercase letters, numbers, and hyphens only.
This will be your realm's URL: /{newRealmName || 'realm-name'}/live
</p>
</div>
<button type="submit" class="btn btn-block">Create Realm</button>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,448 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth, isAuthenticated } from '$lib/stores/auth';
let profile = null;
let pgpKeys = [];
let loading = true;
let error = '';
let isOwnProfile = false;
let activeTab = 'bio';
let expandedKeys = {}; // Track which keys are expanded
onMount(async () => {
// No authentication required - profile is public
const username = $page.params.username;
// Check if viewing own profile (only if authenticated)
if ($isAuthenticated && $auth.user) {
isOwnProfile = $auth.user.username === username;
}
await loadProfile(username);
await loadPgpKeys(username);
loading = false;
});
async function loadProfile(username) {
try {
// Public endpoint - no auth header needed
const response = await fetch(`/api/users/${username}`);
if (response.ok) {
const data = await response.json();
profile = data.profile;
// Check if the profile has pgpOnlyEnabledAt set
if (profile && profile.pgpOnlyEnabledAt) {
console.log('Profile has PGP-only enabled at:', profile.pgpOnlyEnabledAt);
}
} else if (response.status === 404) {
error = 'User not found';
} else {
error = 'Failed to load profile';
}
} catch (e) {
error = 'Error loading profile';
console.error(e);
}
}
async function loadPgpKeys(username) {
try {
// Public endpoint - no auth header needed
const response = await fetch(`/api/users/${username}/pgp-keys`);
if (response.ok) {
const data = await response.json();
pgpKeys = data.keys;
// Initialize all keys as collapsed
pgpKeys.forEach(key => {
expandedKeys[key.fingerprint] = false;
});
}
} catch (e) {
console.error('Failed to load PGP keys:', e);
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
}
function toggleKey(fingerprint) {
expandedKeys[fingerprint] = !expandedKeys[fingerprint];
}
function formatDateTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
timeZoneName: 'short'
});
}
</script>
<style>
.profile-header {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--gray);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 600;
color: var(--white);
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-info h1 {
margin-bottom: 0.5rem;
}
.member-since {
color: var(--gray);
font-size: 0.9rem;
}
.pgp-only-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(40, 167, 69, 0.2);
color: var(--success);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 2rem;
}
.tab-button {
padding: 1rem 2rem;
background: none;
border: none;
color: var(--gray);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
position: relative;
transition: color 0.2s;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
}
.tab-button:hover {
color: var(--white);
}
.tab-button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.bio-section {
padding: 1.5rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.bio-section h3 {
margin-bottom: 1rem;
}
.no-bio {
color: var(--gray);
font-style: italic;
}
.pgp-section {
padding: 1.5rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.pgp-status-info {
background: rgba(86, 29, 94, 0.1);
border: 1px solid rgba(86, 29, 94, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.pgp-status-info.pgp-only {
background: rgba(40, 167, 69, 0.1);
border-color: rgba(40, 167, 69, 0.3);
}
.pgp-status-info p {
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pgp-status-info p:last-child {
margin-bottom: 0;
}
.pgp-status-info .icon {
font-size: 1.2rem;
}
.pgp-key-item {
margin-bottom: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.pgp-key-item:last-child {
margin-bottom: 0;
}
.pgp-key-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.pgp-key-header:hover {
background: rgba(86, 29, 94, 0.1);
margin: -0.5rem;
padding: 0.5rem;
border-radius: 4px;
}
.pgp-key-info {
flex: 1;
}
.fingerprint-display {
font-family: monospace;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.key-date {
color: var(--gray);
font-size: 0.85rem;
}
.expand-icon {
color: var(--gray);
transition: transform 0.2s;
font-size: 1.2rem;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.pgp-key-content {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.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;
max-height: 300px;
overflow-y: auto;
}
.copy-button {
padding: 0.25rem 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
margin-top: 0.5rem;
}
.copy-button:hover {
opacity: 0.9;
}
.no-keys {
color: var(--gray);
text-align: center;
padding: 2rem;
}
.pgp-enabled-date {
font-size: 0.85rem;
color: var(--gray);
font-style: italic;
}
</style>
<div class="container">
{#if loading}
<p>Loading...</p>
{:else if error}
<div class="error">{error}</div>
{:else if profile}
<div class="profile-header">
<div class="profile-avatar">
{#if profile.avatarUrl}
<img src={profile.avatarUrl} alt="{profile.username}" />
{:else}
{profile.username.charAt(0).toUpperCase()}
{/if}
</div>
<div class="profile-info">
<h1>{profile.username}</h1>
<p class="member-since">
Member since {new Date(profile.createdAt).toLocaleDateString()}
</p>
{#if profile.isPgpOnly}
<div class="pgp-only-badge">
<span>🔒</span>
<span>PGP-Only Authentication</span>
</div>
{/if}
</div>
</div>
<div class="tab-nav">
<button
class="tab-button"
class:active={activeTab === 'bio'}
on:click={() => activeTab = 'bio'}
>
Bio
</button>
<button
class="tab-button"
class:active={activeTab === 'pgp'}
on:click={() => activeTab = 'pgp'}
>
PGP Keys ({pgpKeys.length})
</button>
</div>
{#if activeTab === 'bio'}
<div class="bio-section">
<h3>About</h3>
{#if profile.bio}
<p>{profile.bio}</p>
{:else}
<p class="no-bio">No bio yet</p>
{/if}
</div>
{:else if activeTab === 'pgp'}
<div class="pgp-section">
<h3>PGP Keys</h3>
{#if profile.isPgpOnly}
<div class="pgp-status-info pgp-only">
<p>
<span class="icon"></span>
<strong>PGP-Only Authentication Enabled</strong>
</p>
{#if profile.pgpOnlyEnabledAt}
<p class="pgp-enabled-date">
Enabled: {formatDateTime(profile.pgpOnlyEnabledAt)}
</p>
{/if}
<p style="font-size: 0.85rem;">
This user requires PGP signature verification to login.
</p>
</div>
{:else}
<div class="pgp-status-info">
<p>
<span class="icon">🔑</span>
Standard authentication (password + optional PGP)
</p>
</div>
{/if}
{#if pgpKeys.length > 0}
{#each pgpKeys as key}
<div class="pgp-key-item">
<div
class="pgp-key-header"
on:click={() => toggleKey(key.fingerprint)}
on:keypress={(e) => e.key === 'Enter' && toggleKey(key.fingerprint)}
role="button"
tabindex="0"
>
<div class="pgp-key-info">
<div class="fingerprint-display">{key.fingerprint}</div>
<div class="key-date">Added {new Date(key.createdAt).toLocaleDateString()}</div>
</div>
<span class="expand-icon" class:expanded={expandedKeys[key.fingerprint]}>
</span>
</div>
{#if expandedKeys[key.fingerprint]}
<div class="pgp-key-content">
<div class="pgp-key">
{key.publicKey}
</div>
<button
class="copy-button"
on:click={() => copyToClipboard(key.publicKey)}
>
Copy Public Key
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="no-keys">
<p>No PGP keys added</p>
</div>
{/if}
</div>
{/if}
{/if}
</div>

File diff suppressed because it is too large Load diff

56
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,56 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false
}),
// Security improvements
csp: {
mode: 'auto',
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'"],
'style-src': ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com'],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'https://cdnjs.cloudflare.com'],
'connect-src': ["'self'", 'ws://localhost', 'wss://localhost', 'http://localhost:*'],
'media-src': ["'self'", 'blob:', 'http://localhost:*'],
'object-src': ["'none'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'base-uri': ["'self'"]
}
},
// Enable CSRF protection (default is true)
csrf: {
checkOrigin: true
},
// Environment variable configuration
env: {
publicPrefix: 'VITE_' // This is already correct
},
// Ensure default appDir is used (don't override)
// appDir: '_app' // This is the default, no need to set
// Performance: prerender error pages
prerender: {
entries: ['/'],
handleHttpError: ({ path, referrer, message }) => {
// Log errors but don't fail build
console.warn(`${path} (${referrer}) - ${message}`);
}
}
}
};
export default config;

18
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*", ".svelte-kit/ambient.d.ts"],
"exclude": ["node_modules/*", ".svelte-kit/*"]
}

10
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: true
}
});

28
openresty/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM openresty/openresty:alpine
# Install dependencies needed by opm
RUN apk add --no-cache curl perl
# Install lua-resty-http
RUN opm get ledgetech/lua-resty-http
# Copy configuration
COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY lua /usr/local/openresty/nginx/lua
# Create uploads directory structure with proper permissions
RUN mkdir -p /app/uploads/avatars && \
chmod -R 755 /app/uploads
# Create nginx temp directories
RUN mkdir -p /var/cache/nginx/client_temp \
/var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp \
/var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp && \
chmod -R 755 /var/cache/nginx
EXPOSE 80 443
# Run as root but nginx will drop privileges after binding to ports
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]

67
openresty/lua/auth.lua Normal file
View file

@ -0,0 +1,67 @@
local redis_helper = require "redis_helper"
local cjson = require "cjson"
-- Read POST body for OME webhook
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({allowed = false, reason = "No body provided"}))
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- Parse JSON body
local ok, data = pcall(cjson.decode, body)
if not ok then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({allowed = false, reason = "Invalid JSON"}))
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- Extract stream key from the request
local stream_key = nil
-- Check different possible locations for the stream key
if data.request and data.request.url then
-- Extract from URL path or query string
stream_key = data.request.url:match("/app/([^/?]+)")
if not stream_key and data.request.params then
stream_key = data.request.params.key or data.request.params.streamid
end
elseif data.stream and data.stream.name then
stream_key = data.stream.name
end
-- Handle SRT streamid format (app/KEY)
if stream_key then
local extracted = stream_key:match("app/(.+)")
if extracted then
stream_key = extracted
end
end
if not stream_key then
ngx.status = ngx.HTTP_OK
ngx.say(cjson.encode({allowed = false, reason = "No stream key provided"}))
return ngx.exit(ngx.HTTP_OK)
end
-- Validate key
local valid = redis_helper.validate_stream_key(stream_key)
-- OME webhook expects specific response format
local response = {
allowed = valid,
stream = {
name = stream_key
}
}
if not valid then
response.reason = "Invalid stream key"
end
ngx.status = ngx.HTTP_OK
ngx.header.content_type = "application/json"
ngx.say(cjson.encode(response))

View file

@ -0,0 +1,126 @@
local redis = require "resty.redis"
local _M = {}
local function get_redis_connection()
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- connect, send, read timeout in ms
local host = "redis" -- Will be resolved by nginx resolver
local port = tonumber(os.getenv("REDIS_PORT")) or 6379
local ok, err = red:connect(host, port)
if not ok then
ngx.log(ngx.ERR, "Failed to connect to Redis: ", err)
return nil
end
return red
end
local function close_redis_connection(red)
-- Put connection into pool
local ok, err = red:set_keepalive(10000, 100)
if not ok then
ngx.log(ngx.ERR, "Failed to set keepalive: ", err)
end
end
function _M.validate_stream_key(key)
-- For now, skip Redis and go directly to backend
-- This fixes the immediate issue
local http = require "resty.http"
local httpc = http.new()
local backend_url = os.getenv("BACKEND_URL") or "http://drogon-backend:8080"
local res, err = httpc:request_uri(backend_url .. "/api/stream/validate/" .. key, {
method = "GET",
timeout = 1000
})
if res and res.status == 200 then
local cjson = require "cjson"
local ok, data = pcall(cjson.decode, res.body)
if ok and data.valid then
return true
end
end
return false
end
function _M.validate_viewer_token(token, expected_stream_key)
local red = get_redis_connection()
if not red then
ngx.log(ngx.ERR, "Failed to connect to Redis for token validation")
return false
end
-- Get the stream key associated with this token
local res, err = red:get("viewer_token:" .. token)
if not res or res == ngx.null then
ngx.log(ngx.WARN, "Token not found: ", token)
close_redis_connection(red)
return false
end
close_redis_connection(red)
-- Check if the token is for the expected stream
if res ~= expected_stream_key then
ngx.log(ngx.WARN, "Token stream mismatch. Expected: ", expected_stream_key, " Got: ", res)
return false
end
return true
end
function _M.refresh_viewer_token(token)
local red = get_redis_connection()
if not red then
return false
end
-- Refresh TTL to 30 seconds
local ok, err = red:expire("viewer_token:" .. token, 30)
if not ok then
ngx.log(ngx.ERR, "Failed to refresh token TTL: ", err)
end
close_redis_connection(red)
return ok
end
function _M.get_streams_to_disconnect()
local red = get_redis_connection()
if not red then
return {}
end
local res, err = red:smembers("streams_to_disconnect")
close_redis_connection(red)
if not res then
ngx.log(ngx.ERR, "Failed to get streams to disconnect: ", err)
return {}
end
return res
end
function _M.remove_stream_from_disconnect(key)
local red = get_redis_connection()
if not red then
return
end
local ok, err = red:srem("streams_to_disconnect", key)
if not ok then
ngx.log(ngx.ERR, "Failed to remove stream from disconnect set: ", err)
end
close_redis_connection(red)
end
return _M

View file

@ -0,0 +1,84 @@
local redis_helper = require "redis_helper"
local cjson = require "cjson"
-- Safely load the http module
local has_http, http = pcall(require, "resty.http")
local function disconnect_stream(stream_key)
if not has_http then
ngx.log(ngx.WARN, "resty.http module not available, skipping stream disconnection")
return
end
local httpc = http.new()
local ome_url = os.getenv("OME_URL") or "http://ovenmediaengine:8081"
local ome_token = os.getenv("OME_API_TOKEN") or "your-api-token"
-- Get active streams from OME
local res, err = httpc:request_uri(ome_url .. "/v1/vhosts/default/apps/app/streams", {
method = "GET",
headers = {
["Authorization"] = "Bearer " .. ome_token,
["Content-Type"] = "application/json"
}
})
if not res then
ngx.log(ngx.ERR, "Failed to get streams: ", err)
return
end
local ok, data = pcall(cjson.decode, res.body)
if ok and data and data.response and data.response.streams then
for _, stream in ipairs(data.response.streams) do
if stream.name == stream_key then
-- Disconnect the stream
local del_res, del_err = httpc:request_uri(
ome_url .. "/v1/vhosts/default/apps/app/streams/" .. stream.name,
{
method = "DELETE",
headers = {
["Authorization"] = "Bearer " .. ome_token
}
}
)
if del_res and del_res.status == 200 then
ngx.log(ngx.INFO, "Disconnected stream: ", stream_key)
else
ngx.log(ngx.ERR, "Failed to disconnect stream: ", stream_key)
end
break
end
end
end
end
local function monitor_streams()
-- Check if Redis is available
local ok, keys = pcall(redis_helper.get_streams_to_disconnect)
if not ok then
ngx.log(ngx.WARN, "Redis not available yet")
return
end
for _, key in ipairs(keys or {}) do
disconnect_stream(key)
redis_helper.remove_stream_from_disconnect(key)
end
end
-- Start monitoring in a timer with error handling
local function start_monitoring()
local ok, err = ngx.timer.every(1, monitor_streams)
if not ok then
ngx.log(ngx.ERR, "Failed to create timer: ", err)
else
ngx.log(ngx.INFO, "Stream monitor started")
end
end
-- Delay start to ensure services are ready
ngx.timer.at(5, start_monitoring)

450
openresty/nginx.conf Normal file
View file

@ -0,0 +1,450 @@
worker_processes auto;
error_log stderr warn;
# Run worker processes as nobody user (master process remains root for port binding)
user nobody nogroup;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# Temp directories
client_body_temp_path /var/cache/nginx/client_temp;
proxy_temp_path /var/cache/nginx/proxy_temp;
fastcgi_temp_path /var/cache/nginx/fastcgi_temp;
uwsgi_temp_path /var/cache/nginx/uwsgi_temp;
scgi_temp_path /var/cache/nginx/scgi_temp;
# Docker DNS resolver
resolver 127.0.0.11 valid=30s;
lua_package_path "/usr/local/openresty/nginx/lua/?.lua;;";
lua_shared_dict stream_keys 10m;
lua_shared_dict rate_limit 10m;
# Enable compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Map to handle CORS origin properly
map $http_origin $cors_origin {
default "";
"~^https?://localhost(:[0-9]+)?$" $http_origin;
"~^https?://127\.0\.0\.1(:[0-9]+)?$" $http_origin;
"~^https?://\[::1\](:[0-9]+)?$" $http_origin;
}
upstream backend {
server drogon-backend:8080;
}
upstream frontend {
server sveltekit:3000;
}
upstream ome {
server ovenmediaengine:8080;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=1r/m;
# Increase client max body size for avatar uploads
client_max_body_size 1m;
server {
listen 80;
server_name localhost;
# Security headers for the whole server
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Fixed: Serve uploaded files with correct configuration
location /uploads/ {
# Use root directive with absolute path to avoid alias+try_files bug
root /app;
# Security settings
autoindex off;
add_header X-Content-Type-Options "nosniff" always;
# Only serve files, not directories
try_files $uri =404;
# Cache static images
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
}
}
# SvelteKit immutable assets (with content hashes)
location ~ ^/_app/immutable/ {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long cache for immutable assets
proxy_cache_valid 200 1y;
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
}
# SvelteKit mutable assets
location ~ ^/_app/ {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Short cache for mutable assets
expires 1h;
add_header Cache-Control "public, max-age=3600" always;
}
# Authentication endpoints with strict rate limiting
location = /api/auth/register {
limit_req zone=register_limit burst=2 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/auth/login {
limit_req zone=auth_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/api/auth/(pgp-challenge|pgp-verify) {
limit_req zone=auth_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Public user profile endpoints - no authentication required
location ~ ^/api/users/[^/]+/?$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache public profiles for a short time
expires 1m;
add_header Cache-Control "public, max-age=60" always;
}
# Public user PGP keys endpoints - no authentication required
location ~ ^/api/users/[^/]+/pgp-keys$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache PGP keys for a bit longer as they change less frequently
expires 5m;
add_header Cache-Control "public, max-age=300" always;
}
# Public realm endpoints (with viewer token authentication for stream-key)
location ~ ^/api/realms/(by-name/[^/]+|live|[0-9]+/stats|[0-9]+/viewer-token|[0-9]+/stream-key)$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Short cache for live realm data
expires 10s;
add_header Cache-Control "public, max-age=10" always;
}
# Public stream endpoints (some require viewer tokens)
location ~ ^/api/stream/(heartbeat/[^/]+)$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
}
# Other API endpoints (authenticated)
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Frontend (all other requests)
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Enable HTTP/1.1 for keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
# Separate server block for port 8088 (HLS/LLHLS)
server {
listen 8088;
server_name localhost;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
# Token validation for HLS/LLHLS playlists and segments
location ~ ^/app/([^/]+)/(.*\.(m3u8|ts|m4s))$ {
set $stream_key $1;
set $file_path $2;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Access control via Lua
access_by_lua_block {
local redis_helper = require "redis_helper"
-- Get viewer token from cookie
local cookie_header = ngx.var.http_cookie
if not cookie_header then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("No authentication token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Extract viewer_token cookie
local token = nil
-- Handle URL-encoded cookies and spaces
cookie_header = ngx.unescape_uri(cookie_header)
for k, v in string.gmatch(cookie_header, "([^=]+)=([^;]+)") do
k = k:match("^%s*(.-)%s*$") -- trim whitespace
if k == "viewer_token" then
token = v:match("^%s*(.-)%s*$") -- trim whitespace
break
end
end
if not token then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("Missing viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Validate token
local valid_stream = redis_helper.validate_viewer_token(token, ngx.var.stream_key)
if not valid_stream then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("Invalid viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Optionally refresh token TTL on segment access
redis_helper.refresh_viewer_token(token)
}
# Cache settings for segments
location ~ \.ts$ {
expires 1h;
add_header Cache-Control "public, max-age=3600" always;
}
# Don't cache playlists
location ~ \.m3u8$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Proxy to OvenMediaEngine
proxy_pass http://ome/app/$stream_key/$file_path;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Public access for stream info
location / {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Credentials "true" always;
proxy_pass http://ome;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

123
ovenmediaengine/Server.xml Normal file
View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<Server version="8">
<Name>OvenMediaEngine</Name>
<Type>origin</Type>
<IP>*</IP>
<PrivacyProtection>false</PrivacyProtection>
<Bind>
<Managers>
<API>
<Port>8081</Port>
<TLSPort>8443</TLSPort>
<WorkerCount>1</WorkerCount>
</API>
</Managers>
<Managers>
<API>
<Port>8081</Port>
<TLS>
<Port>8082</Port>
</TLS>
</API>
</Managers>
<Providers>
<RTMP>
<Port>1935</Port>
</RTMP>
<SRT>
<Port>9999</Port>
</SRT>
</Providers>
<Publishers>
<HLS>
<Port>8080</Port>
</HLS>
<LLHLS>
<Port>8080</Port>
</LLHLS>
<WebRTC>
<Signalling>
<Port>3333</Port>
</Signalling>
<IceCandidates>
<IceCandidate>*:10000-10009/udp</IceCandidate>
</IceCandidates>
</WebRTC>
</Publishers>
</Bind>
<Managers>
<API>
<AccessToken>${env:OME_API_ACCESS_TOKEN}</AccessToken>
<CrossDomains>
<Url>*</Url>
</CrossDomains>
</API>
</Managers>
<VirtualHosts>
<VirtualHost>
<Name>default</Name>
<Host>
<Names>
<Name>*</Name>
</Names>
</Host>
<Applications>
<Application>
<Name>app</Name>
<Type>live</Type>
<Providers>
<RTMP>
<BlockDuplicateStreamName>true</BlockDuplicateStreamName>
</RTMP>
<SRT>
<BlockDuplicateStreamName>true</BlockDuplicateStreamName>
</SRT>
</Providers>
<OutputProfiles>
<OutputProfile>
<Name>bypass</Name>
<OutputStreamName>${OriginStreamName}</OutputStreamName>
<Encodes>
<Video>
<Bypass>true</Bypass>
</Video>
<Audio>
<Bypass>true</Bypass>
</Audio>
</Encodes>
</OutputProfile>
</OutputProfiles>
<Publishers>
<LLHLS>
<ChunkDuration>0.5</ChunkDuration>
<SegmentDuration>3</SegmentDuration>
<SegmentCount>10</SegmentCount>
<CrossDomains>
<Url>http://localhost</Url>
</CrossDomains>
</LLHLS>
<HLS>
<SegmentDuration>3</SegmentDuration>
<SegmentCount>10</SegmentCount>
<CrossDomains>
<Url>http://localhost</Url>
</CrossDomains>
</HLS>
<WebRTC>
<Timeout>30000</Timeout>
<Rtx>false</Rtx>
<Ulpfec>false</Ulpfec>
<JitterBuffer>false</JitterBuffer>
</WebRTC>
</Publishers>
</Application>
</Applications>
</VirtualHost>
</VirtualHosts>
</Server>

10995
text.txt Normal file

File diff suppressed because it is too large Load diff