This commit is contained in:
parent
a0e6d40679
commit
954755fbc3
19 changed files with 356 additions and 321 deletions
24
backend/src/common/CryptoUtils.h
Normal file
24
backend/src/common/CryptoUtils.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
namespace crypto_utils {
|
||||||
|
|
||||||
|
// Convert raw bytes to lowercase hex string
|
||||||
|
inline std::string bytesToHex(const unsigned char* data, size_t length) {
|
||||||
|
std::stringstream ss;
|
||||||
|
for (size_t i = 0; i < length; ++i) {
|
||||||
|
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(data[i]);
|
||||||
|
}
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overload for std::array
|
||||||
|
template<size_t N>
|
||||||
|
inline std::string bytesToHex(const std::array<unsigned char, N>& data) {
|
||||||
|
return bytesToHex(data.data(), N);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crypto_utils
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <trantor/utils/Logger.h>
|
#include <trantor/utils/Logger.h>
|
||||||
|
#include "CryptoUtils.h"
|
||||||
|
|
||||||
// Generate cryptographically secure random hex filename with extension
|
// Generate cryptographically secure random hex filename with extension
|
||||||
// Uses /dev/urandom for secure randomness instead of std::mt19937
|
// Uses /dev/urandom for secure randomness instead of std::mt19937
|
||||||
|
|
@ -22,22 +23,17 @@ inline std::string generateRandomFilename(const std::string& ext) {
|
||||||
ssize_t bytesRead = read(fd, bytes.data(), bytes.size());
|
ssize_t bytesRead = read(fd, bytes.data(), bytes.size());
|
||||||
close(fd);
|
close(fd);
|
||||||
if (bytesRead == static_cast<ssize_t>(bytes.size())) {
|
if (bytesRead == static_cast<ssize_t>(bytes.size())) {
|
||||||
std::stringstream ss;
|
return crypto_utils::bytesToHex(bytes) + "." + ext;
|
||||||
for (unsigned char b : bytes) {
|
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(b);
|
|
||||||
}
|
|
||||||
return ss.str() + "." + ext;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to std::random_device if /dev/urandom fails
|
// Fallback to std::random_device if /dev/urandom fails
|
||||||
// (shouldn't happen on Linux, but provides resilience)
|
// (shouldn't happen on Linux, but provides resilience)
|
||||||
std::random_device rd;
|
std::random_device rd;
|
||||||
std::stringstream ss;
|
for (size_t i = 0; i < bytes.size(); ++i) {
|
||||||
for (int i = 0; i < 16; ++i) {
|
bytes[i] = static_cast<unsigned char>(rd() & 0xFF);
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << (rd() & 0xFF);
|
|
||||||
}
|
}
|
||||||
return ss.str() + "." + ext;
|
return crypto_utils::bytesToHex(bytes) + "." + ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically create a file with exclusive access (O_CREAT | O_EXCL)
|
// Atomically create a file with exclusive access (O_CREAT | O_EXCL)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,43 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <drogon/HttpResponse.h>
|
#include <drogon/HttpResponse.h>
|
||||||
|
#include <drogon/HttpRequest.h>
|
||||||
#include <json/json.h>
|
#include <json/json.h>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
using namespace drogon;
|
using namespace drogon;
|
||||||
|
|
||||||
|
// Pagination helper
|
||||||
|
struct PaginationParams {
|
||||||
|
int page;
|
||||||
|
int limit;
|
||||||
|
int offset;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline PaginationParams parsePagination(const HttpRequestPtr& req, int defaultLimit = 20, int maxLimit = 50) {
|
||||||
|
PaginationParams params;
|
||||||
|
params.page = 1;
|
||||||
|
params.limit = defaultLimit;
|
||||||
|
|
||||||
|
auto pageParam = req->getParameter("page");
|
||||||
|
auto limitParam = req->getParameter("limit");
|
||||||
|
|
||||||
|
if (!pageParam.empty()) {
|
||||||
|
try { params.page = std::max(1, std::stoi(pageParam)); } catch (...) {}
|
||||||
|
}
|
||||||
|
if (!limitParam.empty()) {
|
||||||
|
try { params.limit = std::min(std::stoi(limitParam), maxLimit); } catch (...) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
params.offset = (params.page - 1) * params.limit;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTMP URL validation
|
||||||
|
inline bool isValidRtmpUrl(const std::string& url) {
|
||||||
|
return url.length() >= 7 &&
|
||||||
|
(url.substr(0, 7) == "rtmp://" || url.substr(0, 8) == "rtmps://");
|
||||||
|
}
|
||||||
|
|
||||||
inline HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
inline HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||||
r->setStatusCode(c);
|
r->setStatusCode(c);
|
||||||
|
|
|
||||||
|
|
@ -113,20 +113,7 @@ namespace {
|
||||||
|
|
||||||
void AudioController::getAllAudio(const HttpRequestPtr &req,
|
void AudioController::getAllAudio(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
int page = 1;
|
auto pagination = parsePagination(req, 20, 50);
|
||||||
int limit = 20;
|
|
||||||
|
|
||||||
auto pageParam = req->getParameter("page");
|
|
||||||
auto limitParam = req->getParameter("limit");
|
|
||||||
|
|
||||||
if (!pageParam.empty()) {
|
|
||||||
try { page = std::stoi(pageParam); } catch (...) {}
|
|
||||||
}
|
|
||||||
if (!limitParam.empty()) {
|
|
||||||
try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
||||||
|
|
@ -139,7 +126,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
|
||||||
"WHERE a.is_public = true AND a.status = 'ready' "
|
"WHERE a.is_public = true AND a.status = 'ready' "
|
||||||
"ORDER BY a.created_at DESC "
|
"ORDER BY a.created_at DESC "
|
||||||
"LIMIT $1 OFFSET $2"
|
"LIMIT $1 OFFSET $2"
|
||||||
<< static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
<< static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
|
||||||
>> [callback](const Result& r) {
|
>> [callback](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
@ -287,20 +274,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional pagination (backwards compatible - no params = all results, but capped at 500)
|
// Optional pagination (backwards compatible - no params = all results, but capped at 500)
|
||||||
int page = 1;
|
auto pagination = parsePagination(req, 500, 500);
|
||||||
int limit = 500; // Default max to prevent huge responses
|
|
||||||
|
|
||||||
auto pageParam = req->getParameter("page");
|
|
||||||
auto limitParam = req->getParameter("limit");
|
|
||||||
|
|
||||||
if (!pageParam.empty()) {
|
|
||||||
try { page = std::max(1, std::stoi(pageParam)); } catch (...) {}
|
|
||||||
}
|
|
||||||
if (!limitParam.empty()) {
|
|
||||||
try { limit = std::min(std::max(1, std::stoi(limitParam)), 500); } catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
||||||
|
|
@ -309,7 +283,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
|
||||||
"JOIN users u ON r.user_id = u.id "
|
"JOIN users u ON r.user_id = u.id "
|
||||||
"WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'audio'"
|
"WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'audio'"
|
||||||
<< id
|
<< id
|
||||||
>> [callback, dbClient, id, limit, offset](const Result& realmResult) {
|
>> [callback, dbClient, id, pagination](const Result& realmResult) {
|
||||||
if (realmResult.empty()) {
|
if (realmResult.empty()) {
|
||||||
callback(jsonError("Audio realm not found", k404NotFound));
|
callback(jsonError("Audio realm not found", k404NotFound));
|
||||||
return;
|
return;
|
||||||
|
|
@ -321,7 +295,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
|
||||||
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
||||||
"ORDER BY a.created_at DESC "
|
"ORDER BY a.created_at DESC "
|
||||||
"LIMIT $2 OFFSET $3"
|
"LIMIT $2 OFFSET $3"
|
||||||
<< id << static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
<< id << static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
|
||||||
>> [callback, realmResult](const Result& r) {
|
>> [callback, realmResult](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
@ -369,20 +343,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional pagination
|
// Optional pagination
|
||||||
int page = 1;
|
auto pagination = parsePagination(req, 500, 500);
|
||||||
int limit = 500;
|
|
||||||
|
|
||||||
auto pageParam = req->getParameter("page");
|
|
||||||
auto limitParam = req->getParameter("limit");
|
|
||||||
|
|
||||||
if (!pageParam.empty()) {
|
|
||||||
try { page = std::max(1, std::stoi(pageParam)); } catch (...) {}
|
|
||||||
}
|
|
||||||
if (!limitParam.empty()) {
|
|
||||||
try { limit = std::min(std::max(1, std::stoi(limitParam)), 500); } catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
||||||
|
|
@ -391,7 +352,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
|
||||||
"JOIN users u ON r.user_id = u.id "
|
"JOIN users u ON r.user_id = u.id "
|
||||||
"WHERE LOWER(r.name) = LOWER($1) AND r.is_active = true AND r.realm_type = 'audio'"
|
"WHERE LOWER(r.name) = LOWER($1) AND r.is_active = true AND r.realm_type = 'audio'"
|
||||||
<< realmName
|
<< realmName
|
||||||
>> [callback, dbClient, limit, offset](const Result& realmResult) {
|
>> [callback, dbClient, pagination](const Result& realmResult) {
|
||||||
if (realmResult.empty()) {
|
if (realmResult.empty()) {
|
||||||
callback(jsonError("Audio realm not found", k404NotFound));
|
callback(jsonError("Audio realm not found", k404NotFound));
|
||||||
return;
|
return;
|
||||||
|
|
@ -405,7 +366,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
|
||||||
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
||||||
"ORDER BY a.created_at DESC "
|
"ORDER BY a.created_at DESC "
|
||||||
"LIMIT $2 OFFSET $3"
|
"LIMIT $2 OFFSET $3"
|
||||||
<< realmId << static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
<< realmId << static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
|
||||||
>> [callback, realmResult](const Result& r) {
|
>> [callback, realmResult](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,7 @@ using namespace drogon::orm;
|
||||||
|
|
||||||
void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
int page = 1;
|
auto pagination = parsePagination(req, 20, 50);
|
||||||
int limit = 20;
|
|
||||||
|
|
||||||
auto pageParam = req->getParameter("page");
|
|
||||||
auto limitParam = req->getParameter("limit");
|
|
||||||
|
|
||||||
if (!pageParam.empty()) {
|
|
||||||
try { page = std::stoi(pageParam); } catch (...) {}
|
|
||||||
}
|
|
||||||
if (!limitParam.empty()) {
|
|
||||||
try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||||
|
|
@ -48,7 +35,7 @@ void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
||||||
"WHERE e.is_public = true AND e.status = 'ready' "
|
"WHERE e.is_public = true AND e.status = 'ready' "
|
||||||
"ORDER BY e.created_at DESC "
|
"ORDER BY e.created_at DESC "
|
||||||
"LIMIT $1 OFFSET $2"
|
"LIMIT $1 OFFSET $2"
|
||||||
<< static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
<< static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
|
||||||
>> [callback](const Result& r) {
|
>> [callback](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include "../services/CensorService.h"
|
#include "../services/CensorService.h"
|
||||||
#include "../common/HttpHelpers.h"
|
#include "../common/HttpHelpers.h"
|
||||||
#include "../common/AuthHelpers.h"
|
#include "../common/AuthHelpers.h"
|
||||||
|
#include "../common/CryptoUtils.h"
|
||||||
#include <drogon/utils/Utilities.h>
|
#include <drogon/utils/Utilities.h>
|
||||||
#include <drogon/Cookie.h>
|
#include <drogon/Cookie.h>
|
||||||
#include <drogon/MultiPart.h>
|
#include <drogon/MultiPart.h>
|
||||||
|
|
@ -28,12 +29,7 @@ namespace {
|
||||||
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
||||||
throw std::runtime_error("Failed to generate secure stream key");
|
throw std::runtime_error("Failed to generate secure stream key");
|
||||||
}
|
}
|
||||||
|
return crypto_utils::bytesToHex(bytes, sizeof(bytes));
|
||||||
std::stringstream ss;
|
|
||||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(bytes[i]);
|
|
||||||
}
|
|
||||||
return ss.str();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool validateRealmName(const std::string& name) {
|
bool validateRealmName(const std::string& name) {
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ void RestreamController::addDestination(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate RTMP URL format
|
// Validate RTMP URL format
|
||||||
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
|
if (!isValidRtmpUrl(rtmpUrl)) {
|
||||||
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +208,7 @@ void RestreamController::updateDestination(const HttpRequestPtr &req,
|
||||||
callback(jsonError("RTMP URL must be 1-500 characters"));
|
callback(jsonError("RTMP URL must be 1-500 characters"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
|
if (!isValidRtmpUrl(rtmpUrl)) {
|
||||||
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include "../common/AuthHelpers.h"
|
#include "../common/AuthHelpers.h"
|
||||||
#include "../common/FileUtils.h"
|
#include "../common/FileUtils.h"
|
||||||
#include "../common/FileValidation.h"
|
#include "../common/FileValidation.h"
|
||||||
|
#include "../common/CryptoUtils.h"
|
||||||
#include <drogon/MultiPart.h>
|
#include <drogon/MultiPart.h>
|
||||||
#include <drogon/Cookie.h>
|
#include <drogon/Cookie.h>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
|
@ -23,52 +24,38 @@ namespace {
|
||||||
std::string hashApiKey(const std::string& apiKey) {
|
std::string hashApiKey(const std::string& apiKey) {
|
||||||
unsigned char hash[SHA256_DIGEST_LENGTH];
|
unsigned char hash[SHA256_DIGEST_LENGTH];
|
||||||
SHA256(reinterpret_cast<const unsigned char*>(apiKey.c_str()), apiKey.length(), hash);
|
SHA256(reinterpret_cast<const unsigned char*>(apiKey.c_str()), apiKey.length(), hash);
|
||||||
|
return crypto_utils::bytesToHex(hash, SHA256_DIGEST_LENGTH);
|
||||||
std::stringstream ss;
|
|
||||||
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
|
|
||||||
ss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(hash[i]);
|
|
||||||
}
|
|
||||||
return ss.str();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to set httpOnly access token cookie (2.5 hours to match JWT expiry)
|
// Parameterized cookie helper
|
||||||
|
void setCookie(const HttpResponsePtr& resp, const std::string& name,
|
||||||
|
const std::string& value, int maxAge) {
|
||||||
|
Cookie cookie(name, value);
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
cookie.setSecure(false); // Set to true in production with HTTPS
|
||||||
|
cookie.setMaxAge(maxAge);
|
||||||
|
cookie.setSameSite(Cookie::SameSite::kLax);
|
||||||
|
resp->addCookie(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int AUTH_COOKIE_MAX_AGE = 9000; // 2.5 hours
|
||||||
|
constexpr int REFRESH_COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days
|
||||||
|
|
||||||
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||||
Cookie authCookie("auth_token", token);
|
setCookie(resp, "auth_token", token, AUTH_COOKIE_MAX_AGE);
|
||||||
authCookie.setPath("/");
|
|
||||||
authCookie.setHttpOnly(true);
|
|
||||||
authCookie.setSecure(false); // Set to true in production with HTTPS
|
|
||||||
authCookie.setMaxAge(9000); // 2.5 hours (150 minutes, matches JWT expiry)
|
|
||||||
authCookie.setSameSite(Cookie::SameSite::kLax);
|
|
||||||
resp->addCookie(authCookie);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to set httpOnly refresh token cookie (long-lived: 90 days)
|
|
||||||
void setRefreshCookie(const HttpResponsePtr& resp, const std::string& token) {
|
void setRefreshCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||||
Cookie refreshCookie("refresh_token", token);
|
setCookie(resp, "refresh_token", token, REFRESH_COOKIE_MAX_AGE);
|
||||||
refreshCookie.setPath("/");
|
|
||||||
refreshCookie.setHttpOnly(true);
|
|
||||||
refreshCookie.setSecure(false); // Set to true in production with HTTPS
|
|
||||||
refreshCookie.setMaxAge(90 * 24 * 60 * 60); // 90 days
|
|
||||||
refreshCookie.setSameSite(Cookie::SameSite::kLax);
|
|
||||||
resp->addCookie(refreshCookie);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to clear auth cookie
|
|
||||||
void clearAuthCookie(const HttpResponsePtr& resp) {
|
void clearAuthCookie(const HttpResponsePtr& resp) {
|
||||||
Cookie authCookie("auth_token", "");
|
setCookie(resp, "auth_token", "", 0);
|
||||||
authCookie.setPath("/");
|
|
||||||
authCookie.setHttpOnly(true);
|
|
||||||
authCookie.setMaxAge(0); // Expire immediately
|
|
||||||
resp->addCookie(authCookie);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to clear refresh cookie
|
|
||||||
void clearRefreshCookie(const HttpResponsePtr& resp) {
|
void clearRefreshCookie(const HttpResponsePtr& resp) {
|
||||||
Cookie refreshCookie("refresh_token", "");
|
setCookie(resp, "refresh_token", "", 0);
|
||||||
refreshCookie.setPath("/");
|
|
||||||
refreshCookie.setHttpOnly(true);
|
|
||||||
refreshCookie.setMaxAge(0); // Expire immediately
|
|
||||||
resp->addCookie(refreshCookie);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1307,7 +1294,7 @@ void UserController::getBotApiKeys(const HttpRequestPtr &req,
|
||||||
Json::Value key;
|
Json::Value key;
|
||||||
key["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
key["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||||
key["name"] = row["name"].as<std::string>();
|
key["name"] = row["name"].as<std::string>();
|
||||||
key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as<std::string>();
|
key["scopes"] = row["scopes"].isNull() ? "chat:rw" : row["scopes"].as<std::string>();
|
||||||
key["createdAt"] = row["created_at"].as<std::string>();
|
key["createdAt"] = row["created_at"].as<std::string>();
|
||||||
key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as<std::string>();
|
key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as<std::string>();
|
||||||
key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as<std::string>();
|
key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as<std::string>();
|
||||||
|
|
@ -1386,12 +1373,7 @@ void UserController::createBotApiKey(const HttpRequestPtr &req,
|
||||||
callback(jsonError("Failed to generate secure API key", k500InternalServerError));
|
callback(jsonError("Failed to generate secure API key", k500InternalServerError));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::stringstream ss;
|
std::string apiKey = "key_" + crypto_utils::bytesToHex(bytes, sizeof(bytes));
|
||||||
ss << "key_";
|
|
||||||
for (int i = 0; i < 32; i++) {
|
|
||||||
ss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(bytes[i]);
|
|
||||||
}
|
|
||||||
std::string apiKey = ss.str();
|
|
||||||
|
|
||||||
// SECURITY FIX: Hash the API key before storing (plaintext never stored)
|
// SECURITY FIX: Hash the API key before storing (plaintext never stored)
|
||||||
std::string apiKeyHash = hashApiKey(apiKey);
|
std::string apiKeyHash = hashApiKey(apiKey);
|
||||||
|
|
|
||||||
|
|
@ -269,21 +269,7 @@ namespace {
|
||||||
|
|
||||||
void VideoController::getAllVideos(const HttpRequestPtr &req,
|
void VideoController::getAllVideos(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
// Get pagination parameters
|
auto pagination = parsePagination(req, 20, 50);
|
||||||
int page = 1;
|
|
||||||
int limit = 20;
|
|
||||||
|
|
||||||
auto pageParam = req->getParameter("page");
|
|
||||||
auto limitParam = req->getParameter("limit");
|
|
||||||
|
|
||||||
if (!pageParam.empty()) {
|
|
||||||
try { page = std::stoi(pageParam); } catch (...) {}
|
|
||||||
}
|
|
||||||
if (!limitParam.empty()) {
|
|
||||||
try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||||
|
|
@ -296,7 +282,7 @@ void VideoController::getAllVideos(const HttpRequestPtr &req,
|
||||||
"WHERE v.is_public = true AND v.status = 'ready' "
|
"WHERE v.is_public = true AND v.status = 'ready' "
|
||||||
"ORDER BY v.created_at DESC "
|
"ORDER BY v.created_at DESC "
|
||||||
"LIMIT $1 OFFSET $2"
|
"LIMIT $1 OFFSET $2"
|
||||||
<< static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
<< static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
|
||||||
>> [callback](const Result& r) {
|
>> [callback](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "DatabaseService.h"
|
#include "DatabaseService.h"
|
||||||
#include "../services/RedisHelper.h"
|
#include "../services/RedisHelper.h"
|
||||||
|
#include "../common/CryptoUtils.h"
|
||||||
#include <drogon/orm/DbClient.h>
|
#include <drogon/orm/DbClient.h>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
|
|
@ -27,12 +28,7 @@ namespace {
|
||||||
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
||||||
throw std::runtime_error("Failed to generate secure stream key");
|
throw std::runtime_error("Failed to generate secure stream key");
|
||||||
}
|
}
|
||||||
|
return crypto_utils::bytesToHex(bytes, sizeof(bytes));
|
||||||
std::stringstream ss;
|
|
||||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(bytes[i]);
|
|
||||||
}
|
|
||||||
return ss.str();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
chat-service/src/common/CryptoUtils.h
Normal file
24
chat-service/src/common/CryptoUtils.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
namespace crypto_utils {
|
||||||
|
|
||||||
|
// Convert raw bytes to lowercase hex string
|
||||||
|
inline std::string bytesToHex(const unsigned char* data, size_t length) {
|
||||||
|
std::stringstream ss;
|
||||||
|
for (size_t i = 0; i < length; ++i) {
|
||||||
|
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(data[i]);
|
||||||
|
}
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overload for std::array
|
||||||
|
template<size_t N>
|
||||||
|
inline std::string bytesToHex(const std::array<unsigned char, N>& data) {
|
||||||
|
return bytesToHex(data.data(), N);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crypto_utils
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include "../services/AuthService.h"
|
#include "../services/AuthService.h"
|
||||||
#include "../services/ModerationService.h"
|
#include "../services/ModerationService.h"
|
||||||
#include "../services/RedisMessageStore.h"
|
#include "../services/RedisMessageStore.h"
|
||||||
|
#include "../common/CryptoUtils.h"
|
||||||
#include <drogon/HttpClient.h>
|
#include <drogon/HttpClient.h>
|
||||||
#include <json/json.h>
|
#include <json/json.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
@ -117,6 +118,28 @@ void ChatWebSocketController::broadcastParticipantLeft(const std::string& realmI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate browser fingerprint from request headers
|
||||||
|
std::string ChatWebSocketController::generateFingerprint(const HttpRequestPtr& req,
|
||||||
|
const WebSocketConnectionPtr& wsConnPtr) {
|
||||||
|
std::string fingerprint = req->getHeader("X-Server-Fingerprint");
|
||||||
|
if (!fingerprint.empty()) {
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string clientIp = req->getHeader("X-Real-IP");
|
||||||
|
if (clientIp.empty()) {
|
||||||
|
clientIp = wsConnPtr->peerAddr().toIp();
|
||||||
|
}
|
||||||
|
std::string userAgent = req->getHeader("User-Agent");
|
||||||
|
std::string acceptLang = req->getHeader("Accept-Language");
|
||||||
|
|
||||||
|
std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang;
|
||||||
|
|
||||||
|
unsigned char hash[SHA256_DIGEST_LENGTH];
|
||||||
|
SHA256(reinterpret_cast<const unsigned char*>(toHash.c_str()), toHash.length(), hash);
|
||||||
|
return crypto_utils::bytesToHex(hash, SHA256_DIGEST_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req,
|
void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req,
|
||||||
const WebSocketConnectionPtr& wsConnPtr) {
|
const WebSocketConnectionPtr& wsConnPtr) {
|
||||||
LOG_INFO << "New WebSocket connection from " << wsConnPtr->peerAddr().toIpPort();
|
LOG_INFO << "New WebSocket connection from " << wsConnPtr->peerAddr().toIpPort();
|
||||||
|
|
@ -188,26 +211,7 @@ void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req,
|
||||||
LOG_WARN << "User " << info.username << " has pending uberban - capturing fingerprint and applying ban";
|
LOG_WARN << "User " << info.username << " has pending uberban - capturing fingerprint and applying ban";
|
||||||
|
|
||||||
// Generate fingerprint NOW (only for pending_uberban users)
|
// Generate fingerprint NOW (only for pending_uberban users)
|
||||||
std::string fingerprint = req->getHeader("X-Server-Fingerprint");
|
std::string fingerprint = generateFingerprint(req, wsConnPtr);
|
||||||
if (fingerprint.empty()) {
|
|
||||||
std::string clientIp = req->getHeader("X-Real-IP");
|
|
||||||
if (clientIp.empty()) {
|
|
||||||
clientIp = wsConnPtr->peerAddr().toIp();
|
|
||||||
}
|
|
||||||
std::string userAgent = req->getHeader("User-Agent");
|
|
||||||
std::string acceptLang = req->getHeader("Accept-Language");
|
|
||||||
|
|
||||||
std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang;
|
|
||||||
|
|
||||||
unsigned char hash[SHA256_DIGEST_LENGTH];
|
|
||||||
SHA256(reinterpret_cast<const unsigned char*>(toHash.c_str()), toHash.length(), hash);
|
|
||||||
|
|
||||||
std::stringstream ss;
|
|
||||||
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
|
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(hash[i]);
|
|
||||||
}
|
|
||||||
fingerprint = ss.str();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add fingerprint to banned set
|
// Add fingerprint to banned set
|
||||||
redis.addFingerprintBan(fingerprint);
|
redis.addFingerprintBan(fingerprint);
|
||||||
|
|
@ -248,32 +252,12 @@ void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate fingerprint for guests (always needed for guest moderation)
|
// Generate fingerprint for guests (always needed for guest moderation)
|
||||||
std::string fingerprint = req->getHeader("X-Server-Fingerprint");
|
info.fingerprint = generateFingerprint(req, wsConnPtr);
|
||||||
if (fingerprint.empty()) {
|
|
||||||
std::string clientIp = req->getHeader("X-Real-IP");
|
|
||||||
if (clientIp.empty()) {
|
|
||||||
clientIp = wsConnPtr->peerAddr().toIp();
|
|
||||||
}
|
|
||||||
std::string userAgent = req->getHeader("User-Agent");
|
|
||||||
std::string acceptLang = req->getHeader("Accept-Language");
|
|
||||||
|
|
||||||
std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang;
|
|
||||||
|
|
||||||
unsigned char hash[SHA256_DIGEST_LENGTH];
|
|
||||||
SHA256(reinterpret_cast<const unsigned char*>(toHash.c_str()), toHash.length(), hash);
|
|
||||||
|
|
||||||
std::stringstream ss;
|
|
||||||
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
|
|
||||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(hash[i]);
|
|
||||||
}
|
|
||||||
fingerprint = ss.str();
|
|
||||||
}
|
|
||||||
info.fingerprint = fingerprint;
|
|
||||||
|
|
||||||
// Check fingerprint ban for guests
|
// Check fingerprint ban for guests
|
||||||
auto& redis = services::RedisMessageStore::getInstance();
|
auto& redis = services::RedisMessageStore::getInstance();
|
||||||
if (redis.isFingerprintBanned(fingerprint)) {
|
if (redis.isFingerprintBanned(info.fingerprint)) {
|
||||||
LOG_WARN << "Guest connection rejected - fingerprint banned: " << fingerprint.substr(0, 8) << "...";
|
LOG_WARN << "Guest connection rejected - fingerprint banned: " << info.fingerprint.substr(0, 8) << "...";
|
||||||
Json::Value error;
|
Json::Value error;
|
||||||
error["type"] = "error";
|
error["type"] = "error";
|
||||||
error["error"] = "You have been banned from chat.";
|
error["error"] = "You have been banned from chat.";
|
||||||
|
|
@ -550,7 +534,8 @@ void ChatWebSocketController::handleChatMessage(const WebSocketConnectionPtr& ws
|
||||||
auto result = chatService.sendMessage(
|
auto result = chatService.sendMessage(
|
||||||
currentInfo.realmId, currentInfo.userId, currentInfo.username, userColor, avatarUrl, content,
|
currentInfo.realmId, currentInfo.userId, currentInfo.username, userColor, avatarUrl, content,
|
||||||
currentInfo.isGuest, currentInfo.isModerator, currentInfo.isStreamer, message, selfDestructSeconds,
|
currentInfo.isGuest, currentInfo.isModerator, currentInfo.isStreamer, message, selfDestructSeconds,
|
||||||
currentInfo.isApiKeyConnection // SECURITY FIX: Pass bot flag for rate limiting
|
currentInfo.isApiKeyConnection, // SECURITY FIX: Pass bot flag for rate limiting
|
||||||
|
currentInfo.apiKeyId // SECURITY FIX: Rate limit per API key, not per user
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == services::SendMessageResult::SUCCESS) {
|
if (result == services::SendMessageResult::SUCCESS) {
|
||||||
|
|
@ -1408,6 +1393,42 @@ void ChatWebSocketController::checkGuestTimeouts() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatWebSocketController::checkPendingConnectionTimeouts() {
|
||||||
|
constexpr int PENDING_TIMEOUT_SECONDS = 15;
|
||||||
|
auto now = std::chrono::steady_clock::now();
|
||||||
|
std::vector<WebSocketConnectionPtr> toDisconnect;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||||
|
for (const auto& [conn, startTime] : pendingConnections_) {
|
||||||
|
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
now - startTime
|
||||||
|
).count();
|
||||||
|
|
||||||
|
if (elapsed >= PENDING_TIMEOUT_SECONDS) {
|
||||||
|
toDisconnect.push_back(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove timed-out connections from pending map
|
||||||
|
for (const auto& conn : toDisconnect) {
|
||||||
|
pendingConnections_.erase(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect outside the lock to avoid holding it while sending messages
|
||||||
|
for (const auto& conn : toDisconnect) {
|
||||||
|
Json::Value msg;
|
||||||
|
msg["type"] = "error";
|
||||||
|
msg["error"] = "API key validation timed out. Please reconnect and try again.";
|
||||||
|
try {
|
||||||
|
conn->send(Json::writeString(Json::StreamWriterBuilder(), msg));
|
||||||
|
} catch (...) {}
|
||||||
|
conn->shutdown(CloseCode::kViolation);
|
||||||
|
LOG_WARN << "Pending bot connection timed out after " << PENDING_TIMEOUT_SECONDS << " seconds - disconnecting";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Debug: Static initializer to verify this file is loaded
|
// Debug: Static initializer to verify this file is loaded
|
||||||
static struct ChatWebSocketControllerLoader {
|
static struct ChatWebSocketControllerLoader {
|
||||||
ChatWebSocketControllerLoader() {
|
ChatWebSocketControllerLoader() {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ private:
|
||||||
static void broadcastParticipantJoined(const std::string& realmId, const ConnectionInfo& joinedUser);
|
static void broadcastParticipantJoined(const std::string& realmId, const ConnectionInfo& joinedUser);
|
||||||
static void broadcastParticipantLeft(const std::string& realmId, const std::string& userId, const std::string& username);
|
static void broadcastParticipantLeft(const std::string& realmId, const std::string& userId, const std::string& username);
|
||||||
|
|
||||||
|
// Generate browser fingerprint from request headers
|
||||||
|
static std::string generateFingerprint(const HttpRequestPtr& req, const WebSocketConnectionPtr& wsConnPtr);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Internal API: Try to uberban a user by ID (used by backend admin endpoint)
|
// Internal API: Try to uberban a user by ID (used by backend admin endpoint)
|
||||||
// Returns: fingerprint if user was connected and banned, empty string if not connected
|
// Returns: fingerprint if user was connected and banned, empty string if not connected
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@ int main() {
|
||||||
});
|
});
|
||||||
LOG_INFO << "Guest session timeout checker registered (45-123 minute random timeout)";
|
LOG_INFO << "Guest session timeout checker registered (45-123 minute random timeout)";
|
||||||
|
|
||||||
|
// Register pending bot connection timeout checker (runs every 5 seconds)
|
||||||
|
app().getLoop()->runEvery(5.0, []() {
|
||||||
|
ChatWebSocketController::checkPendingConnectionTimeouts();
|
||||||
|
});
|
||||||
|
LOG_INFO << "Pending bot connection timeout checker registered (15 second timeout)";
|
||||||
|
|
||||||
// Schedule sticker fetch (must be done here, after event loop is set up)
|
// Schedule sticker fetch (must be done here, after event loop is set up)
|
||||||
stickerService.scheduleFetch();
|
stickerService.scheduleFetch();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,14 @@ SendMessageResult ChatService::sendMessage(const std::string& realmId,
|
||||||
bool isStreamer,
|
bool isStreamer,
|
||||||
models::ChatMessage& outMessage,
|
models::ChatMessage& outMessage,
|
||||||
int selfDestructSeconds,
|
int selfDestructSeconds,
|
||||||
bool isBot) {
|
bool isBot,
|
||||||
|
int64_t botApiKeyId) {
|
||||||
auto& redis = RedisMessageStore::getInstance();
|
auto& redis = RedisMessageStore::getInstance();
|
||||||
auto& modService = ModerationService::getInstance();
|
auto& modService = ModerationService::getInstance();
|
||||||
|
|
||||||
// SECURITY FIX: Bot rate limiting (1 message per second)
|
// SECURITY FIX: Bot rate limiting (1 message per second per API key)
|
||||||
if (isBot) {
|
if (isBot && botApiKeyId > 0) {
|
||||||
if (!canBotSendMessage(userId)) {
|
if (!canBotSendMessage(botApiKeyId)) {
|
||||||
return SendMessageResult::BOT_RATE_LIMITED;
|
return SendMessageResult::BOT_RATE_LIMITED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,22 +277,22 @@ void ChatService::cleanupMessages() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Bot rate limiting implementation
|
// SECURITY FIX: Bot rate limiting implementation (per API key, not per user)
|
||||||
bool ChatService::canBotSendMessage(const std::string& botUserId) {
|
bool ChatService::canBotSendMessage(int64_t apiKeyId) {
|
||||||
std::lock_guard<std::mutex> lock(botRateLimitMutex_);
|
std::lock_guard<std::mutex> lock(botRateLimitMutex_);
|
||||||
|
|
||||||
auto now = std::chrono::steady_clock::now();
|
auto now = std::chrono::steady_clock::now();
|
||||||
auto it = botLastMessage_.find(botUserId);
|
auto it = botLastMessage_.find(apiKeyId);
|
||||||
|
|
||||||
if (it != botLastMessage_.end()) {
|
if (it != botLastMessage_.end()) {
|
||||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - it->second).count();
|
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - it->second).count();
|
||||||
if (elapsed < BOT_RATE_LIMIT_MS) {
|
if (elapsed < BOT_RATE_LIMIT_MS) {
|
||||||
LOG_DEBUG << "Bot " << botUserId << " rate limited (only " << elapsed << "ms since last message)";
|
LOG_DEBUG << "Bot API key " << apiKeyId << " rate limited (only " << elapsed << "ms since last message)";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
botLastMessage_[botUserId] = now;
|
botLastMessage_[apiKeyId] = now;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ public:
|
||||||
bool isStreamer,
|
bool isStreamer,
|
||||||
models::ChatMessage& outMessage,
|
models::ChatMessage& outMessage,
|
||||||
int selfDestructSeconds = 0,
|
int selfDestructSeconds = 0,
|
||||||
bool isBot = false); // SECURITY FIX: Bot rate limiting
|
bool isBot = false,
|
||||||
|
int64_t botApiKeyId = 0); // SECURITY FIX: Bot rate limiting per API key
|
||||||
|
|
||||||
// Schedule a message for self-destruction
|
// Schedule a message for self-destruction
|
||||||
void scheduleSelfDestruct(const std::string& realmId, const std::string& messageId, int delaySeconds);
|
void scheduleSelfDestruct(const std::string& realmId, const std::string& messageId, int delaySeconds);
|
||||||
|
|
@ -73,11 +74,11 @@ private:
|
||||||
bool isContentValid(const std::string& content);
|
bool isContentValid(const std::string& content);
|
||||||
void cleanupMessages();
|
void cleanupMessages();
|
||||||
|
|
||||||
// SECURITY FIX: Bot rate limiting (1 message per second per bot)
|
// SECURITY FIX: Bot rate limiting (1 message per second per API key)
|
||||||
static constexpr int BOT_RATE_LIMIT_MS = 1000; // 1 second between messages
|
static constexpr int BOT_RATE_LIMIT_MS = 1000; // 1 second between messages
|
||||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> botLastMessage_;
|
std::unordered_map<int64_t, std::chrono::steady_clock::time_point> botLastMessage_;
|
||||||
std::mutex botRateLimitMutex_;
|
std::mutex botRateLimitMutex_;
|
||||||
bool canBotSendMessage(const std::string& botUserId);
|
bool canBotSendMessage(int64_t apiKeyId);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace services
|
} // namespace services
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,6 @@ button:disabled {
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
background: #000;
|
background: #000;
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,25 @@
|
||||||
let canvas;
|
let canvas;
|
||||||
let ctx;
|
let ctx;
|
||||||
let animationId;
|
let animationId;
|
||||||
let particles = [];
|
|
||||||
let crystal = new Set(); // Stored as "x,y" strings for O(1) lookup
|
|
||||||
let width, height;
|
let width, height;
|
||||||
let centerX, centerY;
|
|
||||||
let hue = 0;
|
let hue = 0;
|
||||||
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
||||||
let shatterParticles = [];
|
|
||||||
let phaseStartTime = 0;
|
let phaseStartTime = 0;
|
||||||
|
let branches = []; // Active growing branch tips
|
||||||
|
let crystalPoints = []; // All drawn points for shatter effect
|
||||||
|
let shatterParticles = [];
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
particleCount: 500, // Active random walkers
|
seedCount: 5, // Number of initial seed points
|
||||||
particleSpeed: 3, // Movement speed
|
branchSpeed: 2, // Pixels per frame
|
||||||
stickDistance: 2, // Distance to attach to crystal
|
maxBranches: 800, // Max simultaneous branch tips
|
||||||
maxCrystalSize: 8000, // Max crystal points before shatter
|
branchChance: 0.03, // Chance to spawn new branch per frame
|
||||||
hueShiftSpeed: 0.3, // Color cycling speed
|
turnAngle: 0.3, // Max random turn per frame (radians)
|
||||||
shatterDuration: 2000, // Milliseconds for shatter effect
|
hueShiftSpeed: 0.2, // Color cycling speed
|
||||||
dissolveDuration: 2000 // Milliseconds for dissolve effect
|
maxPoints: 15000, // Max crystal points before shatter
|
||||||
|
shatterDuration: 2500, // Milliseconds for shatter effect
|
||||||
|
dissolveDuration: 2000, // Milliseconds for dissolve effect
|
||||||
|
lineWidth: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
function initCanvas() {
|
function initCanvas() {
|
||||||
|
|
@ -31,47 +33,40 @@
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
centerX = Math.floor(width / 2);
|
|
||||||
centerY = Math.floor(height / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCrystal() {
|
function initCrystal() {
|
||||||
crystal.clear();
|
branches = [];
|
||||||
particles = [];
|
crystalPoints = [];
|
||||||
shatterParticles = [];
|
shatterParticles = [];
|
||||||
phase = 'growing';
|
phase = 'growing';
|
||||||
phaseStartTime = performance.now();
|
phaseStartTime = performance.now();
|
||||||
|
|
||||||
// Seed crystal at center with a small cluster
|
|
||||||
for (let dx = -2; dx <= 2; dx++) {
|
|
||||||
for (let dy = -2; dy <= 2; dy++) {
|
|
||||||
crystal.add(`${centerX + dx},${centerY + dy}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize random walkers from edges
|
|
||||||
for (let i = 0; i < CONFIG.particleCount; i++) {
|
|
||||||
particles.push(createParticle());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.fillStyle = 'black';
|
ctx.fillStyle = 'black';
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function createParticle() {
|
// Create seed points at random locations
|
||||||
// Spawn from random edge
|
for (let i = 0; i < CONFIG.seedCount; i++) {
|
||||||
const edge = Math.floor(Math.random() * 4);
|
const x = Math.random() * width;
|
||||||
let x, y;
|
const y = Math.random() * height;
|
||||||
switch (edge) {
|
|
||||||
case 0: x = Math.random() * width; y = 0; break;
|
// Each seed spawns multiple branches in different directions
|
||||||
case 1: x = width; y = Math.random() * height; break;
|
const branchCount = 3 + Math.floor(Math.random() * 4);
|
||||||
case 2: x = Math.random() * width; y = height; break;
|
for (let j = 0; j < branchCount; j++) {
|
||||||
case 3: x = 0; y = Math.random() * height; break;
|
const angle = (Math.PI * 2 * j) / branchCount + Math.random() * 0.5;
|
||||||
|
branches.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
angle,
|
||||||
|
hue: Math.random() * 360,
|
||||||
|
age: 0,
|
||||||
|
generation: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { x, y, hue: Math.random() * 360 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function update() {
|
function update() {
|
||||||
|
|
@ -87,51 +82,90 @@
|
||||||
function updateGrowing() {
|
function updateGrowing() {
|
||||||
hue = (hue + CONFIG.hueShiftSpeed) % 360;
|
hue = (hue + CONFIG.hueShiftSpeed) % 360;
|
||||||
|
|
||||||
for (let i = particles.length - 1; i >= 0; i--) {
|
const newBranches = [];
|
||||||
const p = particles[i];
|
|
||||||
|
|
||||||
// Random walk toward center with bias
|
for (let i = branches.length - 1; i >= 0; i--) {
|
||||||
const dx = centerX - p.x;
|
const branch = branches[i];
|
||||||
const dy = centerY - p.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
// Biased random walk (DLA with drift)
|
// Random walk - slight angle change
|
||||||
p.x += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dx / dist) * 0.5;
|
branch.angle += (Math.random() - 0.5) * CONFIG.turnAngle;
|
||||||
p.y += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dy / dist) * 0.5;
|
|
||||||
|
|
||||||
// Check for crystallization
|
// Calculate new position
|
||||||
if (shouldCrystallize(p)) {
|
const newX = branch.x + Math.cos(branch.angle) * CONFIG.branchSpeed;
|
||||||
const px = Math.round(p.x);
|
const newY = branch.y + Math.sin(branch.angle) * CONFIG.branchSpeed;
|
||||||
const py = Math.round(p.y);
|
|
||||||
crystal.add(`${px},${py}`);
|
// Check bounds - kill branch if out of screen
|
||||||
particles[i] = createParticle(); // Respawn
|
if (newX < 0 || newX > width || newY < 0 || newY > height) {
|
||||||
|
branches.splice(i, 1);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respawn if out of bounds
|
// Store point for shatter effect
|
||||||
if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) {
|
crystalPoints.push({
|
||||||
particles[i] = createParticle();
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
hue: (branch.hue + branch.age * 0.5) % 360
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw the branch segment
|
||||||
|
const h = (branch.hue + branch.age * 0.5) % 360;
|
||||||
|
ctx.strokeStyle = `hsla(${h}, 80%, 60%, 0.9)`;
|
||||||
|
ctx.lineWidth = CONFIG.lineWidth;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(branch.x, branch.y);
|
||||||
|
ctx.lineTo(newX, newY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Update branch position
|
||||||
|
branch.x = newX;
|
||||||
|
branch.y = newY;
|
||||||
|
branch.age++;
|
||||||
|
|
||||||
|
// Chance to spawn a new branch (fork)
|
||||||
|
if (Math.random() < CONFIG.branchChance && branches.length + newBranches.length < CONFIG.maxBranches) {
|
||||||
|
const forkAngle = branch.angle + (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.7);
|
||||||
|
newBranches.push({
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
angle: forkAngle,
|
||||||
|
hue: (branch.hue + 20 + Math.random() * 40) % 360,
|
||||||
|
age: 0,
|
||||||
|
generation: branch.generation + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chance to die (increases with age and generation)
|
||||||
|
const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002;
|
||||||
|
if (Math.random() < deathChance) {
|
||||||
|
branches.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if crystal is full
|
// Add new branches
|
||||||
if (crystal.size > CONFIG.maxCrystalSize) {
|
branches.push(...newBranches);
|
||||||
|
|
||||||
|
// Check if we should shatter (too many points or no more branches)
|
||||||
|
if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) {
|
||||||
startShatter();
|
startShatter();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function shouldCrystallize(p) {
|
// Spawn new seeds occasionally if branches are dying off
|
||||||
const px = Math.round(p.x);
|
if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) {
|
||||||
const py = Math.round(p.y);
|
const x = Math.random() * width;
|
||||||
|
const y = Math.random() * height;
|
||||||
// Check neighbors
|
const branchCount = 2 + Math.floor(Math.random() * 3);
|
||||||
for (let dx = -CONFIG.stickDistance; dx <= CONFIG.stickDistance; dx++) {
|
for (let j = 0; j < branchCount; j++) {
|
||||||
for (let dy = -CONFIG.stickDistance; dy <= CONFIG.stickDistance; dy++) {
|
const angle = Math.random() * Math.PI * 2;
|
||||||
if (crystal.has(`${px + dx},${py + dy}`)) {
|
branches.push({
|
||||||
return true;
|
x,
|
||||||
}
|
y,
|
||||||
|
angle,
|
||||||
|
hue: Math.random() * 360,
|
||||||
|
age: 0,
|
||||||
|
generation: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startShatter() {
|
function startShatter() {
|
||||||
|
|
@ -140,15 +174,18 @@
|
||||||
shatterParticles = [];
|
shatterParticles = [];
|
||||||
|
|
||||||
// Convert crystal points to shatter particles
|
// Convert crystal points to shatter particles
|
||||||
for (const key of crystal) {
|
// Sample points to avoid too many particles
|
||||||
const [x, y] = key.split(',').map(Number);
|
const step = Math.max(1, Math.floor(crystalPoints.length / 3000));
|
||||||
const angle = Math.atan2(y - centerY, x - centerX);
|
for (let i = 0; i < crystalPoints.length; i += step) {
|
||||||
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
const p = crystalPoints[i];
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = 1 + Math.random() * 4;
|
||||||
shatterParticles.push({
|
shatterParticles.push({
|
||||||
x, y,
|
x: p.x,
|
||||||
vx: Math.cos(angle) * (2 + Math.random() * 4),
|
y: p.y,
|
||||||
vy: Math.sin(angle) * (2 + Math.random() * 4),
|
vx: Math.cos(angle) * speed,
|
||||||
hue: (hue + dist * 0.3) % 360,
|
vy: Math.sin(angle) * speed,
|
||||||
|
hue: p.hue,
|
||||||
alpha: 1,
|
alpha: 1,
|
||||||
size: 2 + Math.random() * 2
|
size: 2 + Math.random() * 2
|
||||||
});
|
});
|
||||||
|
|
@ -158,10 +195,22 @@
|
||||||
function updateShattering() {
|
function updateShattering() {
|
||||||
const elapsed = performance.now() - phaseStartTime;
|
const elapsed = performance.now() - phaseStartTime;
|
||||||
|
|
||||||
|
// Clear with slight fade for trail effect
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
for (const p of shatterParticles) {
|
for (const p of shatterParticles) {
|
||||||
p.x += p.vx;
|
p.x += p.vx;
|
||||||
p.y += p.vy;
|
p.y += p.vy;
|
||||||
|
p.vy += 0.05; // Slight gravity
|
||||||
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration));
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration));
|
||||||
|
|
||||||
|
if (p.alpha > 0) {
|
||||||
|
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed >= CONFIG.shatterDuration) {
|
if (elapsed >= CONFIG.shatterDuration) {
|
||||||
|
|
@ -173,8 +222,20 @@
|
||||||
function updateDissolving() {
|
function updateDissolving() {
|
||||||
const elapsed = performance.now() - phaseStartTime;
|
const elapsed = performance.now() - phaseStartTime;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
for (const p of shatterParticles) {
|
for (const p of shatterParticles) {
|
||||||
|
p.x += p.vx * 0.5;
|
||||||
|
p.y += p.vy * 0.5;
|
||||||
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
||||||
|
|
||||||
|
if (p.alpha > 0.01) {
|
||||||
|
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed >= CONFIG.dissolveDuration) {
|
if (elapsed >= CONFIG.dissolveDuration) {
|
||||||
|
|
@ -182,51 +243,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
|
||||||
// Semi-transparent overlay for trail effect
|
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (phase === 'growing') {
|
|
||||||
drawCrystal();
|
|
||||||
drawParticles();
|
|
||||||
} else {
|
|
||||||
drawShatterParticles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCrystal() {
|
|
||||||
for (const key of crystal) {
|
|
||||||
const [x, y] = key.split(',').map(Number);
|
|
||||||
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
|
||||||
const h = (hue + dist * 0.3) % 360;
|
|
||||||
|
|
||||||
ctx.fillStyle = `hsla(${h}, 80%, 60%, 0.9)`;
|
|
||||||
ctx.fillRect(x - 1, y - 1, 3, 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawParticles() {
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
|
||||||
for (const p of particles) {
|
|
||||||
ctx.fillRect(p.x, p.y, 2, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawShatterParticles() {
|
|
||||||
for (const p of shatterParticles) {
|
|
||||||
if (p.alpha <= 0) continue;
|
|
||||||
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAnimation() {
|
function startAnimation() {
|
||||||
function loop() {
|
function loop() {
|
||||||
update();
|
update();
|
||||||
draw();
|
|
||||||
animationId = requestAnimationFrame(loop);
|
animationId = requestAnimationFrame(loop);
|
||||||
}
|
}
|
||||||
loop();
|
loop();
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
background: #000;
|
background: #000;
|
||||||
padding: var(--nav-padding-y) 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue