13205 lines
454 KiB
Text
13205 lines
454 KiB
Text
Folder PATH listing
|
|
Volume serial number is 000001F9 1430:6C90
|
|
C:\USERS\ADMINISTRATOR\DESKTOP\PUB\REALMS.INDIA
|
|
¦ .env
|
|
¦ docker
|
|
¦ docker-compose.yml
|
|
¦ text.txt
|
|
¦
|
|
+---backend
|
|
¦ ¦ .dockerignore
|
|
¦ ¦ CMakeLists.txt
|
|
¦ ¦ conanfile.txt
|
|
¦ ¦ config.json
|
|
¦ ¦ Dockerfile
|
|
¦ ¦
|
|
¦ +---src
|
|
¦ ¦ admin_tool.cpp
|
|
¦ ¦ main.cpp
|
|
¦ ¦
|
|
¦ +---common
|
|
¦ ¦ utils.h
|
|
¦ ¦
|
|
¦ +---controllers
|
|
¦ ¦ AdminController.cpp
|
|
¦ ¦ AdminController.h
|
|
¦ ¦ RealmController.cpp
|
|
¦ ¦ RealmController.h
|
|
¦ ¦ StreamController.cpp
|
|
¦ ¦ StreamController.h
|
|
¦ ¦ UserController.cpp
|
|
¦ ¦ UserController.h
|
|
¦ ¦
|
|
¦ +---models
|
|
¦ ¦ Realm.h
|
|
¦ ¦ StreamKey.h
|
|
¦ ¦
|
|
¦ +---services
|
|
¦ AuthService.cpp
|
|
¦ AuthService.h
|
|
¦ CorsMiddleware.h
|
|
¦ DatabaseService.cpp
|
|
¦ DatabaseService.h
|
|
¦ OmeClient.h
|
|
¦ RedisHelper.cpp
|
|
¦ RedisHelper.h
|
|
¦ StatsService.cpp
|
|
¦ StatsService.h
|
|
¦
|
|
+---database
|
|
¦ init.sql
|
|
¦
|
|
+---frontend
|
|
¦ ¦ .gitignore
|
|
¦ ¦ Dockerfile
|
|
¦ ¦ package.json
|
|
¦ ¦ svelte.config.js
|
|
¦ ¦ tsconfig.json
|
|
¦ ¦ vite.config.ts
|
|
¦ ¦
|
|
¦ +---src
|
|
¦ ¦ app.css
|
|
¦ ¦ app.d.ts
|
|
¦ ¦ app.html
|
|
¦ ¦
|
|
¦ +---lib
|
|
¦ ¦ ¦ api.js
|
|
¦ ¦ ¦ pgp.js
|
|
¦ ¦ ¦ websocket.js
|
|
¦ ¦ ¦
|
|
¦ ¦ +---stores
|
|
¦ ¦ auth.js
|
|
¦ ¦ user.js
|
|
¦ ¦
|
|
¦ +---routes
|
|
¦ ¦ +layout.svelte
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---admin
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---login
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---my-realms
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---profile
|
|
¦ ¦ +---[username]
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---settings
|
|
¦ ¦ +page.svelte
|
|
¦ ¦
|
|
¦ +---[realm]
|
|
¦ +---live
|
|
¦ +page.svelte
|
|
¦
|
|
+---openresty
|
|
¦ ¦ Dockerfile
|
|
¦ ¦ nginx.conf
|
|
¦ ¦
|
|
¦ +---lua
|
|
¦ auth.lua
|
|
¦ redis_helper.lua
|
|
¦ stream_monitor.lua
|
|
¦
|
|
+---ovenmediaengine
|
|
¦ Server.xml
|
|
¦
|
|
+---scripts
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\.env ###
|
|
|
|
# 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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\docker ###
|
|
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\docker-compose.yml ###
|
|
|
|
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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\.dockerignore ###
|
|
|
|
# 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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\CMakeLists.txt ###
|
|
|
|
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)
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\conanfile.txt ###
|
|
|
|
[requires]
|
|
redis-plus-plus/1.3.13
|
|
hiredis/1.2.0
|
|
|
|
[options]
|
|
redis-plus-plus/*:shared=True
|
|
hiredis/*:shared=True
|
|
|
|
[generators]
|
|
CMakeDeps
|
|
CMakeToolchain
|
|
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\config.json ###
|
|
|
|
{
|
|
"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": {}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\Dockerfile ###
|
|
|
|
FROM drogonframework/drogon:latest
|
|
|
|
WORKDIR /app
|
|
|
|
# Install additional dependencies including GPG for PGP verification
|
|
RUN apt-get update && apt-get install -y \
|
|
libpq-dev \
|
|
postgresql-client \
|
|
pkg-config \
|
|
git \
|
|
cmake \
|
|
libhiredis-dev \
|
|
curl \
|
|
libssl-dev \
|
|
gnupg \
|
|
gnupg2 \
|
|
&& 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
|
|
|
|
# Create a temporary directory for GPG operations
|
|
RUN mkdir -p /tmp/pgp_verify && \
|
|
chmod 777 /tmp/pgp_verify
|
|
|
|
# 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 "Checking GPG installation..."\n\
|
|
gpg --version\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"]
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\admin_tool.cpp ###
|
|
|
|
#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;
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\main.cpp ###
|
|
|
|
#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");
|
|
|
|
// Initialize StatsService BEFORE registering callbacks
|
|
LOG_INFO << "Initializing StatsService...";
|
|
StatsService::getInstance().initialize();
|
|
|
|
// 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 start the stats timer
|
|
app().registerBeginningAdvice([]() {
|
|
LOG_INFO << "Application started successfully";
|
|
|
|
// Start the stats polling timer
|
|
LOG_INFO << "Starting stats polling...";
|
|
StatsService::getInstance().startPolling();
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\common\utils.h ###
|
|
|
|
#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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\AdminController.cpp ###
|
|
|
|
#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;
|
|
}
|
|
|
|
// Update getUsers in AdminController.cpp:
|
|
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, u.color_code, "
|
|
"(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["colorCode"] = row["color_code"].isNull() ? "#561D5E" : row["color_code"].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"));
|
|
};
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\AdminController.h ###
|
|
|
|
#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);
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\RealmController.cpp ###
|
|
|
|
#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 &, // Remove parameter name since it's unused
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &realmId) {
|
|
// Remove authentication requirement for public viewing
|
|
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.is_active = true"
|
|
<< 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>();
|
|
// Don't expose stream key in public endpoint
|
|
// 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) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
// Parse realm ID
|
|
int64_t id;
|
|
try {
|
|
id = std::stoll(realmId);
|
|
} catch (...) {
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
return;
|
|
}
|
|
|
|
// Verify the realm exists and belongs to the user
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id FROM realms WHERE id = $1 AND user_id = $2"
|
|
<< id << user.id
|
|
>> [callback](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Realm not found or access denied", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
// Currently no fields to update since we removed display_name and description
|
|
// This endpoint is kept for potential future updates
|
|
// For now, just return success
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Realm updated successfully";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(jsonError("Database error"));
|
|
};
|
|
}
|
|
|
|
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"));
|
|
};
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\RealmController.h ###
|
|
|
|
#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);
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\StreamController.cpp ###
|
|
|
|
#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);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\StreamController.h ###
|
|
|
|
#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_;
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\UserController.cpp ###
|
|
|
|
#include "UserController.h"
|
|
#include "../services/DatabaseService.h"
|
|
#include <drogon/MultiPart.h>
|
|
#include <drogon/Cookie.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;
|
|
}
|
|
}
|
|
|
|
// Helper to set httpOnly auth cookie
|
|
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
|
Cookie authCookie("auth_token", token);
|
|
authCookie.setPath("/");
|
|
authCookie.setHttpOnly(true);
|
|
authCookie.setSecure(false); // Set to true in production with HTTPS
|
|
authCookie.setMaxAge(86400); // 24 hours
|
|
authCookie.setSameSite(Cookie::SameSite::kLax);
|
|
resp->addCookie(authCookie);
|
|
}
|
|
|
|
// Helper to clear auth cookie
|
|
void clearAuthCookie(const HttpResponsePtr& resp) {
|
|
Cookie authCookie("auth_token", "");
|
|
authCookie.setPath("/");
|
|
authCookie.setHttpOnly(true);
|
|
authCookie.setMaxAge(0); // Expire immediately
|
|
resp->addCookie(authCookie);
|
|
}
|
|
}
|
|
|
|
UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
|
|
UserInfo user;
|
|
|
|
// First try to get from cookie
|
|
std::string token = req->getCookie("auth_token");
|
|
|
|
// Fallback to Authorization header for API clients
|
|
if (token.empty()) {
|
|
std::string auth = req->getHeader("Authorization");
|
|
if (!auth.empty() && auth.substr(0, 7) == "Bearer ") {
|
|
token = auth.substr(7);
|
|
}
|
|
}
|
|
|
|
if (!token.empty()) {
|
|
AuthService::getInstance().validateToken(token, user);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
void UserController::register_(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
LOG_DEBUG << "Registration request received";
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
LOG_WARN << "Invalid JSON in registration request";
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
// Check if all required fields exist before accessing them
|
|
if (!(*json).isMember("username") ||
|
|
!(*json).isMember("password") ||
|
|
!(*json).isMember("publicKey") ||
|
|
!(*json).isMember("fingerprint")) {
|
|
LOG_WARN << "Missing required fields in registration request";
|
|
callback(jsonError("Missing required fields"));
|
|
return;
|
|
}
|
|
|
|
// Safely extract the values
|
|
std::string username = (*json)["username"].asString();
|
|
std::string password = (*json)["password"].asString();
|
|
std::string publicKey = (*json)["publicKey"].asString();
|
|
std::string fingerprint = (*json)["fingerprint"].asString();
|
|
|
|
// Validate that none of the strings are empty
|
|
if (username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) {
|
|
LOG_WARN << "Empty required fields in registration request";
|
|
callback(jsonError("All fields are required"));
|
|
return;
|
|
}
|
|
|
|
// Additional validation
|
|
if (username.length() > 30) {
|
|
callback(jsonError("Username too long (max 30 characters)"));
|
|
return;
|
|
}
|
|
|
|
if (password.length() < 8) {
|
|
callback(jsonError("Password must be at least 8 characters"));
|
|
return;
|
|
}
|
|
|
|
LOG_INFO << "Processing registration for user: " << username;
|
|
|
|
AuthService::getInstance().registerUser(username, password, publicKey, fingerprint,
|
|
[callback, username](bool success, const std::string& error, int64_t userId) {
|
|
if (success) {
|
|
LOG_INFO << "User registered successfully: " << username << " (ID: " << userId << ")";
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["userId"] = static_cast<Json::Int64>(userId);
|
|
callback(jsonResp(resp));
|
|
} else {
|
|
LOG_WARN << "Registration failed for " << username << ": " << error;
|
|
callback(jsonError(error));
|
|
}
|
|
});
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in register_: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
} catch (...) {
|
|
LOG_ERROR << "Unknown exception in register_";
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::login(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
LOG_DEBUG << "Login request received";
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
// Check if fields exist before accessing
|
|
if (!(*json).isMember("username") || !(*json).isMember("password")) {
|
|
callback(jsonError("Missing credentials"));
|
|
return;
|
|
}
|
|
|
|
std::string username = (*json)["username"].asString();
|
|
std::string password = (*json)["password"].asString();
|
|
|
|
if (username.empty() || password.empty()) {
|
|
callback(jsonError("Missing credentials"));
|
|
return;
|
|
}
|
|
|
|
LOG_INFO << "Login attempt for user: " << username;
|
|
|
|
AuthService::getInstance().loginUser(username, password,
|
|
[callback, username](bool success, const std::string& token, const UserInfo& user) {
|
|
if (success) {
|
|
LOG_INFO << "Login successful for user: " << username;
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
// Don't send token in body for cookie-based auth
|
|
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;
|
|
resp["user"]["colorCode"] = user.colorCode;
|
|
|
|
auto response = jsonResp(resp);
|
|
setAuthCookie(response, token);
|
|
callback(response);
|
|
} else {
|
|
LOG_WARN << "Login failed for user: " << username;
|
|
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in login: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
} catch (...) {
|
|
LOG_ERROR << "Unknown exception in login";
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::logout(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Logged out successfully";
|
|
|
|
auto response = jsonResp(resp);
|
|
clearAuthCookie(response);
|
|
callback(response);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in logout: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("username")) {
|
|
callback(jsonError("Username required"));
|
|
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));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in pgpChallenge: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::pgpVerify(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("username") ||
|
|
!(*json).isMember("signature") ||
|
|
!(*json).isMember("challenge")) {
|
|
callback(jsonError("Missing required fields"));
|
|
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;
|
|
// Don't send token in body for cookie-based auth
|
|
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;
|
|
resp["user"]["colorCode"] = user.colorCode;
|
|
|
|
auto response = jsonResp(resp);
|
|
setAuthCookie(response, token);
|
|
callback(response);
|
|
} else {
|
|
callback(jsonError("Invalid signature", k401Unauthorized));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in pgpVerify: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getCurrentUser(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
|
"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>();
|
|
resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get user data: " << e.base().what();
|
|
callback(jsonError("Database error"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getCurrentUser: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::updateProfile(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
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).isMember("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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updateProfile: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::updatePassword(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("oldPassword") || !(*json).isMember("newPassword")) {
|
|
callback(jsonError("Missing passwords"));
|
|
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));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updatePassword: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::togglePgpOnly(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
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).isMember("enable") ? (*json)["enable"].asBool() : false;
|
|
|
|
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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in togglePgpOnly: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::addPgpKey(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("publicKey") || !(*json).isMember("fingerprint")) {
|
|
callback(jsonError("Missing key data"));
|
|
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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in addPgpKey: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getPgpKeys(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getPgpKeys: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::uploadAvatar(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in uploadAvatar: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getProfile(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &username) {
|
|
try {
|
|
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, u.user_color "
|
|
"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>();
|
|
resp["profile"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(jsonError("Database error"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getProfile: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getUserPgpKeys(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &username) {
|
|
try {
|
|
// 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"));
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getUserPgpKeys: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::updateColor(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("color")) {
|
|
callback(jsonError("Color is required"));
|
|
return;
|
|
}
|
|
|
|
std::string newColor = (*json)["color"].asString();
|
|
|
|
if (newColor.empty()) {
|
|
callback(jsonError("Color is required"));
|
|
return;
|
|
}
|
|
|
|
AuthService::getInstance().updateUserColor(user.id, newColor,
|
|
[callback, user](bool success, const std::string& error, const std::string& finalColor) {
|
|
if (success) {
|
|
// Fetch updated user info
|
|
AuthService::getInstance().fetchUserInfo(user.id,
|
|
[callback, finalColor](bool fetchSuccess, const UserInfo& updatedUser) {
|
|
if (fetchSuccess) {
|
|
// Generate new token with updated user info including color
|
|
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["color"] = finalColor;
|
|
resp["user"]["id"] = static_cast<Json::Int64>(updatedUser.id);
|
|
resp["user"]["username"] = updatedUser.username;
|
|
resp["user"]["isAdmin"] = updatedUser.isAdmin;
|
|
resp["user"]["isStreamer"] = updatedUser.isStreamer;
|
|
resp["user"]["colorCode"] = updatedUser.colorCode;
|
|
|
|
auto response = jsonResp(resp);
|
|
// Update auth cookie with new token
|
|
setAuthCookie(response, newToken);
|
|
callback(response);
|
|
} else {
|
|
// Color was updated but couldn't fetch full user info
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["color"] = finalColor;
|
|
resp["message"] = "Color updated but please refresh for new token";
|
|
callback(jsonResp(resp));
|
|
}
|
|
});
|
|
} else {
|
|
callback(jsonError(error));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updateColor: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getAvailableColors(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
// Define available colors for user profiles
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
Json::Value colors(Json::arrayValue);
|
|
|
|
// Add predefined color options
|
|
colors.append("#561D5E"); // Default purple
|
|
colors.append("#1E88E5"); // Blue
|
|
colors.append("#43A047"); // Green
|
|
colors.append("#E53935"); // Red
|
|
colors.append("#FB8C00"); // Orange
|
|
colors.append("#8E24AA"); // Purple variant
|
|
colors.append("#00ACC1"); // Cyan
|
|
colors.append("#FFB300"); // Amber
|
|
colors.append("#546E7A"); // Blue Grey
|
|
colors.append("#D81B60"); // Pink
|
|
|
|
resp["colors"] = colors;
|
|
callback(jsonResp(resp));
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getAvailableColors: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\controllers\UserController.h ###
|
|
|
|
#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::logout, "/api/auth/logout", 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);
|
|
ADD_METHOD_TO(UserController::updateColor, "/api/user/color", Put);
|
|
ADD_METHOD_TO(UserController::getAvailableColors, "/api/colors/available", 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 logout(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);
|
|
|
|
void updateColor(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
|
|
|
void getAvailableColors(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
|
|
|
private:
|
|
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\models\Realm.h ###
|
|
|
|
#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;
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\models\StreamKey.h ###
|
|
|
|
#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;
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\AuthService.cpp ###
|
|
|
|
#include "AuthService.h"
|
|
#include "DatabaseService.h"
|
|
#include "RedisHelper.h"
|
|
#include <drogon/utils/Utilities.h>
|
|
#include <regex>
|
|
#include <random>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <cstdlib>
|
|
|
|
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;
|
|
}
|
|
|
|
// Helper function to execute GPG commands
|
|
std::string executeGpgCommand(const std::string& command) {
|
|
std::array<char, 128> buffer;
|
|
std::string result;
|
|
|
|
FILE* pipe = popen(command.c_str(), "r");
|
|
if (!pipe) {
|
|
LOG_ERROR << "Failed to execute GPG command: " << command;
|
|
return "";
|
|
}
|
|
|
|
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
|
result += buffer.data();
|
|
}
|
|
|
|
int exitCode = pclose(pipe);
|
|
|
|
// Exit code is returned as status << 8, so we need to extract the actual exit code
|
|
int actualExitCode = WEXITSTATUS(exitCode);
|
|
|
|
if (actualExitCode != 0) {
|
|
LOG_ERROR << "GPG command failed with exit code: " << actualExitCode
|
|
<< " for command: " << command
|
|
<< " output: " << result;
|
|
// Don't return empty string immediately - sometimes GPG returns non-zero but still works
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Server-side PGP signature verification
|
|
bool verifyPgpSignature(const std::string& message, const std::string& signature, const std::string& publicKey) {
|
|
try {
|
|
// Create temporary directory for GPG operations
|
|
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(8);
|
|
std::string mkdirCmd = "mkdir -p " + tmpDir;
|
|
if (system(mkdirCmd.c_str()) != 0) {
|
|
LOG_ERROR << "Failed to create temporary directory: " << tmpDir;
|
|
return false;
|
|
}
|
|
|
|
// Create GPG home directory
|
|
std::string keyringDir = tmpDir + "/gnupg";
|
|
std::string mkdirGpgCmd = "mkdir -p " + keyringDir + " && chmod 700 " + keyringDir;
|
|
if (system(mkdirGpgCmd.c_str()) != 0) {
|
|
LOG_ERROR << "Failed to create GPG home directory: " << keyringDir;
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Write files
|
|
std::string messageFile = tmpDir + "/message.txt";
|
|
std::string sigFile = tmpDir + "/signature.asc";
|
|
std::string pubkeyFile = tmpDir + "/pubkey.asc";
|
|
|
|
// Write message file
|
|
std::ofstream msgOut(messageFile);
|
|
if (!msgOut) {
|
|
LOG_ERROR << "Failed to create message file: " << messageFile;
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
return false;
|
|
}
|
|
msgOut << message;
|
|
msgOut.close();
|
|
|
|
// Write signature file
|
|
std::ofstream sigOut(sigFile);
|
|
if (!sigOut) {
|
|
LOG_ERROR << "Failed to create signature file: " << sigFile;
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
return false;
|
|
}
|
|
sigOut << signature;
|
|
sigOut.close();
|
|
|
|
// Write public key file
|
|
std::ofstream keyOut(pubkeyFile);
|
|
if (!keyOut) {
|
|
LOG_ERROR << "Failed to create public key file: " << pubkeyFile;
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
return false;
|
|
}
|
|
keyOut << publicKey;
|
|
keyOut.close();
|
|
|
|
// Initialize GPG (create trustdb if needed)
|
|
std::string initCmd = "GNUPGHOME=" + keyringDir + " gpg --batch --yes --list-keys 2>&1";
|
|
executeGpgCommand(initCmd); // This will create the trustdb if it doesn't exist
|
|
|
|
// Import the public key to the temporary keyring
|
|
// Use --trust-model always to avoid trust issues
|
|
std::string importCmd = "GNUPGHOME=" + keyringDir +
|
|
" gpg --batch --yes --trust-model always --import " + pubkeyFile + " 2>&1";
|
|
std::string importResult = executeGpgCommand(importCmd);
|
|
|
|
LOG_DEBUG << "GPG import result: " << importResult;
|
|
|
|
// Check if import was successful (be more lenient with the check)
|
|
bool importSuccess = (importResult.find("imported") != std::string::npos) ||
|
|
(importResult.find("unchanged") != std::string::npos) ||
|
|
(importResult.find("processed: 1") != std::string::npos) ||
|
|
(importResult.find("public key") != std::string::npos);
|
|
|
|
if (!importSuccess) {
|
|
LOG_ERROR << "Failed to import public key. Import output: " << importResult;
|
|
// Try to get more information about what went wrong
|
|
std::string debugCmd = "GNUPGHOME=" + keyringDir + " gpg --list-keys 2>&1";
|
|
std::string debugResult = executeGpgCommand(debugCmd);
|
|
LOG_ERROR << "GPG keyring state: " << debugResult;
|
|
|
|
// Cleanup
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Verify the signature
|
|
// Use --trust-model always to avoid trust issues
|
|
std::string verifyCmd = "GNUPGHOME=" + keyringDir +
|
|
" gpg --batch --yes --trust-model always --verify " +
|
|
sigFile + " " + messageFile + " 2>&1";
|
|
std::string verifyResult = executeGpgCommand(verifyCmd);
|
|
|
|
LOG_DEBUG << "GPG verify result: " << verifyResult;
|
|
|
|
// Check if verification succeeded (check both English and potential localized messages)
|
|
bool verified = (verifyResult.find("Good signature") != std::string::npos) ||
|
|
(verifyResult.find("gpg: Good signature") != std::string::npos) ||
|
|
(verifyResult.find("Signature made") != std::string::npos &&
|
|
verifyResult.find("BAD signature") == std::string::npos);
|
|
|
|
if (!verified) {
|
|
LOG_WARN << "Signature verification failed. Verify output: " << verifyResult;
|
|
} else {
|
|
LOG_INFO << "Signature verification successful for challenge";
|
|
}
|
|
|
|
// Cleanup temporary files
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
system(cleanupCmd.c_str());
|
|
|
|
return verified;
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception during signature verification: " << e.what();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
|
|
try {
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Create a structure to hold the state for recursive attempts
|
|
struct ColorGenerator : public std::enable_shared_from_this<ColorGenerator> {
|
|
std::mt19937 gen;
|
|
std::uniform_int_distribution<> dis;
|
|
std::function<void(const std::string&)> callback;
|
|
DbClientPtr dbClient;
|
|
int attempts;
|
|
|
|
ColorGenerator(std::function<void(const std::string&)> cb, DbClientPtr db)
|
|
: gen(std::random_device{}()),
|
|
dis(0, 0xFFFFFF),
|
|
callback(cb),
|
|
dbClient(db),
|
|
attempts(0) {}
|
|
|
|
void tryGenerate() {
|
|
auto self = shared_from_this();
|
|
|
|
// Limit attempts to prevent infinite recursion
|
|
if (++attempts > 100) {
|
|
LOG_ERROR << "Failed to generate unique color after 100 attempts";
|
|
callback("#561D5E"); // Fallback to default
|
|
return;
|
|
}
|
|
|
|
// Generate random color
|
|
int colorValue = dis(gen);
|
|
char colorHex[8];
|
|
snprintf(colorHex, sizeof(colorHex), "#%06X", colorValue);
|
|
std::string color(colorHex);
|
|
|
|
// Check if color exists
|
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 LIMIT 1"
|
|
<< color
|
|
>> [self, color](const Result& r) {
|
|
if (r.empty()) {
|
|
// Color is unique, use it
|
|
self->callback(color);
|
|
} else {
|
|
// Color exists, try again
|
|
self->tryGenerate();
|
|
}
|
|
}
|
|
>> [self](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error checking color: " << e.base().what();
|
|
// Fallback to a default color
|
|
self->callback("#561D5E");
|
|
};
|
|
}
|
|
};
|
|
|
|
auto generator = std::make_shared<ColorGenerator>(callback, dbClient);
|
|
generator->tryGenerate();
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in generateUniqueColor: " << e.what();
|
|
callback("#561D5E"); // Fallback to default
|
|
}
|
|
}
|
|
|
|
void AuthService::updateUserColor(int64_t userId, const std::string& newColor,
|
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
|
try {
|
|
// Validate color format
|
|
if (newColor.length() != 7 || newColor[0] != '#') {
|
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
|
return;
|
|
}
|
|
|
|
// Check if color is valid hex
|
|
for (size_t i = 1; i < 7; i++) {
|
|
if (!std::isxdigit(newColor[i])) {
|
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
|
return;
|
|
}
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Check if color is already taken
|
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 AND id != $2 LIMIT 1"
|
|
<< newColor << userId
|
|
>> [dbClient, userId, newColor, callback](const Result& r) {
|
|
if (!r.empty()) {
|
|
callback(false, "This color is already taken", "");
|
|
return;
|
|
}
|
|
|
|
// Update the color
|
|
*dbClient << "UPDATE users SET user_color = $1 WHERE id = $2 RETURNING user_color"
|
|
<< newColor << userId
|
|
>> [callback, newColor](const Result& r2) {
|
|
if (!r2.empty()) {
|
|
callback(true, "", newColor);
|
|
} else {
|
|
callback(false, "Failed to update color", "");
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update color: " << e.base().what();
|
|
callback(false, "Database error", "");
|
|
};
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(false, "Database error", "");
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updateUserColor: " << e.what();
|
|
callback(false, "Internal server error", "");
|
|
}
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
LOG_DEBUG << "Starting user registration for: " << username;
|
|
|
|
// 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;
|
|
}
|
|
|
|
LOG_DEBUG << "Validation passed, generating unique color";
|
|
|
|
// Generate unique color first
|
|
generateUniqueColor([this, username, password, publicKey, fingerprint, callback](const std::string& color) {
|
|
try {
|
|
LOG_DEBUG << "Got unique color: " << color << ", checking username availability";
|
|
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, "Database connection error", 0);
|
|
return;
|
|
}
|
|
|
|
// Check if username exists
|
|
*dbClient << "SELECT id FROM users WHERE username = $1 LIMIT 1"
|
|
<< username
|
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r) {
|
|
try {
|
|
if (!r.empty()) {
|
|
LOG_WARN << "Username already exists: " << username;
|
|
callback(false, "Username already exists", 0);
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG << "Username available, checking fingerprint";
|
|
|
|
// Check if fingerprint exists
|
|
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1 LIMIT 1"
|
|
<< fingerprint
|
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r2) {
|
|
try {
|
|
if (!r2.empty()) {
|
|
LOG_WARN << "Fingerprint already exists";
|
|
callback(false, "This PGP key is already registered", 0);
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG << "Fingerprint available, hashing password";
|
|
|
|
// Hash password
|
|
std::string hash;
|
|
try {
|
|
hash = BCrypt::generateHash(password);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to hash password: " << e.what();
|
|
callback(false, "Failed to process password", 0);
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG << "Password hashed, inserting user";
|
|
|
|
// Insert user first (without transaction for simplicity)
|
|
*dbClient << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only, user_color) "
|
|
"VALUES ($1, $2, false, false, false, $3) RETURNING id"
|
|
<< username << hash << color
|
|
>> [dbClient, publicKey, fingerprint, callback, username](const Result& r3) {
|
|
try {
|
|
if (r3.empty()) {
|
|
LOG_ERROR << "Failed to insert user";
|
|
callback(false, "Failed to create user", 0);
|
|
return;
|
|
}
|
|
|
|
int64_t userId = r3[0]["id"].as<int64_t>();
|
|
LOG_INFO << "User created with ID: " << userId;
|
|
|
|
// Insert PGP key
|
|
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
|
<< userId << publicKey << fingerprint
|
|
>> [callback, userId, username](const Result&) {
|
|
LOG_INFO << "User registration complete for: " << username << " (ID: " << userId << ")";
|
|
callback(true, "", userId);
|
|
}
|
|
>> [dbClient, userId, callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
|
|
// Try to clean up the user
|
|
*dbClient << "DELETE FROM users WHERE id = $1" << userId >> [](const Result&) {} >> [](const DrogonDbException&) {};
|
|
callback(false, "Failed to save PGP key", 0);
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception processing user insert result: " << e.what();
|
|
callback(false, "Registration failed", 0);
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to insert user: " << e.base().what();
|
|
callback(false, "Registration failed", 0);
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in fingerprint check callback: " << e.what();
|
|
callback(false, "Registration failed", 0);
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error checking fingerprint: " << e.base().what();
|
|
callback(false, "Database error", 0);
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in username check callback: " << e.what();
|
|
callback(false, "Registration failed", 0);
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error checking username: " << e.base().what();
|
|
callback(false, "Database error", 0);
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in color generation callback: " << e.what();
|
|
callback(false, "Registration failed", 0);
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in registerUser: " << e.what();
|
|
callback(false, "Registration failed", 0);
|
|
}
|
|
}
|
|
|
|
void AuthService::loginUser(const std::string& username, const std::string& password,
|
|
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
|
try {
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, "", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
|
"FROM users WHERE username = $1 LIMIT 1"
|
|
<< username
|
|
>> [password, callback, this](const Result& r) {
|
|
try {
|
|
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>();
|
|
|
|
bool valid = false;
|
|
try {
|
|
valid = BCrypt::validatePassword(password, hash);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
if (!valid) {
|
|
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>();
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
std::string token = generateToken(user);
|
|
callback(true, token, user);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in login callback: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(false, "", UserInfo{});
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in loginUser: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
}
|
|
}
|
|
|
|
void AuthService::initiatePgpLogin(const std::string& username,
|
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
|
try {
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, "", "");
|
|
return;
|
|
}
|
|
|
|
// 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, "", "");
|
|
};
|
|
}
|
|
);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in initiatePgpLogin: " << e.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) {
|
|
try {
|
|
// Get stored challenge from Redis
|
|
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
|
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
|
try {
|
|
if (storedChallenge.empty() || storedChallenge != challenge) {
|
|
LOG_WARN << "Challenge mismatch for user: " << username;
|
|
callback(false, "", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
// Delete challenge after use
|
|
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
|
|
|
// Get user's public key and verify signature
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, "", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_streamer, "
|
|
"u.is_pgp_only, u.bio, u.avatar_url, u.pgp_only_enabled_at, u.user_color "
|
|
"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, signature, challenge, this](const Result& r) {
|
|
try {
|
|
if (r.empty()) {
|
|
LOG_WARN << "No PGP key found for user";
|
|
callback(false, "", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
std::string publicKey = r[0]["public_key"].as<std::string>();
|
|
|
|
// CRITICAL: Server-side signature verification
|
|
bool signatureValid = verifyPgpSignature(challenge, signature, publicKey);
|
|
|
|
if (!signatureValid) {
|
|
LOG_WARN << "Invalid PGP signature for user";
|
|
callback(false, "Invalid signature", UserInfo{});
|
|
return;
|
|
}
|
|
|
|
LOG_INFO << "PGP signature verified successfully for user";
|
|
|
|
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>();
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
std::string token = generateToken(user);
|
|
callback(true, token, user);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(false, "", UserInfo{});
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in Redis callback: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
}
|
|
}
|
|
);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in verifyPgpLogin: " << e.what();
|
|
callback(false, "", UserInfo{});
|
|
}
|
|
}
|
|
|
|
std::string AuthService::generateToken(const UserInfo& user) {
|
|
try {
|
|
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)))
|
|
.set_payload_claim("color_code", jwt::claim(
|
|
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
|
)) // Ensure color is never empty
|
|
.sign(jwt::algorithm::hs256{jwtSecret_});
|
|
|
|
return token;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to generate token: " << e.what();
|
|
return "";
|
|
}
|
|
}
|
|
|
|
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|
try {
|
|
if (jwtSecret_.empty()) {
|
|
const char* envSecret = std::getenv("JWT_SECRET");
|
|
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
|
}
|
|
|
|
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;
|
|
|
|
// Get color from token if available, otherwise will need to fetch from DB
|
|
if (decoded.has_payload_claim("color_code")) {
|
|
userInfo.colorCode = decoded.get_payload_claim("color_code").as_string();
|
|
} else {
|
|
// For older tokens without color, default value
|
|
userInfo.colorCode = "#561D5E";
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
// Validate new password
|
|
std::string error;
|
|
if (!validatePassword(newPassword, error)) {
|
|
callback(false, error);
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, "Database connection error");
|
|
return;
|
|
}
|
|
|
|
// Verify old password
|
|
*dbClient << "SELECT password_hash FROM users WHERE id = $1 LIMIT 1"
|
|
<< userId
|
|
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
|
try {
|
|
if (r.empty()) {
|
|
callback(false, "User not found");
|
|
return;
|
|
}
|
|
|
|
std::string hash = r[0]["password_hash"].as<std::string>();
|
|
|
|
bool valid = false;
|
|
try {
|
|
valid = BCrypt::validatePassword(oldPassword, hash);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
|
callback(false, "Password validation error");
|
|
return;
|
|
}
|
|
|
|
if (!valid) {
|
|
callback(false, "Incorrect password");
|
|
return;
|
|
}
|
|
|
|
// Update password
|
|
std::string newHash;
|
|
try {
|
|
newHash = BCrypt::generateHash(newPassword);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to hash new password: " << e.what();
|
|
callback(false, "Failed to process new password");
|
|
return;
|
|
}
|
|
|
|
*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");
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in password update callback: " << e.what();
|
|
callback(false, "Failed to update password");
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(false, "Database error");
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updatePassword: " << e.what();
|
|
callback(false, "Failed to update password");
|
|
}
|
|
}
|
|
|
|
void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback) {
|
|
try {
|
|
auto dbClient = app().getDbClient();
|
|
if (!dbClient) {
|
|
LOG_ERROR << "Database client is null";
|
|
callback(false, UserInfo{});
|
|
return;
|
|
}
|
|
|
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
|
"FROM users WHERE id = $1 LIMIT 1"
|
|
<< userId
|
|
>> [callback](const Result& r) {
|
|
try {
|
|
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>();
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
callback(true, user);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
|
callback(false, UserInfo{});
|
|
}
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
callback(false, UserInfo{});
|
|
};
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
|
|
callback(false, UserInfo{});
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\AuthService.h ###
|
|
|
|
#pragma once
|
|
#include <string>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <jwt-cpp/jwt.h>
|
|
#include <bcrypt/BCrypt.hpp>
|
|
|
|
struct UserInfo {
|
|
int64_t id = 0;
|
|
std::string username;
|
|
bool isAdmin = false;
|
|
bool isStreamer = false;
|
|
bool isPgpOnly = false;
|
|
std::string bio;
|
|
std::string avatarUrl;
|
|
std::string pgpOnlyEnabledAt;
|
|
std::string colorCode;
|
|
};
|
|
|
|
class AuthService {
|
|
public:
|
|
static AuthService& getInstance() {
|
|
static AuthService instance;
|
|
return instance;
|
|
}
|
|
|
|
void 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);
|
|
|
|
void loginUser(const std::string& username, const std::string& password,
|
|
std::function<void(bool, const std::string&, const UserInfo&)> callback);
|
|
|
|
void initiatePgpLogin(const std::string& username,
|
|
std::function<void(bool, const std::string&, const std::string&)> callback);
|
|
|
|
void verifyPgpLogin(const std::string& username, const std::string& signature,
|
|
const std::string& challenge,
|
|
std::function<void(bool, const std::string&, const UserInfo&)> callback);
|
|
|
|
std::string generateToken(const UserInfo& user);
|
|
bool validateToken(const std::string& token, UserInfo& userInfo);
|
|
|
|
// New method to fetch complete user info including color
|
|
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
|
|
|
void updatePassword(int64_t userId, const std::string& oldPassword,
|
|
const std::string& newPassword,
|
|
std::function<void(bool, const std::string&)> callback);
|
|
|
|
void updateUserColor(int64_t userId, const std::string& newColor,
|
|
std::function<void(bool, const std::string&, const std::string&)> callback);
|
|
|
|
void generateUniqueColor(std::function<void(const std::string& color)> callback);
|
|
|
|
private:
|
|
AuthService() = default;
|
|
std::string jwtSecret_;
|
|
|
|
bool validatePassword(const std::string& password, std::string& error);
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\CorsMiddleware.h ###
|
|
|
|
#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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\DatabaseService.cpp ###
|
|
|
|
#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);
|
|
};
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\DatabaseService.h ###
|
|
|
|
#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;
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\OmeClient.h ###
|
|
|
|
#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
|
|
// In backend/src/services/OmeClient.h
|
|
|
|
void getActiveStreams(std::function<void(bool, const Json::Value&)> callback) {
|
|
// Try the streams endpoint first
|
|
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();
|
|
LOG_DEBUG << "OME streams response: " << json.toStyledString();
|
|
|
|
// OME might return the streams in different formats
|
|
// Sometimes it's {"response": ["stream1", "stream2"]}
|
|
// Sometimes it's {"response": {"streams": ["stream1", "stream2"]}}
|
|
if (json.isMember("response")) {
|
|
callback(true, json);
|
|
} else {
|
|
// Wrap the response if needed
|
|
Json::Value wrapped;
|
|
wrapped["response"] = json;
|
|
callback(true, wrapped);
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to parse OME response: " << e.what();
|
|
Json::Value empty;
|
|
empty["response"] = Json::arrayValue;
|
|
callback(false, empty);
|
|
}
|
|
} else {
|
|
LOG_ERROR << "Failed to get active streams from OME";
|
|
Json::Value empty;
|
|
empty["response"] = Json::arrayValue;
|
|
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;
|
|
}
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\RedisHelper.cpp ###
|
|
|
|
#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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\RedisHelper.h ###
|
|
|
|
#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);
|
|
}
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\StatsService.cpp ###
|
|
|
|
#include "StatsService.h"
|
|
#include "../controllers/StreamController.h"
|
|
#include "../services/RedisHelper.h"
|
|
#include "../services/OmeClient.h"
|
|
#include <drogon/HttpClient.h>
|
|
#include <drogon/utils/Utilities.h>
|
|
#include <set>
|
|
|
|
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;
|
|
}
|
|
|
|
void StatsService::startPolling() {
|
|
if (!running_) {
|
|
LOG_WARN << "Stats service not initialized, cannot start polling";
|
|
return;
|
|
}
|
|
|
|
LOG_INFO << "Starting stats polling timer...";
|
|
|
|
if (auto loop = drogon::app().getLoop()) {
|
|
try {
|
|
// Do an immediate poll
|
|
pollOmeStats();
|
|
|
|
// Then set up the timer
|
|
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();
|
|
}
|
|
} else {
|
|
LOG_ERROR << "Event loop not available for stats polling";
|
|
}
|
|
}
|
|
|
|
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() {
|
|
LOG_INFO << "Polling OvenMediaEngine for active streams...";
|
|
|
|
// Get active streams from OME
|
|
OmeClient::getInstance().getActiveStreams([this](bool success, const Json::Value& json) {
|
|
if (success && json.isMember("response")) {
|
|
LOG_INFO << "OME Active Streams Response: " << json["response"].toStyledString();
|
|
|
|
std::set<std::string> activeStreamKeys;
|
|
|
|
// Handle both array and object responses from OME
|
|
if (json["response"].isArray()) {
|
|
for (const auto& stream : json["response"]) {
|
|
if (stream.isString()) {
|
|
activeStreamKeys.insert(stream.asString());
|
|
}
|
|
}
|
|
} else if (json["response"].isMember("streams") && json["response"]["streams"].isArray()) {
|
|
for (const auto& stream : json["response"]["streams"]) {
|
|
if (stream.isString()) {
|
|
activeStreamKeys.insert(stream.asString());
|
|
} else if (stream.isMember("name")) {
|
|
activeStreamKeys.insert(stream["name"].asString());
|
|
}
|
|
}
|
|
}
|
|
|
|
LOG_INFO << "Found " << activeStreamKeys.size() << " active streams from OME";
|
|
|
|
// Update each active stream
|
|
for (const auto& streamKey : activeStreamKeys) {
|
|
LOG_INFO << "Processing active stream: " << streamKey;
|
|
|
|
// IMMEDIATELY update database to mark as live
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, "
|
|
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1"
|
|
<< streamKey
|
|
>> [streamKey](const orm::Result&) {
|
|
LOG_INFO << "Successfully marked realm as live: " << streamKey;
|
|
}
|
|
>> [streamKey](const orm::DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update realm live status: " << e.base().what();
|
|
};
|
|
|
|
// Then update detailed stats
|
|
updateStreamStats(streamKey);
|
|
}
|
|
|
|
// Mark all non-active streams as offline
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT stream_key FROM realms WHERE is_live = true"
|
|
>> [activeStreamKeys](const orm::Result& r) {
|
|
auto db = app().getDbClient();
|
|
for (const auto& row : r) {
|
|
std::string key = row["stream_key"].as<std::string>();
|
|
if (activeStreamKeys.find(key) == activeStreamKeys.end()) {
|
|
LOG_INFO << "Marking realm as offline: " << key;
|
|
*db << "UPDATE realms SET is_live = false, viewer_count = 0, "
|
|
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1"
|
|
<< key
|
|
>> [key](const orm::Result&) {
|
|
LOG_INFO << "Marked realm as offline: " << key;
|
|
}
|
|
>> [](const orm::DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to mark realm offline: " << e.base().what();
|
|
};
|
|
}
|
|
}
|
|
}
|
|
>> [](const orm::DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to query live realms: " << e.base().what();
|
|
};
|
|
} else {
|
|
LOG_ERROR << "Failed to get active streams from OME or empty response";
|
|
}
|
|
});
|
|
}
|
|
|
|
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 or is live
|
|
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();
|
|
|
|
// Cast to int32 to match PostgreSQL integer type
|
|
int32_t viewerCount = static_cast<int32_t>(stats.uniqueViewers);
|
|
|
|
// Update realm's live status and viewer count
|
|
*dbClient << "UPDATE realms SET is_live = $1, viewer_count = $2, updated_at = CURRENT_TIMESTAMP WHERE stream_key = $3"
|
|
<< stats.isLive << viewerCount << streamKey
|
|
>> [streamKey, stats](const orm::Result&) {
|
|
LOG_INFO << "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;
|
|
|
|
// First, try to get the stream stats
|
|
OmeClient::getInstance().getStreamStats(streamKey, [this, callback, streamKey](bool success, const Json::Value& json) {
|
|
StreamStats stats;
|
|
bool streamExists = false;
|
|
|
|
if (success && json.isMember("response") && !json["response"].isNull()) {
|
|
try {
|
|
const auto& data = json["response"];
|
|
streamExists = true;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Check multiple indicators for live status
|
|
bool hasInput = false;
|
|
|
|
// Check for input field
|
|
if (data.isMember("input") && !data["input"].isNull()) {
|
|
hasInput = true;
|
|
const auto& input = data["input"];
|
|
|
|
// Get bitrate from input tracks
|
|
if (input.isMember("tracks") && input["tracks"].isArray()) {
|
|
for (const auto& track : input["tracks"]) {
|
|
if (track["type"].asString() == "video" && track.isMember("bitrate")) {
|
|
stats.bitrate = track["bitrate"].asDouble();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Alternative: Check lastThroughputIn
|
|
if (!hasInput && data.isMember("lastThroughputIn")) {
|
|
double throughput = data["lastThroughputIn"].asDouble();
|
|
if (throughput > 0) {
|
|
hasInput = true;
|
|
stats.bitrate = throughput;
|
|
}
|
|
}
|
|
|
|
// Alternative: Check avgThroughputIn
|
|
if (!hasInput && data.isMember("avgThroughputIn")) {
|
|
double avgThroughput = data["avgThroughputIn"].asDouble();
|
|
if (avgThroughput > 0) {
|
|
hasInput = true;
|
|
stats.bitrate = avgThroughput;
|
|
}
|
|
}
|
|
|
|
// Check bytes counters
|
|
if (data.isMember("totalBytesIn")) {
|
|
stats.totalBytesIn = data["totalBytesIn"].asInt64();
|
|
if (stats.totalBytesIn > 0) {
|
|
hasInput = true;
|
|
}
|
|
}
|
|
|
|
if (data.isMember("totalBytesOut")) {
|
|
stats.totalBytesOut = data["totalBytesOut"].asInt64();
|
|
}
|
|
|
|
// Stream is live if it has input or active bitrate
|
|
stats.isLive = hasInput || stats.bitrate > 0;
|
|
|
|
LOG_DEBUG << "Stream " << streamKey
|
|
<< " - hasInput: " << hasInput
|
|
<< ", bitrate: " << stats.bitrate
|
|
<< ", totalBytesIn: " << stats.totalBytesIn
|
|
<< ", isLive: " << stats.isLive;
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to parse stats: " << e.what();
|
|
stats.isLive = false;
|
|
}
|
|
} else {
|
|
// Stream doesn't exist in OME
|
|
stats.isLive = false;
|
|
LOG_DEBUG << "Stream " << streamKey << " not found in OME";
|
|
}
|
|
|
|
stats.lastUpdated = std::chrono::system_clock::now();
|
|
|
|
// If stream exists, try to get detailed stream info
|
|
if (streamExists) {
|
|
OmeClient::getInstance().getStreamInfo(streamKey, [callback, stats](bool infoSuccess, const Json::Value& infoJson) mutable {
|
|
// Parse stream metadata if available
|
|
if (infoSuccess && infoJson.isMember("response")) {
|
|
try {
|
|
const auto& response = infoJson["response"];
|
|
|
|
LOG_DEBUG << "Stream info response: " << response.toStyledString();
|
|
|
|
// Check if stream has input (another way to verify it's live)
|
|
if (response.isMember("input") && !response["input"].isNull()) {
|
|
stats.isLive = true;
|
|
|
|
// Try to get codec from input tracks first
|
|
if (response["input"].isMember("tracks") && response["input"]["tracks"].isArray()) {
|
|
for (const auto& track : response["input"]["tracks"]) {
|
|
if (track["type"].asString() == "video") {
|
|
if (track.isMember("codec")) {
|
|
std::string codec = track["codec"].asString();
|
|
// Clean up codec string
|
|
if (codec == "H264" || codec == "h264") {
|
|
stats.codec = "H.264";
|
|
} else if (codec == "H265" || codec == "h265") {
|
|
stats.codec = "H.265";
|
|
} else if (codec == "VP8" || codec == "vp8") {
|
|
stats.codec = "VP8";
|
|
} else if (codec == "VP9" || codec == "vp9") {
|
|
stats.codec = "VP9";
|
|
} else {
|
|
stats.codec = codec;
|
|
}
|
|
}
|
|
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();
|
|
} else if (track.isMember("frameRate")) {
|
|
stats.fps = track["frameRate"].asDouble();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no codec found in input, try output tracks
|
|
if (stats.codec.empty() || stats.codec == "N/A") {
|
|
if (response.isMember("tracks") && response["tracks"].isArray()) {
|
|
for (const auto& track : response["tracks"]) {
|
|
if (track["type"].asString() == "video") {
|
|
if (track.isMember("codec")) {
|
|
std::string codec = track["codec"].asString();
|
|
if (codec == "H264" || codec == "h264") {
|
|
stats.codec = "H.264";
|
|
} else if (codec == "H265" || codec == "h265") {
|
|
stats.codec = "H.265";
|
|
} else if (codec == "VP8" || codec == "vp8") {
|
|
stats.codec = "VP8";
|
|
} else if (codec == "VP9" || codec == "vp9") {
|
|
stats.codec = "VP9";
|
|
} else {
|
|
stats.codec = codec;
|
|
}
|
|
}
|
|
if (stats.resolution == "N/A" && track.isMember("width") && track.isMember("height")) {
|
|
stats.resolution = std::to_string(track["width"].asInt()) + "x" +
|
|
std::to_string(track["height"].asInt());
|
|
}
|
|
if (stats.fps == 0 && track.isMember("framerate")) {
|
|
stats.fps = track["framerate"].asDouble();
|
|
} else if (stats.fps == 0 && track.isMember("frameRate")) {
|
|
stats.fps = track["frameRate"].asDouble();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set defaults if still empty
|
|
if (stats.codec.empty()) {
|
|
stats.codec = "Unknown";
|
|
}
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to parse stream info: " << e.what();
|
|
}
|
|
}
|
|
|
|
callback(true, stats);
|
|
});
|
|
} else {
|
|
// Stream doesn't exist, return offline stats
|
|
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;
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\backend\src\services\StatsService.h ###
|
|
|
|
#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 startPolling(); // NEW: Separate method to start polling
|
|
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
|
|
};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\database\init.sql ###
|
|
|
|
-- 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),
|
|
user_color VARCHAR(7) UNIQUE NOT NULL, -- Unique hex color for each user
|
|
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_users_user_color ON users(user_color);
|
|
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();
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\.gitignore ###
|
|
|
|
.DS_Store
|
|
node_modules
|
|
/build
|
|
/.svelte-kit
|
|
/package
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
vite.config.js.timestamp-*
|
|
vite.config.ts.timestamp-*
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\Dockerfile ###
|
|
|
|
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"]
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\package.json ###
|
|
|
|
{
|
|
"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"
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\svelte.config.js ###
|
|
|
|
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
|
|
}),
|
|
|
|
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'", 'data:', 'https://cdnjs.cloudflare.com'], // Added 'data:' for embedded fonts
|
|
'connect-src': [
|
|
"'self'",
|
|
'ws://localhost:*', // Changed to include port wildcard
|
|
'wss://localhost:*', // Changed to include port wildcard
|
|
'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;
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\tsconfig.json ###
|
|
|
|
{
|
|
"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/*"]
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\vite.config.ts ###
|
|
|
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
import { defineConfig } from 'vite';
|
|
|
|
export default defineConfig({
|
|
plugins: [sveltekit()],
|
|
server: {
|
|
port: 3000,
|
|
host: true
|
|
}
|
|
});
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\app.css ###
|
|
|
|
* {
|
|
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;
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\app.d.ts ###
|
|
|
|
// 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 {};
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\app.html ###
|
|
|
|
<!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>
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\lib\api.js ###
|
|
|
|
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}`);
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\lib\pgp.js ###
|
|
|
|
// Client-side PGP utilities with encrypted storage
|
|
|
|
const DB_NAME = 'pgp_storage';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'encrypted_keys';
|
|
|
|
// Initialize IndexedDB
|
|
async function initDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Derive key from passphrase using PBKDF2
|
|
async function deriveKey(passphrase, salt) {
|
|
const enc = new TextEncoder();
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
enc.encode(passphrase),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: salt,
|
|
iterations: 100000,
|
|
hash: 'SHA-256'
|
|
},
|
|
keyMaterial,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
true,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
// Validate passphrase strength
|
|
export function validatePassphrase(passphrase) {
|
|
if (!passphrase || passphrase.length < 12) {
|
|
return 'Passphrase must be at least 12 characters';
|
|
}
|
|
|
|
// Check for complexity
|
|
const hasUpper = /[A-Z]/.test(passphrase);
|
|
const hasLower = /[a-z]/.test(passphrase);
|
|
const hasNumber = /[0-9]/.test(passphrase);
|
|
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(passphrase);
|
|
|
|
const complexity = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
|
if (complexity < 3) {
|
|
return 'Passphrase must contain at least 3 of: uppercase, lowercase, numbers, special characters';
|
|
}
|
|
|
|
return null; // Valid
|
|
}
|
|
|
|
export async function generateKeyPair(username, passphrase) {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('PGP operations can only be performed in the browser');
|
|
}
|
|
|
|
// Validate passphrase
|
|
const passphraseError = validatePassphrase(passphrase);
|
|
if (passphraseError) {
|
|
throw new Error(passphraseError);
|
|
}
|
|
|
|
const { generateKey, readKey } = await import('openpgp');
|
|
|
|
const { privateKey, publicKey } = await generateKey({
|
|
type: 'rsa',
|
|
rsaBits: 2048,
|
|
userIDs: [{ name: username }],
|
|
passphrase // Always encrypt with 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;
|
|
}
|
|
}
|
|
|
|
// Save encrypted private key to IndexedDB
|
|
export async function saveEncryptedPrivateKey(passphrase, armoredKey) {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('Storage operations can only be performed in the browser');
|
|
}
|
|
|
|
// Validate passphrase
|
|
const passphraseError = validatePassphrase(passphrase);
|
|
if (passphraseError) {
|
|
throw new Error(passphraseError);
|
|
}
|
|
|
|
try {
|
|
// Generate random salt and IV
|
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
// Derive encryption key
|
|
const key = await deriveKey(passphrase, salt);
|
|
|
|
// Encrypt the private key
|
|
const enc = new TextEncoder();
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{
|
|
name: 'AES-GCM',
|
|
iv: iv
|
|
},
|
|
key,
|
|
enc.encode(armoredKey)
|
|
);
|
|
|
|
// Store in IndexedDB
|
|
const db = await initDB();
|
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
await store.put({
|
|
id: 'primary_key',
|
|
salt: Array.from(salt),
|
|
iv: Array.from(iv),
|
|
encrypted: Array.from(new Uint8Array(encrypted)),
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to save encrypted key:', error);
|
|
throw new Error('Failed to save encrypted key');
|
|
}
|
|
}
|
|
|
|
// Unlock and retrieve private key from IndexedDB
|
|
export async function unlockPrivateKey(passphrase) {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('Storage operations can only be performed in the browser');
|
|
}
|
|
|
|
if (!passphrase) {
|
|
throw new Error('Passphrase required');
|
|
}
|
|
|
|
try {
|
|
const db = await initDB();
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
const data = await new Promise((resolve, reject) => {
|
|
const request = store.get('primary_key');
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
if (!data) {
|
|
throw new Error('No encrypted key found');
|
|
}
|
|
|
|
// Reconstruct typed arrays
|
|
const salt = new Uint8Array(data.salt);
|
|
const iv = new Uint8Array(data.iv);
|
|
const encrypted = new Uint8Array(data.encrypted);
|
|
|
|
// Derive decryption key
|
|
const key = await deriveKey(passphrase, salt);
|
|
|
|
// Decrypt
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{
|
|
name: 'AES-GCM',
|
|
iv: iv
|
|
},
|
|
key,
|
|
encrypted
|
|
);
|
|
|
|
const dec = new TextDecoder();
|
|
return dec.decode(decrypted);
|
|
} catch (error) {
|
|
console.error('Failed to unlock private key:', error);
|
|
throw new Error('Failed to unlock private key. Check your passphrase.');
|
|
}
|
|
}
|
|
|
|
// Check if an encrypted key exists
|
|
export async function hasEncryptedKey() {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
try {
|
|
const db = await initDB();
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
const data = await new Promise((resolve) => {
|
|
const request = store.get('primary_key');
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => resolve(null);
|
|
});
|
|
|
|
return !!data;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Remove encrypted key from IndexedDB
|
|
export async function removeEncryptedPrivateKey() {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
try {
|
|
const db = await initDB();
|
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
await store.delete('primary_key');
|
|
} catch (error) {
|
|
console.error('Failed to remove encrypted key:', error);
|
|
}
|
|
}
|
|
|
|
export async function signMessage(message, privateKeyArmored, passphrase) {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('PGP operations can only be performed in the browser');
|
|
}
|
|
|
|
if (!passphrase) {
|
|
throw new Error('Passphrase required to unlock private key');
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// DEPRECATED - DO NOT USE
|
|
export function storePrivateKey() {
|
|
throw new Error('Plaintext key storage is disabled for security. Use saveEncryptedPrivateKey() instead.');
|
|
}
|
|
|
|
export function getStoredPrivateKey() {
|
|
throw new Error('Plaintext key storage is disabled for security. Use unlockPrivateKey() instead.');
|
|
}
|
|
|
|
export function removeStoredPrivateKey() {
|
|
throw new Error('Plaintext key storage is disabled for security. Use removeEncryptedPrivateKey() instead.');
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\lib\websocket.js ###
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\lib\stores\auth.js ###
|
|
|
|
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,
|
|
loading: true
|
|
});
|
|
|
|
return {
|
|
subscribe,
|
|
|
|
async init() {
|
|
if (!browser) return;
|
|
|
|
// Use cookie-based auth - no localStorage tokens
|
|
try {
|
|
const response = await fetch('/api/user/me', {
|
|
credentials: 'include' // Send cookies
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
set({ user: data.user, loading: false });
|
|
} else {
|
|
set({ user: null, loading: false });
|
|
}
|
|
} catch (error) {
|
|
console.error('Auth init error:', error);
|
|
set({ user: null, loading: false });
|
|
}
|
|
},
|
|
|
|
async login(credentials) {
|
|
const response = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include', // Receive httpOnly cookie
|
|
body: JSON.stringify(credentials)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// Server sets httpOnly cookie, we just store user data
|
|
set({ user: data.user, 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' },
|
|
credentials: 'include', // Receive httpOnly cookie
|
|
body: JSON.stringify({ username, signature, challenge })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// Server sets httpOnly cookie, we just store user data
|
|
set({ user: data.user, 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' },
|
|
credentials: 'include',
|
|
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' };
|
|
},
|
|
|
|
async updateColor(color) {
|
|
const response = await fetch('/api/user/color', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include', // Use cookies for auth
|
|
body: JSON.stringify({ color })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// Update the store with new user data
|
|
update(state => ({
|
|
...state,
|
|
user: {
|
|
...state.user,
|
|
userColor: data.color,
|
|
colorCode: data.color
|
|
}
|
|
}));
|
|
|
|
return { success: true, color: data.color };
|
|
}
|
|
|
|
return { success: false, error: data.error || 'Failed to update color' };
|
|
},
|
|
|
|
updateUser(userData) {
|
|
update(state => ({
|
|
...state,
|
|
user: userData
|
|
}));
|
|
},
|
|
|
|
async logout() {
|
|
// Call logout endpoint to clear httpOnly cookie
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
credentials: 'include'
|
|
});
|
|
|
|
set({ user: 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
|
|
);
|
|
|
|
export const userColor = derived(
|
|
auth,
|
|
$auth => $auth.user?.colorCode || '#561D5E'
|
|
);
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\lib\stores\user.js ###
|
|
|
|
import { writable, derived } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
|
|
function createUserStore() {
|
|
// Initialize from localStorage if in browser
|
|
const initialUser = browser ? JSON.parse(localStorage.getItem('user') || 'null') : null;
|
|
|
|
const { subscribe, set, update } = writable(initialUser);
|
|
|
|
return {
|
|
subscribe,
|
|
set: (user) => {
|
|
if (browser && user) {
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
} else if (browser) {
|
|
localStorage.removeItem('user');
|
|
}
|
|
set(user);
|
|
},
|
|
update: (fn) => {
|
|
update(currentUser => {
|
|
const newUser = fn(currentUser);
|
|
if (browser && newUser) {
|
|
localStorage.setItem('user', JSON.stringify(newUser));
|
|
}
|
|
return newUser;
|
|
});
|
|
},
|
|
updateColor: async (newColor) => {
|
|
const token = browser ? localStorage.getItem('token') : null;
|
|
if (!token) return false;
|
|
|
|
try {
|
|
const response = await fetch('/api/user/color', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ color: newColor })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Update the store with new user data
|
|
if (data.user) {
|
|
// Full user data returned
|
|
set(data.user);
|
|
} else {
|
|
// Only color returned, update existing user
|
|
update(u => u ? { ...u, userColor: data.color } : null);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Failed to update color:', error);
|
|
return false;
|
|
}
|
|
},
|
|
refresh: async () => {
|
|
const token = browser ? localStorage.getItem('token') : null;
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/user/me', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.user) {
|
|
set(data.user);
|
|
return data.user;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to refresh user:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
export const userStore = createUserStore();
|
|
|
|
// Derived store for just the color
|
|
export const userColor = derived(
|
|
userStore,
|
|
$user => $user?.userColor || '#561D5E'
|
|
);
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\+layout.svelte ###
|
|
|
|
<script>
|
|
import { onMount } from 'svelte';
|
|
import { auth, isAuthenticated, isAdmin, isStreamer, userColor } 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: all 0.2s;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.user-avatar-btn.has-color {
|
|
background: var(--user-color);
|
|
}
|
|
|
|
.user-avatar-btn.has-color.with-image {
|
|
border-color: var(--user-color);
|
|
border-width: 3px;
|
|
}
|
|
|
|
.user-avatar-btn:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.user-avatar-btn.has-color:hover {
|
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.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: flex;
|
|
align-items: center;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.dropdown-username:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.user-color-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
margin-right: 0.5rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.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"
|
|
class:has-color={$userColor}
|
|
class:with-image={$auth.user.avatarUrl}
|
|
style="--user-color: {$userColor}"
|
|
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">
|
|
<span
|
|
class="user-color-dot"
|
|
style="background: {$userColor}"
|
|
></span>
|
|
{$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 />
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\+page.svelte ###
|
|
|
|
<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>
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\admin\+page.svelte ###
|
|
|
|
<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>
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\login\+page.svelte ###
|
|
|
|
<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 keyPassphrase = '';
|
|
let confirmKeyPassphrase = '';
|
|
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 = '';
|
|
let saveKeyLocally = false;
|
|
|
|
// Show PGP command example
|
|
let showPgpExample = false;
|
|
|
|
// For unlocking stored key
|
|
let storedKeyPassphrase = '';
|
|
|
|
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;
|
|
}
|
|
|
|
if (keyPassphrase !== confirmKeyPassphrase) {
|
|
error = 'Key passphrases do not match';
|
|
return;
|
|
}
|
|
|
|
const passphraseError = pgp.validatePassphrase(keyPassphrase);
|
|
if (passphraseError) {
|
|
error = passphraseError;
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
|
|
try {
|
|
// Generate PGP key pair with passphrase
|
|
const keyPair = await pgp.generateKeyPair(username, keyPassphrase);
|
|
|
|
const result = await auth.register({
|
|
username,
|
|
password,
|
|
publicKey: keyPair.publicKey,
|
|
fingerprint: keyPair.fingerprint
|
|
});
|
|
|
|
if (result.success) {
|
|
// Save keys for display
|
|
generatedPrivateKey = keyPair.privateKey;
|
|
generatedPublicKey = keyPair.publicKey;
|
|
showGeneratedKeys = true;
|
|
} else {
|
|
error = result.error;
|
|
}
|
|
} catch (e) {
|
|
error = e.message || 'Failed to generate PGP keys';
|
|
console.error(e);
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function proceedAfterKeys() {
|
|
loading = true;
|
|
|
|
try {
|
|
// If user wants to save locally, store encrypted
|
|
if (saveKeyLocally) {
|
|
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
|
|
}
|
|
|
|
// Auto-login after registration
|
|
const result = await auth.login({ username, password });
|
|
if (result.success) {
|
|
goto('/');
|
|
} else {
|
|
showGeneratedKeys = false;
|
|
mode = 'login';
|
|
error = 'Registration successful. Please login.';
|
|
}
|
|
} catch (e) {
|
|
error = 'Failed to save keys: ' + e.message;
|
|
}
|
|
|
|
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 = '';
|
|
|
|
// Check if we have a stored key
|
|
const hasKey = await pgp.hasEncryptedKey();
|
|
if (hasKey) {
|
|
// We'll need to unlock it
|
|
storedKeyPassphrase = '';
|
|
}
|
|
} 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 signWithStoredKey() {
|
|
error = '';
|
|
loading = true;
|
|
|
|
try {
|
|
// Unlock the stored private key
|
|
const privateKey = await pgp.unlockPrivateKey(storedKeyPassphrase);
|
|
|
|
// Sign the challenge
|
|
pgpSignature = await pgp.signMessage(pgpChallenge, privateKey, storedKeyPassphrase);
|
|
|
|
// Clear passphrase for security
|
|
storedKeyPassphrase = '';
|
|
} catch (e) {
|
|
error = 'Failed to unlock key or sign: ' + e.message;
|
|
}
|
|
|
|
loading = 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 = '';
|
|
storedKeyPassphrase = '';
|
|
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);
|
|
}
|
|
</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 and your passphrase 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 (Encrypted with your passphrase)</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 (REQUIRED!)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group" style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); padding: 1rem; border-radius: 4px;">
|
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={saveKeyLocally}
|
|
style="width: 20px; height: 20px;"
|
|
/>
|
|
Save encrypted key in this browser for easier PGP login
|
|
</label>
|
|
<small style="color: var(--gray); display: block; margin-top: 0.5rem;">
|
|
The key will be encrypted with your passphrase and stored locally. You can export or remove it later from Settings.
|
|
</small>
|
|
</div>
|
|
|
|
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
|
|
{loading ? 'Saving...' : '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>
|
|
|
|
{#await pgp.hasEncryptedKey() then hasKey}
|
|
{#if hasKey && !pgpSignature}
|
|
<div class="instruction-box" style="background: rgba(40, 167, 69, 0.1); border-color: rgba(40, 167, 69, 0.3);">
|
|
<p><strong>Stored key detected!</strong></p>
|
|
<p>Enter your passphrase to unlock your private key and sign automatically:</p>
|
|
|
|
<div class="form-group" style="margin: 1rem 0;">
|
|
<input
|
|
type="password"
|
|
bind:value={storedKeyPassphrase}
|
|
placeholder="Enter your key passphrase"
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
on:click={signWithStoredKey}
|
|
disabled={loading || !storedKeyPassphrase}
|
|
style="margin-bottom: 0.5rem;"
|
|
>
|
|
{loading ? 'Signing...' : 'Unlock & Sign'}
|
|
</button>
|
|
</div>
|
|
|
|
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
|
|
OR sign manually
|
|
</div>
|
|
{/if}
|
|
{/await}
|
|
|
|
<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: Your private key passphrase is required to unlock your key for signing.
|
|
</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 (will prompt for passphrase)
|
|
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>Enter your passphrase when prompted</li>
|
|
<li>Open the .asc 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>
|
|
|
|
<div style="background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); padding: 1rem; border-radius: 8px; margin: 1.5rem 0;">
|
|
<h3 style="margin: 0 0 1rem 0; font-size: 1.1rem; color: var(--primary);">🔠PGP Key Passphrase</h3>
|
|
<p style="font-size: 0.9rem; color: var(--gray); margin-bottom: 1rem;">
|
|
This passphrase encrypts your PGP private key. You'll need it every time you use PGP login.
|
|
</p>
|
|
|
|
<div class="form-group">
|
|
<label for="key-passphrase">Key Passphrase</label>
|
|
<input
|
|
type="password"
|
|
id="key-passphrase"
|
|
bind:value={keyPassphrase}
|
|
required
|
|
disabled={loading}
|
|
placeholder="Enter a strong passphrase (12+ characters)"
|
|
/>
|
|
<small style="color: var(--gray);">
|
|
Must be 12+ characters with 3 of: uppercase, lowercase, numbers, special characters
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="confirm-key-passphrase">Confirm Key Passphrase</label>
|
|
<input
|
|
type="password"
|
|
id="confirm-key-passphrase"
|
|
bind:value={confirmKeyPassphrase}
|
|
required
|
|
disabled={loading}
|
|
placeholder="Confirm your key passphrase"
|
|
/>
|
|
</div>
|
|
</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 and encrypted with your passphrase.
|
|
</p>
|
|
</form>
|
|
{/if}
|
|
|
|
<div style="margin-top: 2rem; text-align: center;">
|
|
{#if mode === 'login'}
|
|
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; username = ''; password = ''; }}>
|
|
Need an account? Register
|
|
</button>
|
|
{:else}
|
|
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}>
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\my-realms\+page.svelte ###
|
|
|
|
<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 Math.round(bitrate / 1000) + ' kbps'; // Changed to lowercase 'kbps' and rounded
|
|
} 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-owner-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.25rem 0.75rem;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
margin-left: 1rem;
|
|
}
|
|
|
|
.realm-owner-color {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--white);
|
|
}
|
|
|
|
.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}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\profile\[username]\+page.svelte ###
|
|
|
|
<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;
|
|
border: 3px solid var(--border);
|
|
position: relative;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.profile-avatar.has-color {
|
|
background: var(--user-color);
|
|
border-color: var(--user-color);
|
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.profile-avatar.has-color.with-image {
|
|
border-width: 4px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.color-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
margin-top: 0.5rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.color-dot {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.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;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bio-section::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 3px;
|
|
background: var(--user-color, var(--primary));
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.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"
|
|
class:has-color={profile.colorCode}
|
|
class:with-image={profile.avatarUrl}
|
|
style="--user-color: {profile.colorCode || '#561D5E'}"
|
|
>
|
|
{#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>
|
|
<div class="color-badge">
|
|
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
|
|
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
|
|
</div>
|
|
{#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" style="--user-color: {profile.colorCode || '#561D5E'}">
|
|
<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>
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\settings\+page.svelte ###
|
|
|
|
<script>
|
|
import { onMount } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import { auth, isAuthenticated } from '$lib/stores/auth';
|
|
import { goto } from '$app/navigation';
|
|
import * as pgp from '$lib/pgp';
|
|
|
|
let activeTab = 'profile';
|
|
let loading = false;
|
|
let message = '';
|
|
let error = '';
|
|
|
|
// Profile
|
|
let bio = '';
|
|
let avatarFile = null;
|
|
let avatarPreview = '';
|
|
let avatarPreviewUrl = ''; // Separate preview for file upload
|
|
let hasProfileChanges = false;
|
|
|
|
// Password
|
|
let oldPassword = '';
|
|
let newPassword = '';
|
|
let confirmPassword = '';
|
|
|
|
// Color picker
|
|
let userColor = '#561D5E';
|
|
let newColor = '';
|
|
let showColorPicker = false;
|
|
let colorLoading = false;
|
|
let colorError = '';
|
|
let colorMessage = '';
|
|
|
|
// Popular colors to choose from
|
|
const suggestedColors = [
|
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#FD79A8',
|
|
'#A29BFE', '#6C5CE7', '#FAB1A0', '#74B9FF', '#A8E6CF', '#FFD3B6',
|
|
'#FF8CC3', '#00B894', '#00CEC9', '#0984E3', '#6C5CE7', '#E17055',
|
|
'#FDCB6E', '#55A3FF', '#FD79A8', '#BADC58', '#F8B739', '#FA8231',
|
|
'#EB3B5A', '#FC5C65', '#45AAF2', '#4B7BEC', '#A55EEA', '#D63031',
|
|
'#74B9FF', '#A29BFE', '#FD79A8', '#E17055', '#00B894', '#00CEC9'
|
|
];
|
|
|
|
// PGP
|
|
let pgpKeys = [];
|
|
let pgpOnly = false;
|
|
let pgpOnlyEnabledAt = '';
|
|
let showAddKey = false;
|
|
let newPublicKey = '';
|
|
let showPgpWarning = false;
|
|
let pgpConfirmationText = '';
|
|
const PGP_CONFIRMATION_PHRASE = 'I UNDERSTAND THIS IS PERMANENT';
|
|
|
|
// For displaying generated keys
|
|
let showGeneratedKeys = false;
|
|
let generatedPrivateKey = '';
|
|
let generatedPublicKey = '';
|
|
let keyPassphrase = '';
|
|
let confirmKeyPassphrase = '';
|
|
let saveKeyLocally = false;
|
|
let hasEncryptedKey = false;
|
|
|
|
// Unlock key dialog
|
|
let showUnlockDialog = false;
|
|
let unlockPassphrase = '';
|
|
|
|
// User data for safe access
|
|
let currentUser = null;
|
|
|
|
onMount(async () => {
|
|
// Only run on client side
|
|
if (!browser) return;
|
|
|
|
await auth.init();
|
|
if (!$isAuthenticated) {
|
|
goto('/login');
|
|
return;
|
|
}
|
|
|
|
// Store user for safe access
|
|
currentUser = $auth.user;
|
|
|
|
// Check if we have an encrypted key stored
|
|
hasEncryptedKey = await pgp.hasEncryptedKey();
|
|
|
|
// Get fresh user data to ensure we have latest values
|
|
try {
|
|
const response = await fetch('/api/user/me', {
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.user) {
|
|
// Update local state with fresh data
|
|
bio = data.user.bio || '';
|
|
pgpOnly = data.user.isPgpOnly === true;
|
|
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
|
|
avatarPreview = data.user.avatarUrl || '';
|
|
userColor = data.user.colorCode || '#561D5E';
|
|
newColor = userColor;
|
|
currentUser = data.user;
|
|
|
|
// Update auth store with fresh data
|
|
auth.updateUser(data.user);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load user data:', e);
|
|
// Fallback to auth store data if available
|
|
if ($auth.user) {
|
|
bio = $auth.user.bio || '';
|
|
pgpOnly = $auth.user.isPgpOnly === true;
|
|
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
|
|
avatarPreview = $auth.user.avatarUrl || '';
|
|
userColor = $auth.user.colorCode || '#561D5E';
|
|
newColor = userColor;
|
|
currentUser = $auth.user;
|
|
}
|
|
}
|
|
|
|
loadPgpKeys();
|
|
});
|
|
|
|
async function loadPgpKeys() {
|
|
if (!browser) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/user/pgp-keys', {
|
|
credentials: 'include'
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
pgpKeys = data.keys;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load PGP keys:', e);
|
|
}
|
|
}
|
|
|
|
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 updateProfile() {
|
|
error = '';
|
|
message = '';
|
|
loading = true;
|
|
|
|
try {
|
|
// Update bio
|
|
const response = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ bio })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
error = data.error || 'Failed to update profile';
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
// Upload avatar if changed
|
|
if (avatarFile) {
|
|
const formData = new FormData();
|
|
formData.append('file', avatarFile);
|
|
|
|
const avatarResponse = await fetch('/api/user/avatar', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData
|
|
});
|
|
|
|
const avatarData = await avatarResponse.json();
|
|
|
|
if (avatarResponse.ok && avatarData.success) {
|
|
avatarPreview = avatarData.avatarUrl;
|
|
avatarFile = null;
|
|
avatarPreviewUrl = '';
|
|
|
|
// Update current user and auth store
|
|
if (currentUser) {
|
|
currentUser = { ...currentUser, avatarUrl: avatarData.avatarUrl };
|
|
auth.updateUser(currentUser);
|
|
}
|
|
} else {
|
|
error = avatarData.error || 'Failed to upload avatar';
|
|
loading = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
message = 'Profile updated successfully';
|
|
error = '';
|
|
hasProfileChanges = false;
|
|
|
|
// Update current user and auth store
|
|
if (currentUser) {
|
|
currentUser = { ...currentUser, bio };
|
|
auth.updateUser(currentUser);
|
|
}
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} catch (e) {
|
|
error = 'Error updating profile';
|
|
message = '';
|
|
console.error(e);
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function updatePassword() {
|
|
error = '';
|
|
message = '';
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
error = 'Passwords do not match';
|
|
return;
|
|
}
|
|
|
|
const passwordError = validatePassword(newPassword);
|
|
if (passwordError) {
|
|
error = passwordError;
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
|
|
try {
|
|
const response = await fetch('/api/user/password', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ oldPassword, newPassword })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
message = 'Password updated successfully';
|
|
error = '';
|
|
oldPassword = '';
|
|
newPassword = '';
|
|
confirmPassword = '';
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} else {
|
|
error = data.error || 'Failed to update password';
|
|
message = '';
|
|
}
|
|
} catch (e) {
|
|
error = 'Error updating password';
|
|
message = '';
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function updateColor() {
|
|
if (!newColor || newColor === userColor) {
|
|
colorError = 'Please select a different color';
|
|
return;
|
|
}
|
|
|
|
colorLoading = true;
|
|
colorError = '';
|
|
colorMessage = '';
|
|
|
|
const result = await auth.updateColor(newColor);
|
|
|
|
if (result.success) {
|
|
userColor = result.color;
|
|
colorMessage = 'Color updated successfully!';
|
|
showColorPicker = false;
|
|
|
|
// Refresh the current user data to ensure consistency
|
|
if (currentUser) {
|
|
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
|
|
}
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { colorMessage = ''; }, 3000);
|
|
} else {
|
|
colorError = result.error || 'Failed to update color';
|
|
}
|
|
|
|
colorLoading = false;
|
|
}
|
|
|
|
function selectSuggestedColor(color) {
|
|
newColor = color;
|
|
}
|
|
|
|
function handleColorInput(event) {
|
|
const value = event.target.value;
|
|
// Ensure it starts with # and is uppercase
|
|
if (value.length > 0 && value[0] !== '#') {
|
|
newColor = '#' + value;
|
|
} else {
|
|
newColor = value.toUpperCase();
|
|
}
|
|
}
|
|
|
|
function isValidColor(color) {
|
|
return /^#[0-9A-F]{6}$/i.test(color);
|
|
}
|
|
|
|
// Fixed: Handle checkbox click to show warning
|
|
function handlePgpCheckboxClick(event) {
|
|
if (pgpOnly) {
|
|
// Already enabled, can't disable
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Prevent the checkbox from changing state
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Show warning dialog
|
|
showPgpWarning = true;
|
|
}
|
|
|
|
// Handle label click
|
|
function handlePgpLabelClick(event) {
|
|
event.preventDefault();
|
|
if (!pgpOnly && !loading) {
|
|
showPgpWarning = true;
|
|
}
|
|
}
|
|
|
|
async function enablePgpOnly() {
|
|
// Check confirmation text
|
|
if (pgpConfirmationText !== PGP_CONFIRMATION_PHRASE) {
|
|
error = 'Please type the confirmation text exactly as shown';
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
error = '';
|
|
message = '';
|
|
showPgpWarning = false;
|
|
pgpConfirmationText = '';
|
|
|
|
try {
|
|
const response = await fetch('/api/user/pgp-only', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ enable: true })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
pgpOnly = true;
|
|
pgpOnlyEnabledAt = data.pgpOnlyEnabledAt || new Date().toISOString();
|
|
|
|
// Update current user and auth store
|
|
if (currentUser) {
|
|
currentUser = { ...currentUser, isPgpOnly: true, pgpOnlyEnabledAt };
|
|
auth.updateUser(currentUser);
|
|
}
|
|
|
|
message = 'PGP-only mode enabled successfully';
|
|
error = '';
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} else {
|
|
error = data.error || 'Failed to enable PGP-only mode';
|
|
message = '';
|
|
// Reset checkbox on failure
|
|
pgpOnly = false;
|
|
}
|
|
} catch (e) {
|
|
error = 'Error updating setting';
|
|
message = '';
|
|
console.error(e);
|
|
// Reset checkbox on error
|
|
pgpOnly = false;
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
// Fixed: Cancel PGP warning and reset checkbox
|
|
function cancelPgpWarning() {
|
|
showPgpWarning = false;
|
|
pgpConfirmationText = '';
|
|
// Reset the checkbox to unchecked state
|
|
pgpOnly = false;
|
|
}
|
|
|
|
async function generateNewKeyPair() {
|
|
if (!browser) return;
|
|
|
|
error = '';
|
|
keyPassphrase = '';
|
|
confirmKeyPassphrase = '';
|
|
saveKeyLocally = false;
|
|
|
|
// Prompt for passphrase first
|
|
showGeneratedKeys = true;
|
|
generatedPrivateKey = '';
|
|
generatedPublicKey = '';
|
|
}
|
|
|
|
async function generateKeysWithPassphrase() {
|
|
error = '';
|
|
|
|
if (!keyPassphrase) {
|
|
error = 'Passphrase is required';
|
|
return;
|
|
}
|
|
|
|
if (keyPassphrase !== confirmKeyPassphrase) {
|
|
error = 'Passphrases do not match';
|
|
return;
|
|
}
|
|
|
|
const passphraseError = pgp.validatePassphrase(keyPassphrase);
|
|
if (passphraseError) {
|
|
error = passphraseError;
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
|
|
try {
|
|
const keyPair = await pgp.generateKeyPair(currentUser?.username || 'user', keyPassphrase);
|
|
|
|
generatedPrivateKey = keyPair.privateKey;
|
|
generatedPublicKey = keyPair.publicKey;
|
|
|
|
} catch (e) {
|
|
error = 'Failed to generate key pair: ' + e.message;
|
|
console.error(e);
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function saveGeneratedKeys() {
|
|
loading = true;
|
|
error = '';
|
|
|
|
try {
|
|
// Save public key to server
|
|
const fingerprint = await pgp.getFingerprint(generatedPublicKey);
|
|
|
|
const response = await fetch('/api/user/pgp-key', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
publicKey: generatedPublicKey,
|
|
fingerprint: fingerprint
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// If user wants to save locally, encrypt and store
|
|
if (saveKeyLocally) {
|
|
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
|
|
hasEncryptedKey = true;
|
|
}
|
|
|
|
message = 'New key pair saved successfully';
|
|
error = '';
|
|
await loadPgpKeys();
|
|
showGeneratedKeys = false;
|
|
generatedPrivateKey = '';
|
|
generatedPublicKey = '';
|
|
keyPassphrase = '';
|
|
confirmKeyPassphrase = '';
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} else {
|
|
error = data.error || 'Failed to save key';
|
|
message = '';
|
|
}
|
|
} catch (e) {
|
|
error = 'Failed to save key pair';
|
|
message = '';
|
|
console.error(e);
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function addPublicKey() {
|
|
error = '';
|
|
loading = true;
|
|
|
|
try {
|
|
const fingerprint = await pgp.getFingerprint(newPublicKey);
|
|
if (!fingerprint) {
|
|
error = 'Invalid PGP key';
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/api/user/pgp-key', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
publicKey: newPublicKey,
|
|
fingerprint
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
message = 'PGP key added successfully';
|
|
error = '';
|
|
newPublicKey = '';
|
|
showAddKey = false;
|
|
await loadPgpKeys();
|
|
|
|
// Clear message after 3 seconds
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} else {
|
|
error = data.error || 'Failed to add key';
|
|
message = '';
|
|
}
|
|
} catch (e) {
|
|
error = 'Error adding key';
|
|
message = '';
|
|
}
|
|
|
|
loading = false;
|
|
}
|
|
|
|
async function handleAvatarChange(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
error = '';
|
|
|
|
if (file.size > 250 * 1024) {
|
|
error = 'File too large (max 250KB)';
|
|
return;
|
|
}
|
|
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
if (!['jpg', 'jpeg', 'png', 'gif'].includes(ext)) {
|
|
error = 'Invalid file type (jpg, png, gif only)';
|
|
return;
|
|
}
|
|
|
|
avatarFile = file;
|
|
hasProfileChanges = true;
|
|
|
|
// Preview using FileReader
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
avatarPreviewUrl = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function cancelAvatarChange() {
|
|
avatarFile = null;
|
|
avatarPreviewUrl = '';
|
|
hasProfileChanges = bio !== (currentUser?.bio || '');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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'
|
|
});
|
|
}
|
|
|
|
async function showUnlockKeyDialog() {
|
|
showUnlockDialog = true;
|
|
unlockPassphrase = '';
|
|
}
|
|
|
|
async function unlockAndExportKey() {
|
|
error = '';
|
|
|
|
try {
|
|
const privateKey = await pgp.unlockPrivateKey(unlockPassphrase);
|
|
downloadKey(privateKey, `${displayUsername}-private-key.asc`);
|
|
showUnlockDialog = false;
|
|
unlockPassphrase = '';
|
|
message = 'Private key exported successfully';
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
} catch (e) {
|
|
error = 'Failed to unlock key: ' + e.message;
|
|
}
|
|
}
|
|
|
|
async function removeLocalKey() {
|
|
if (confirm('Remove the encrypted private key from this browser? You will need to re-import it later to use PGP login.')) {
|
|
await pgp.removeEncryptedPrivateKey();
|
|
hasEncryptedKey = false;
|
|
message = 'Local key removed';
|
|
setTimeout(() => { message = ''; }, 3000);
|
|
}
|
|
}
|
|
|
|
// Track profile changes
|
|
$: if (browser) {
|
|
hasProfileChanges = bio !== (currentUser?.bio || '') || avatarFile !== null;
|
|
}
|
|
|
|
// Get username safely for display
|
|
$: displayUsername = currentUser?.username || '';
|
|
// Choose which avatar to show
|
|
$: displayAvatar = avatarPreviewUrl || avatarPreview;
|
|
</script>
|
|
|
|
<style>
|
|
.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);
|
|
}
|
|
|
|
.file-input-wrapper {
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: inline-block;
|
|
}
|
|
|
|
.file-input-wrapper input[type="file"] {
|
|
position: absolute;
|
|
font-size: 100px;
|
|
right: 0;
|
|
top: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.file-input-button {
|
|
display: inline-block;
|
|
padding: 0.75rem 1.5rem;
|
|
background: transparent;
|
|
color: var(--white);
|
|
border: 1px solid var(--primary);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.file-input-button:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.checkbox-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 2rem;
|
|
padding: 1rem;
|
|
background: rgba(86, 29, 94, 0.1);
|
|
border: 1px solid rgba(86, 29, 94, 0.3);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.checkbox-wrapper.disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.checkbox-wrapper input[type="checkbox"] {
|
|
width: 20px;
|
|
height: 20px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.checkbox-wrapper.disabled input[type="checkbox"] {
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.checkbox-wrapper label {
|
|
margin: 0;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
flex: 1;
|
|
}
|
|
|
|
.checkbox-wrapper.disabled label {
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.avatar-display {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background: var(--gray);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 2rem;
|
|
color: var(--white);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.avatar-display img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.avatar-link {
|
|
display: block;
|
|
margin-top: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.avatar-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.warning-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.9);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.warning-content {
|
|
background: #111;
|
|
border: 2px solid var(--error);
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.warning-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--error);
|
|
}
|
|
|
|
.warning-icon {
|
|
font-size: 2.5rem;
|
|
}
|
|
|
|
.warning-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.warning-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 1.5rem 0;
|
|
}
|
|
|
|
.warning-list li {
|
|
margin-bottom: 1rem;
|
|
padding-left: 2rem;
|
|
position: relative;
|
|
}
|
|
|
|
.warning-list li::before {
|
|
content: "âš ï¸";
|
|
position: absolute;
|
|
left: 0;
|
|
}
|
|
|
|
.confirmation-box {
|
|
background: rgba(220, 53, 69, 0.1);
|
|
border: 1px solid var(--error);
|
|
border-radius: 4px;
|
|
padding: 1rem;
|
|
margin: 1.5rem 0;
|
|
}
|
|
|
|
.confirmation-text {
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.confirmation-input {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
background: var(--black);
|
|
border: 1px solid var(--error);
|
|
color: var(--white);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.warning-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: flex-end;
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.pgp-enabled-info {
|
|
background: rgba(40, 167, 69, 0.1);
|
|
border: 1px solid rgba(40, 167, 69, 0.3);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.pgp-enabled-info p {
|
|
margin: 0 0 0.5rem 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.pgp-enabled-info .check-icon {
|
|
color: var(--success);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.pgp-enabled-date {
|
|
font-size: 0.9rem;
|
|
color: var(--gray);
|
|
}
|
|
|
|
.save-reminder {
|
|
background: rgba(255, 193, 7, 0.1);
|
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
|
border-radius: 4px;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* Color picker styles */
|
|
.color-picker-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.color-input-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.color-preview {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 8px;
|
|
border: 2px solid var(--border);
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.color-preview:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.color-input {
|
|
padding: 0.75rem;
|
|
font-family: monospace;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.color-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 1fr);
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.color-option {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 8px;
|
|
border: 2px solid transparent;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.color-option:hover {
|
|
transform: scale(1.1);
|
|
border-color: var(--white);
|
|
}
|
|
|
|
.color-option.selected {
|
|
border-color: var(--white);
|
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.passphrase-info {
|
|
background: rgba(86, 29, 94, 0.1);
|
|
border: 1px solid rgba(86, 29, 94, 0.3);
|
|
border-radius: 4px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.passphrase-info h4 {
|
|
margin: 0 0 0.5rem 0;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.passphrase-info ul {
|
|
margin: 0;
|
|
padding-left: 1.5rem;
|
|
font-size: 0.9rem;
|
|
color: var(--gray);
|
|
}
|
|
|
|
.key-storage-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: rgba(255, 193, 7, 0.1);
|
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.local-key-info {
|
|
background: rgba(40, 167, 69, 0.1);
|
|
border: 1px solid rgba(40, 167, 69, 0.3);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.local-key-info p {
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.local-key-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
</style>
|
|
|
|
<div class="container">
|
|
<h1>Settings</h1>
|
|
|
|
<div class="tab-nav">
|
|
<button
|
|
class="tab-button"
|
|
class:active={activeTab === 'profile'}
|
|
on:click={() => activeTab = 'profile'}
|
|
>
|
|
Profile
|
|
</button>
|
|
<button
|
|
class="tab-button"
|
|
class:active={activeTab === 'appearance'}
|
|
on:click={() => activeTab = 'appearance'}
|
|
>
|
|
Appearance
|
|
</button>
|
|
<button
|
|
class="tab-button"
|
|
class:active={activeTab === 'password'}
|
|
on:click={() => activeTab = 'password'}
|
|
>
|
|
Password
|
|
</button>
|
|
<button
|
|
class="tab-button"
|
|
class:active={activeTab === 'pgp'}
|
|
on:click={() => activeTab = 'pgp'}
|
|
>
|
|
PGP Keys
|
|
</button>
|
|
</div>
|
|
|
|
{#if activeTab === 'profile'}
|
|
<div class="card">
|
|
<h2>Profile Settings</h2>
|
|
|
|
{#if message}
|
|
<div class="success" style="margin-bottom: 1rem;">{message}</div>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
|
{/if}
|
|
|
|
{#if hasProfileChanges}
|
|
<div class="save-reminder">
|
|
<span>ℹï¸</span>
|
|
<span>You have unsaved changes. Click "Save Profile" to apply them.</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="form-group">
|
|
<label for="avatar-upload">Avatar</label>
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<div class="avatar-display">
|
|
{#if displayAvatar}
|
|
<img src={displayAvatar} alt="Avatar" />
|
|
{:else if displayUsername}
|
|
{displayUsername.charAt(0).toUpperCase()}
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<div class="file-input-wrapper">
|
|
<div class="file-input-button">Choose File</div>
|
|
<input
|
|
id="avatar-upload"
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/gif"
|
|
on:change={handleAvatarChange}
|
|
/>
|
|
</div>
|
|
{#if avatarFile}
|
|
<button
|
|
class="btn btn-secondary"
|
|
style="margin-left: 0.5rem;"
|
|
on:click={cancelAvatarChange}
|
|
>
|
|
Cancel
|
|
</button>
|
|
{/if}
|
|
<div style="font-size: 0.85rem; color: var(--gray); margin-top: 0.5rem;">
|
|
Max 250KB, JPG/PNG/GIF only
|
|
</div>
|
|
{#if avatarPreview && !avatarFile && !avatarPreviewUrl}
|
|
<a href={avatarPreview} target="_blank" class="avatar-link">
|
|
{avatarPreview}
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="bio">Bio</label>
|
|
<textarea
|
|
id="bio"
|
|
bind:value={bio}
|
|
rows="4"
|
|
placeholder="Tell us about yourself..."
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
on:click={updateProfile}
|
|
disabled={loading || !hasProfileChanges}
|
|
>
|
|
{loading ? 'Saving...' : 'Save Profile'}
|
|
</button>
|
|
</div>
|
|
{:else if activeTab === 'appearance'}
|
|
<div class="card">
|
|
<h2>Appearance Settings</h2>
|
|
|
|
{#if colorMessage}
|
|
<div class="success" style="margin-bottom: 1rem;">{colorMessage}</div>
|
|
{/if}
|
|
|
|
{#if colorError}
|
|
<div class="error" style="margin-bottom: 1rem;">{colorError}</div>
|
|
{/if}
|
|
|
|
<div class="form-group">
|
|
<label>Profile Color</label>
|
|
<p style="color: var(--gray); font-size: 0.9rem; margin-bottom: 1rem;">
|
|
Your unique color appears next to your name across the platform
|
|
</p>
|
|
|
|
<div class="color-picker-container">
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<div
|
|
class="color-preview"
|
|
style="background: {userColor};"
|
|
title="Current color"
|
|
></div>
|
|
<div>
|
|
<div style="font-family: monospace; font-size: 1.1rem;">
|
|
{userColor}
|
|
</div>
|
|
<div style="font-size: 0.85rem; color: var(--gray);">
|
|
Current color
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => { showColorPicker = !showColorPicker; newColor = userColor; }}
|
|
>
|
|
{showColorPicker ? 'Cancel' : 'Change Color'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showColorPicker}
|
|
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
|
|
<h3 style="margin-bottom: 1rem;">Choose a new color</h3>
|
|
|
|
<div class="form-group">
|
|
<label for="color-input">Custom Color</label>
|
|
<div class="color-input-wrapper">
|
|
<div
|
|
class="color-preview"
|
|
style="background: {isValidColor(newColor) ? newColor : '#000'};"
|
|
></div>
|
|
<input
|
|
id="color-input"
|
|
type="text"
|
|
class="color-input"
|
|
value={newColor}
|
|
on:input={handleColorInput}
|
|
placeholder="#000000"
|
|
maxlength="7"
|
|
pattern="^#[0-9A-Fa-f]{6}$"
|
|
/>
|
|
<input
|
|
type="color"
|
|
value={newColor}
|
|
on:input={(e) => newColor = e.target.value.toUpperCase()}
|
|
style="width: 50px; height: 40px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px;"
|
|
/>
|
|
</div>
|
|
<small style="color: var(--gray);">
|
|
Enter a hex color code (e.g., #FF6B6B)
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Suggested Colors</label>
|
|
<div class="color-grid">
|
|
{#each suggestedColors as color}
|
|
<button
|
|
class="color-option"
|
|
class:selected={newColor === color}
|
|
style="background: {color};"
|
|
on:click={() => selectSuggestedColor(color)}
|
|
title={color}
|
|
></button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
|
|
<button
|
|
on:click={updateColor}
|
|
disabled={colorLoading || !isValidColor(newColor) || newColor === userColor}
|
|
>
|
|
{colorLoading ? 'Updating...' : 'Save Color'}
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => { showColorPicker = false; newColor = userColor; }}
|
|
disabled={colorLoading}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
{#if newColor && newColor !== userColor}
|
|
<div style="margin-top: 1rem; padding: 1rem; background: rgba(0, 0, 0, 0.3); border-radius: 4px;">
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<div
|
|
class="color-preview"
|
|
style="background: {newColor};"
|
|
></div>
|
|
<div>
|
|
<div style="font-size: 0.85rem; color: var(--gray);">Preview</div>
|
|
<div style="font-family: monospace;">{newColor}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if activeTab === 'password'}
|
|
<div class="card">
|
|
<h2>Change Password</h2>
|
|
|
|
{#if pgpOnly}
|
|
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
|
|
<p>
|
|
<span class="check-icon">ðŸ”</span>
|
|
<strong>PGP-only mode is enabled</strong>
|
|
</p>
|
|
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
|
Password login is disabled. You can still change your password as a backup,
|
|
but you must use PGP to login.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if message}
|
|
<div class="success" style="margin-bottom: 1rem;">{message}</div>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
|
{/if}
|
|
|
|
<form on:submit|preventDefault={updatePassword}>
|
|
<div class="form-group">
|
|
<label for="old-password">Current Password</label>
|
|
<input
|
|
type="password"
|
|
id="old-password"
|
|
bind:value={oldPassword}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="new-password">New Password</label>
|
|
<input
|
|
type="password"
|
|
id="new-password"
|
|
bind:value={newPassword}
|
|
required
|
|
/>
|
|
<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-new-password">Confirm New Password</label>
|
|
<input
|
|
type="password"
|
|
id="confirm-new-password"
|
|
bind:value={confirmPassword}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? 'Updating...' : 'Update Password'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{:else if activeTab === 'pgp'}
|
|
<div class="card">
|
|
<h2>PGP Key Management</h2>
|
|
|
|
{#if message}
|
|
<div class="success" style="margin-bottom: 1rem;">{message}</div>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
|
{/if}
|
|
|
|
{#if pgpOnly}
|
|
<div class="pgp-enabled-info">
|
|
<p>
|
|
<span class="check-icon">✔</span>
|
|
<strong>PGP-only mode is active</strong>
|
|
</p>
|
|
<p class="pgp-enabled-date">
|
|
Enabled on: {formatDateTime(pgpOnlyEnabledAt)}
|
|
</p>
|
|
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
|
Password login has been permanently disabled. You can only login using your PGP keys.
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<div class="checkbox-wrapper" class:disabled={pgpOnly}>
|
|
<input
|
|
type="checkbox"
|
|
id="pgp-only"
|
|
checked={pgpOnly}
|
|
on:click={handlePgpCheckboxClick}
|
|
disabled={pgpOnly || loading}
|
|
readonly
|
|
/>
|
|
<label
|
|
for="pgp-only"
|
|
on:click={handlePgpLabelClick}
|
|
>
|
|
Enable PGP-only mode (disables password login permanently)
|
|
</label>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if hasEncryptedKey}
|
|
<div class="local-key-info">
|
|
<p>
|
|
<span style="color: var(--success);">🔑</span>
|
|
<strong>Encrypted private key stored in this browser</strong>
|
|
</p>
|
|
<p style="font-size: 0.9rem; color: var(--gray);">
|
|
Your private key is encrypted and stored locally. You'll need your passphrase to use it.
|
|
</p>
|
|
<div class="local-key-actions">
|
|
<button on:click={showUnlockKeyDialog}>
|
|
Export Private Key
|
|
</button>
|
|
<button class="btn btn-danger" on:click={removeLocalKey}>
|
|
Remove Local Key
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<h3>Your PGP Keys</h3>
|
|
|
|
{#if pgpKeys.length > 0}
|
|
<div class="table" style="margin-bottom: 2rem;">
|
|
<table style="width: 100%;">
|
|
<thead>
|
|
<tr>
|
|
<th>Fingerprint</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each pgpKeys as key}
|
|
<tr>
|
|
<td class="fingerprint">{key.fingerprint}</td>
|
|
<td>{new Date(key.createdAt).toLocaleDateString()}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else}
|
|
<p style="color: var(--gray); margin-bottom: 2rem;">No PGP keys found</p>
|
|
{/if}
|
|
|
|
{#if showGeneratedKeys}
|
|
<div style="margin: 2rem 0; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
|
|
<h3>Generate New Key Pair</h3>
|
|
|
|
{#if !generatedPrivateKey}
|
|
<div class="passphrase-info">
|
|
<h4>🔠Passphrase Requirements</h4>
|
|
<ul>
|
|
<li>At least 12 characters long</li>
|
|
<li>Include 3 of: uppercase, lowercase, numbers, special characters</li>
|
|
<li>This passphrase encrypts your private key</li>
|
|
<li>You'll need it every time you use your key</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="key-passphrase">Passphrase</label>
|
|
<input
|
|
type="password"
|
|
id="key-passphrase"
|
|
bind:value={keyPassphrase}
|
|
placeholder="Enter a strong passphrase"
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="confirm-key-passphrase">Confirm Passphrase</label>
|
|
<input
|
|
type="password"
|
|
id="confirm-key-passphrase"
|
|
bind:value={confirmKeyPassphrase}
|
|
placeholder="Confirm your passphrase"
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 1rem;">
|
|
<button on:click={generateKeysWithPassphrase} disabled={loading || !keyPassphrase}>
|
|
{loading ? 'Generating...' : 'Generate Keys'}
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => { showGeneratedKeys = false; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<p style="color: var(--error); margin-bottom: 1rem;">
|
|
<strong>Important:</strong> Save your private key securely. You will need it and your passphrase to login with PGP.
|
|
</p>
|
|
|
|
<div class="form-group">
|
|
<label>Public Key</label>
|
|
<textarea
|
|
readonly
|
|
rows="8"
|
|
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, `${displayUsername}-public-key.asc`)}>
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Private Key (Encrypted with your passphrase)</label>
|
|
<textarea
|
|
readonly
|
|
rows="8"
|
|
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, `${displayUsername}-private-key.asc`)}>
|
|
Download (Required!)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="key-storage-option">
|
|
<input
|
|
type="checkbox"
|
|
id="save-key-locally"
|
|
bind:checked={saveKeyLocally}
|
|
/>
|
|
<label for="save-key-locally">
|
|
Also save encrypted key in this browser for easier PGP login (you can export/remove it later)
|
|
</label>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
|
<button on:click={saveGeneratedKeys} disabled={loading}>
|
|
Save Keys
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => { showGeneratedKeys = false; generatedPrivateKey = ''; generatedPublicKey = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
|
<button on:click={generateNewKeyPair} disabled={loading}>
|
|
Generate New Key Pair
|
|
</button>
|
|
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => showAddKey = !showAddKey}
|
|
>
|
|
Add Existing Public Key
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showAddKey}
|
|
<div style="margin-top: 2rem;">
|
|
<h4>Add Public Key</h4>
|
|
<div class="form-group">
|
|
<textarea
|
|
bind:value={newPublicKey}
|
|
rows="10"
|
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
|
style="font-family: monospace; font-size: 0.9rem;"
|
|
/>
|
|
</div>
|
|
<button on:click={addPublicKey} disabled={loading || !newPublicKey}>
|
|
Add Key
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
style="margin-left: 0.5rem;"
|
|
on:click={() => { showAddKey = false; newPublicKey = ''; }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if showPgpWarning}
|
|
<div class="warning-modal">
|
|
<div class="warning-content">
|
|
<div class="warning-header">
|
|
<span class="warning-icon">âš ï¸</span>
|
|
<h2 class="warning-title">Critical Security Warning</h2>
|
|
</div>
|
|
|
|
<p><strong>You are about to enable PGP-only authentication mode.</strong></p>
|
|
|
|
<ul class="warning-list">
|
|
<li>This action is <strong>PERMANENT</strong> and <strong>CANNOT BE REVERSED</strong></li>
|
|
<li>Password login will be <strong>PERMANENTLY DISABLED</strong></li>
|
|
<li>You will <strong>ONLY</strong> be able to login using your PGP private key</li>
|
|
<li>If you lose your private key, you will <strong>LOSE ACCESS TO YOUR ACCOUNT FOREVER</strong></li>
|
|
<li>There is <strong>NO RECOVERY METHOD</strong> if you lose your PGP keys</li>
|
|
<li>Even administrators <strong>CANNOT</strong> restore password access</li>
|
|
</ul>
|
|
|
|
<div class="confirmation-box">
|
|
<p class="confirmation-text">
|
|
To confirm you understand and accept these risks, type
|
|
<strong style="color: var(--error);">I UNDERSTAND THIS IS PERMANENT</strong> below:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
class="confirmation-input"
|
|
placeholder="Type the confirmation text here"
|
|
bind:value={pgpConfirmationText}
|
|
on:input={(e) => {
|
|
if (e.target.value === PGP_CONFIRMATION_PHRASE) {
|
|
e.target.style.borderColor = 'var(--success)';
|
|
} else {
|
|
e.target.style.borderColor = 'var(--error)';
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="warning-actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={cancelPgpWarning}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="btn btn-danger"
|
|
on:click={enablePgpOnly}
|
|
disabled={loading || pgpConfirmationText !== PGP_CONFIRMATION_PHRASE}
|
|
>
|
|
{loading ? 'Enabling...' : 'Enable PGP-Only Mode'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showUnlockDialog}
|
|
<div class="warning-modal">
|
|
<div class="warning-content">
|
|
<h2>Unlock Private Key</h2>
|
|
<p style="margin-bottom: 1rem;">Enter your passphrase to decrypt and export your private key.</p>
|
|
|
|
{#if error}
|
|
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
|
{/if}
|
|
|
|
<div class="form-group">
|
|
<label for="unlock-passphrase">Passphrase</label>
|
|
<input
|
|
type="password"
|
|
id="unlock-passphrase"
|
|
bind:value={unlockPassphrase}
|
|
placeholder="Enter your key passphrase"
|
|
/>
|
|
</div>
|
|
|
|
<div class="warning-actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
on:click={() => { showUnlockDialog = false; unlockPassphrase = ''; }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
on:click={unlockAndExportKey}
|
|
disabled={!unlockPassphrase}
|
|
>
|
|
Unlock & Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\frontend\src\routes\[realm]\live\+page.svelte ###
|
|
|
|
<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;
|
|
position: relative;
|
|
}
|
|
|
|
.player-wrapper::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 4px;
|
|
background: linear-gradient(90deg,
|
|
var(--user-color, var(--primary)) 0%,
|
|
var(--user-color, var(--primary)) 50%,
|
|
rgba(255, 255, 255, 0.1) 100%
|
|
);
|
|
z-index: 1;
|
|
}
|
|
|
|
.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;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.stream-info-section::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: var(--user-color, var(--primary));
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.stream-header {
|
|
margin-bottom: 1.5rem;
|
|
padding-left: 1rem;
|
|
}
|
|
|
|
.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);
|
|
position: relative;
|
|
overflow: hidden;
|
|
border: 2px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
color: var(--white);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.streamer-avatar.has-color {
|
|
background: var(--user-color);
|
|
border-color: var(--user-color);
|
|
border-width: 3px;
|
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.streamer-avatar.has-color.with-image {
|
|
border-width: 3px;
|
|
}
|
|
|
|
.streamer-avatar img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.streamer-name {
|
|
font-weight: 600;
|
|
color: var(--white);
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* Color accent for chat/info sections */
|
|
.color-accent-bar {
|
|
height: 3px;
|
|
background: var(--user-color, var(--primary));
|
|
margin: 0 0 1rem 0;
|
|
border-radius: 2px;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* Pulse animation for live indicator */
|
|
@keyframes pulse {
|
|
0% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.6;
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.status-indicator.active::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: #ff0000;
|
|
border-radius: 50%;
|
|
margin-right: 0.5rem;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
</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" style="--user-color: {realm.colorCode || '#561D5E'}">
|
|
<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" style="--user-color: {realm.colorCode || '#561D5E'}">
|
|
<div class="stream-header">
|
|
<h1>{realm.name}</h1>
|
|
<div class="streamer-info">
|
|
<div
|
|
class="streamer-avatar"
|
|
class:has-color={realm.colorCode}
|
|
class:with-image={realm.avatarUrl}
|
|
style="--user-color: {realm.colorCode || '#561D5E'}"
|
|
>
|
|
{#if realm.avatarUrl}
|
|
<img src={realm.avatarUrl} alt={realm.username} />
|
|
{:else}
|
|
{realm.username?.charAt(0).toUpperCase() || '?'}
|
|
{/if}
|
|
</div>
|
|
<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">
|
|
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
|
|
<h3>Stream Stats</h3>
|
|
|
|
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
|
{#if stats.isLive}
|
|
Live
|
|
{:else}
|
|
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 && stats.codec !== 'N/A'}
|
|
<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}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\openresty\Dockerfile ###
|
|
|
|
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;"]
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\openresty\nginx.conf ###
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\openresty\lua\auth.lua ###
|
|
|
|
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))
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\openresty\lua\redis_helper.lua ###
|
|
|
|
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
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\openresty\lua\stream_monitor.lua ###
|
|
|
|
|
|
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)
|
|
|
|
|
|
### C:\Users\Administrator\Desktop\pub\realms.india\ovenmediaengine\Server.xml ###
|
|
|
|
<?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>
|
|
|
|
|