beeta/backend/src/controllers/UserController.cpp

2588 lines
123 KiB
C++
Raw Normal View History

2025-08-03 21:53:15 -04:00
#include "UserController.h"
#include "../services/DatabaseService.h"
2026-01-05 22:54:27 -05:00
#include "../services/CensorService.h"
#include "../common/HttpHelpers.h"
#include "../common/AuthHelpers.h"
#include "../common/FileUtils.h"
#include "../common/FileValidation.h"
2025-08-03 21:53:15 -04:00
#include <drogon/MultiPart.h>
2025-08-13 00:10:25 -04:00
#include <drogon/Cookie.h>
2025-08-03 21:53:15 -04:00
#include <fstream>
#include <random>
#include <sstream>
#include <iomanip>
#include <filesystem>
2026-01-05 22:54:27 -05:00
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <regex>
2025-08-03 21:53:15 -04:00
using namespace drogon::orm;
namespace {
2026-01-05 22:54:27 -05:00
// SECURITY FIX: Hash API key with SHA-256 for storage
std::string hashApiKey(const std::string& apiKey) {
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char*>(apiKey.c_str()), apiKey.length(), hash);
2025-08-03 21:53:15 -04:00
std::stringstream ss;
2026-01-05 22:54:27 -05:00
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
ss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(hash[i]);
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
return ss.str();
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-13 00:10:25 -04:00
// 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);
}
2025-08-03 21:53:15 -04:00
}
void UserController::register_(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
try {
LOG_DEBUG << "Registration request received";
2026-01-05 22:54:27 -05:00
// First check if registration is enabled
auto dbClient = app().getDbClient();
*dbClient << "SELECT registration_enabled FROM chat_settings WHERE id = 1"
>> [req, callback](const Result& r) {
bool registrationEnabled = true;
if (!r.empty()) {
registrationEnabled = r[0]["registration_enabled"].as<bool>();
}
if (!registrationEnabled) {
LOG_WARN << "Registration attempt blocked - registration is disabled";
callback(jsonError("Registration is currently disabled"));
return;
}
// Continue with registration
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();
// Block registration if username contains censored words
if (CensorService::getInstance().containsCensoredWords(username)) {
callback(jsonError("Username contains prohibited content"));
return;
}
// 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));
}
});
}
>> DB_ERROR_MSG(callback, "check registration status", "Failed to check registration status");
2025-08-10 07:55:39 -04:00
} 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"));
2025-08-03 21:53:15 -04:00
}
}
void UserController::login(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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;
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
Json::Value resp;
resp["success"] = true;
2026-01-05 22:54:27 -05:00
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
resp["token"] = token;
2025-08-10 07:55:39 -04:00
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
resp["user"]["username"] = user.username;
resp["user"]["isAdmin"] = user.isAdmin;
resp["user"]["isStreamer"] = user.isStreamer;
2026-01-05 22:54:27 -05:00
resp["user"]["isRestreamer"] = user.isRestreamer;
resp["user"]["isBot"] = user.isBot;
resp["user"]["isTexter"] = user.isTexter;
2025-08-10 07:55:39 -04:00
resp["user"]["isPgpOnly"] = user.isPgpOnly;
resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl;
2026-01-05 22:54:27 -05:00
resp["user"]["bannerUrl"] = user.bannerUrl;
resp["user"]["bannerPosition"] = user.bannerPosition;
resp["user"]["bannerZoom"] = user.bannerZoom;
resp["user"]["bannerPositionX"] = user.bannerPositionX;
resp["user"]["graffitiUrl"] = user.graffitiUrl;
2025-08-10 07:55:39 -04:00
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
2025-08-13 00:10:25 -04:00
resp["user"]["colorCode"] = user.colorCode;
2026-01-05 22:54:27 -05:00
2025-08-13 00:10:25 -04:00
auto response = jsonResp(resp);
setAuthCookie(response, token);
callback(response);
2025-08-10 07:55:39 -04:00
} 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"));
2025-08-03 21:53:15 -04:00
}
}
2025-08-13 00:10:25 -04:00
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"));
}
}
2025-08-03 21:53:15 -04:00
void UserController::pgpChallenge(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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"));
2025-08-03 21:53:15 -04:00
}
}
void UserController::pgpVerify(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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;
2026-01-05 22:54:27 -05:00
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
resp["token"] = token;
2025-08-10 07:55:39 -04:00
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
resp["user"]["username"] = user.username;
resp["user"]["isAdmin"] = user.isAdmin;
resp["user"]["isStreamer"] = user.isStreamer;
2026-01-05 22:54:27 -05:00
resp["user"]["isRestreamer"] = user.isRestreamer;
resp["user"]["isBot"] = user.isBot;
resp["user"]["isTexter"] = user.isTexter;
2025-08-10 07:55:39 -04:00
resp["user"]["isPgpOnly"] = user.isPgpOnly;
resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl;
2026-01-05 22:54:27 -05:00
resp["user"]["bannerUrl"] = user.bannerUrl;
resp["user"]["bannerPosition"] = user.bannerPosition;
resp["user"]["bannerZoom"] = user.bannerZoom;
resp["user"]["bannerPositionX"] = user.bannerPositionX;
resp["user"]["graffitiUrl"] = user.graffitiUrl;
2025-08-10 07:55:39 -04:00
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
2025-08-13 00:10:25 -04:00
resp["user"]["colorCode"] = user.colorCode;
2026-01-05 22:54:27 -05:00
2025-08-13 00:10:25 -04:00
auto response = jsonResp(resp);
setAuthCookie(response, token);
callback(response);
2025-08-10 07:55:39 -04:00
} else {
callback(jsonError("Invalid signature", k401Unauthorized));
}
});
} catch (const std::exception& e) {
LOG_ERROR << "Exception in pgpVerify: " << e.what();
callback(jsonError("Internal server error"));
2025-08-03 21:53:15 -04:00
}
}
void UserController::getCurrentUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
2026-01-05 22:54:27 -05:00
*dbClient << "SELECT id, username, is_admin, is_streamer, is_restreamer, is_bot, is_texter, is_sticker_creator, is_uploader, is_watch_creator, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, ubercoin_balance, created_at "
2025-08-10 07:55:39 -04:00
"FROM users WHERE id = $1"
<< user.id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("User not found", k404NotFound));
return;
}
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
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>();
2026-01-05 22:54:27 -05:00
resp["user"]["isRestreamer"] = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
resp["user"]["isBot"] = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
resp["user"]["isTexter"] = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
resp["user"]["isStickerCreator"] = r[0]["is_sticker_creator"].isNull() ? false : r[0]["is_sticker_creator"].as<bool>();
resp["user"]["isUploader"] = r[0]["is_uploader"].isNull() ? false : r[0]["is_uploader"].as<bool>();
resp["user"]["isWatchCreator"] = r[0]["is_watch_creator"].isNull() ? false : r[0]["is_watch_creator"].as<bool>();
2025-08-10 07:55:39 -04:00
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>();
2026-01-05 22:54:27 -05:00
resp["user"]["bannerUrl"] = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
resp["user"]["bannerPosition"] = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
resp["user"]["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
resp["user"]["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
resp["user"]["graffitiUrl"] = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
2025-08-10 07:55:39 -04:00
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>();
2026-01-05 22:54:27 -05:00
resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as<std::string>();
2025-08-10 07:55:39 -04:00
callback(jsonResp(resp));
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR(callback, "get user data");
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getCurrentUser: " << e.what();
callback(jsonError("Internal server error"));
}
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
void UserController::getToken(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
// Return the JWT token for Nakama authentication
// This endpoint allows the frontend to fetch the token without storing it in localStorage
try {
std::string token = req->getCookie("auth_token");
if (token.empty()) {
callback(jsonError("No auth token", k401Unauthorized));
return;
}
Json::Value resp;
resp["success"] = true;
resp["token"] = token;
callback(jsonResp(resp));
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getToken: " << e.what();
callback(jsonError("Internal server error"));
}
}
2025-08-03 21:53:15 -04:00
void UserController::updateProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
std::string bio = (*json).isMember("bio") ? (*json)["bio"].asString() : "";
2026-01-05 22:54:27 -05:00
int bannerPosition = (*json).isMember("bannerPosition") ? (*json)["bannerPosition"].asInt() : -1;
int bannerZoom = (*json).isMember("bannerZoom") ? (*json)["bannerZoom"].asInt() : -1;
int bannerPositionX = (*json).isMember("bannerPositionX") ? (*json)["bannerPositionX"].asInt() : -1;
// Apply censoring to bio
bio = CensorService::getInstance().censor(bio);
// Validate banner position (Y)
if (bannerPosition != -1 && (bannerPosition < 0 || bannerPosition > 100)) {
callback(jsonError("Banner position must be between 0 and 100"));
return;
}
// Validate banner zoom (100-200)
if (bannerZoom != -1 && (bannerZoom < 100 || bannerZoom > 200)) {
callback(jsonError("Banner zoom must be between 100 and 200"));
return;
}
// Validate banner position X (0-100)
if (bannerPositionX != -1 && (bannerPositionX < 0 || bannerPositionX > 100)) {
callback(jsonError("Banner position X must be between 0 and 100"));
return;
}
2025-08-10 07:55:39 -04:00
auto dbClient = app().getDbClient();
2026-01-05 22:54:27 -05:00
// Build query based on what's being updated
bool hasBannerUpdates = (bannerPosition != -1 || bannerZoom != -1 || bannerPositionX != -1);
if (hasBannerUpdates) {
// Update all banner fields along with bio
*dbClient << "UPDATE users SET bio = $1, "
"banner_position = COALESCE(NULLIF($2, -1), banner_position), "
"banner_zoom = COALESCE(NULLIF($3, -1), banner_zoom), "
"banner_position_x = COALESCE(NULLIF($4, -1), banner_position_x) "
"WHERE id = $5 "
"RETURNING banner_position, banner_zoom, banner_position_x"
<< bio << bannerPosition << bannerZoom << bannerPositionX << user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Profile updated successfully";
if (!r.empty()) {
resp["bannerPosition"] = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
resp["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
resp["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
}
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update profile", "Failed to update profile");
} else {
*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));
}
>> DB_ERROR_MSG(callback, "update profile", "Failed to update profile");
}
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
LOG_ERROR << "Exception in updateProfile: " << e.what();
callback(jsonError("Internal server error"));
2025-08-03 21:53:15 -04:00
}
}
void UserController::updatePassword(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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"));
2025-08-03 21:53:15 -04:00
}
}
void UserController::togglePgpOnly(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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));
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR_MSG(callback, "update PGP setting", "Failed to update setting");
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
LOG_ERROR << "Exception in togglePgpOnly: " << e.what();
callback(jsonError("Internal server error"));
}
2025-08-03 21:53:15 -04:00
}
void UserController::addPgpKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
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();
std::string origin = (*json).get("origin", "imported").asString();
// Validate origin value
if (origin != "generated" && origin != "imported") {
origin = "imported";
}
2025-08-10 07:55:39 -04:00
if (publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing key data"));
return;
}
2025-08-10 07:55:39 -04:00
auto dbClient = app().getDbClient();
2025-08-10 07:55:39 -04:00
// Check if fingerprint already exists
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
<< fingerprint
>> [dbClient, user, publicKey, fingerprint, origin, callback](const Result& r) {
2025-08-10 07:55:39 -04:00
if (!r.empty()) {
callback(jsonError("This PGP key is already registered"));
return;
}
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint, key_origin) VALUES ($1, $2, $3, $4)"
<< user.id << publicKey << fingerprint << origin
2025-08-10 07:55:39 -04:00
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR_MSG(callback, "add PGP key", "Failed to add PGP key");
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR(callback, "check PGP key");
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
LOG_ERROR << "Exception in addPgpKey: " << e.what();
callback(jsonError("Internal server error"));
}
2025-08-03 21:53:15 -04:00
}
void UserController::getPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT public_key, fingerprint, key_origin, created_at FROM pgp_keys "
2025-08-10 07:55:39 -04:00
"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);
2025-08-10 07:55:39 -04:00
for (const auto& row : r) {
Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>();
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
2025-08-10 07:55:39 -04:00
key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key);
}
2025-08-10 07:55:39 -04:00
resp["keys"] = keys;
callback(jsonResp(resp));
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR_MSG(callback, "get PGP keys", "Failed to get PGP keys");
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getPgpKeys: " << e.what();
callback(jsonError("Internal server error"));
}
2025-08-03 21:53:15 -04:00
}
void UserController::uploadAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
2025-08-10 07:55:39 -04:00
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
2025-08-03 21:53:15 -04:00
return;
}
2025-08-10 07:55:39 -04:00
MultiPartParser parser;
parser.parse(req);
if (parser.getFiles().empty()) {
callback(jsonError("No file uploaded"));
2025-08-03 21:53:15 -04:00
return;
}
2025-08-10 07:55:39 -04:00
const auto& file = parser.getFiles()[0];
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
// Validate file size (250KB max)
if (file.fileLength() > 250 * 1024) {
callback(jsonError("File too large (max 250KB)"));
2025-08-03 21:53:15 -04:00
return;
}
2026-01-05 22:54:27 -05:00
// Validate file content using magic bytes (not just extension)
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength());
if (!validation.valid) {
LOG_WARN << "Avatar upload rejected: invalid image magic bytes";
callback(jsonError("Invalid image file. Only JPEG, PNG, and GIF are allowed."));
return;
}
// Only allow jpg, png, gif for avatars (no webp)
if (validation.detectedType != "jpeg" && validation.detectedType != "png" && validation.detectedType != "gif") {
2025-08-10 07:55:39 -04:00
callback(jsonError("Invalid file type (jpg, png, gif only)"));
2025-08-03 21:53:15 -04:00
return;
}
2026-01-05 22:54:27 -05:00
// Use the extension from magic byte validation (ignores client-provided extension)
std::string ext = validation.extension.substr(1); // Remove leading dot
2025-08-10 07:55:39 -04:00
// Ensure uploads directory exists
const std::string uploadDir = "/app/uploads/avatars";
2026-01-05 22:54:27 -05:00
if (!ensureDirectoryExists(uploadDir, true)) {
2025-08-10 07:55:39 -04:00
callback(jsonError("Failed to create upload directory"));
return;
}
2025-08-03 21:53:15 -04:00
2025-08-10 07:55:39 -04:00
// 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)
2025-08-03 21:53:15 -04:00
if (std::filesystem::exists(fullPath)) {
2025-08-10 07:55:39 -04:00
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;
2025-08-03 21:53:15 -04:00
}
2025-08-10 07:55:39 -04:00
// Store as proper URL path
std::string avatarUrl = "/uploads/avatars/" + filename;
2025-08-03 21:53:15 -04:00
2026-01-05 22:54:27 -05:00
// First get old avatar path, then update and clean up
2025-08-10 07:55:39 -04:00
auto dbClient = app().getDbClient();
2026-01-05 22:54:27 -05:00
*dbClient << "SELECT avatar_url FROM users WHERE id = $1"
<< user.id
>> [callback, avatarUrl, userId = user.id, dbClient](const Result& r) {
std::string oldAvatarUrl = "";
if (!r.empty() && !r[0]["avatar_url"].isNull()) {
oldAvatarUrl = r[0]["avatar_url"].as<std::string>();
2025-08-10 07:55:39 -04:00
}
2025-08-03 21:53:15 -04:00
2026-01-05 22:54:27 -05:00
// Update database with new avatar URL
*dbClient << "UPDATE users SET avatar_url = $1 WHERE id = $2"
<< avatarUrl << userId
>> [callback, avatarUrl, userId, oldAvatarUrl](const Result&) {
// Delete old avatar file if it exists
if (!oldAvatarUrl.empty() && oldAvatarUrl.find("/uploads/avatars/") != std::string::npos) {
std::string oldPath = "/app" + oldAvatarUrl;
if (std::filesystem::exists(oldPath)) {
std::filesystem::remove(oldPath);
LOG_INFO << "Deleted old avatar: " << oldPath;
}
}
// Fetch updated user info and generate new token
AuthService::getInstance().fetchUserInfo(userId,
[callback, avatarUrl](bool success, const UserInfo& updatedUser) {
if (success) {
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
Json::Value resp;
resp["success"] = true;
resp["avatarUrl"] = avatarUrl;
resp["token"] = newToken;
auto response = jsonResp(resp);
setAuthCookie(response, newToken);
callback(response);
} else {
Json::Value resp;
resp["success"] = true;
resp["avatarUrl"] = avatarUrl;
callback(jsonResp(resp));
}
});
}
>> DB_ERROR_MSG(callback, "update avatar", "Failed to update avatar");
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
>> DB_ERROR(callback, "get old avatar");
2025-08-10 07:55:39 -04:00
} catch (const std::exception& e) {
2026-01-05 22:54:27 -05:00
LOG_ERROR << "Exception in uploadAvatar: " << e.what();
2025-08-10 07:55:39 -04:00
callback(jsonError("Internal server error"));
}
}
2026-01-05 22:54:27 -05:00
void UserController::uploadBanner(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
2025-08-10 07:55:39 -04:00
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
2026-01-05 22:54:27 -05:00
MultiPartParser parser;
parser.parse(req);
if (parser.getFiles().empty()) {
callback(jsonError("No file uploaded"));
2025-08-10 07:55:39 -04:00
return;
}
2026-01-05 22:54:27 -05:00
const auto& file = parser.getFiles()[0];
// Validate file size (500KB max for banners)
if (file.fileLength() > 500 * 1024) {
callback(jsonError("File too large (max 500KB)"));
2025-08-10 07:55:39 -04:00
return;
}
2026-01-05 22:54:27 -05:00
// Validate file content using magic bytes (not just extension)
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength());
if (!validation.valid) {
LOG_WARN << "Banner upload rejected: invalid image magic bytes";
callback(jsonError("Invalid image file. Only JPEG, PNG, and GIF are allowed."));
2025-08-10 07:55:39 -04:00
return;
}
2026-01-05 22:54:27 -05:00
// Only allow jpg, png, gif for banners (no webp)
if (validation.detectedType != "jpeg" && validation.detectedType != "png" && validation.detectedType != "gif") {
callback(jsonError("Invalid file type (jpg, png, gif only)"));
return;
}
// Use the extension from magic byte validation (ignores client-provided extension)
std::string ext = validation.extension.substr(1); // Remove leading dot
// Ensure uploads directory exists
const std::string uploadDir = "/app/uploads/banners";
if (!ensureDirectoryExists(uploadDir, true)) {
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 banner 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 << "Banner saved successfully to: " << fullPath;
LOG_INFO << "File size: " << std::filesystem::file_size(fullPath) << " bytes";
} catch (const std::exception& e) {
LOG_ERROR << "Exception while saving banner: " << e.what();
// Clean up any partial files/directories
if (std::filesystem::exists(fullPath)) {
std::filesystem::remove_all(fullPath);
}
callback(jsonError("Failed to save banner"));
return;
}
// Store as proper URL path
std::string bannerUrl = "/uploads/banners/" + filename;
// Update database with the URL and get new token
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET banner_url = $1 WHERE id = $2"
<< bannerUrl << user.id
>> [callback, bannerUrl, userId = user.id](const Result&) {
// Fetch updated user info and generate new token
AuthService::getInstance().fetchUserInfo(userId,
[callback, bannerUrl](bool success, const UserInfo& updatedUser) {
if (success) {
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
Json::Value resp;
resp["success"] = true;
resp["bannerUrl"] = bannerUrl;
resp["token"] = newToken;
auto response = jsonResp(resp);
setAuthCookie(response, newToken);
callback(response);
} else {
Json::Value resp;
resp["success"] = true;
resp["bannerUrl"] = bannerUrl;
callback(jsonResp(resp));
}
});
}
>> DB_ERROR_MSG(callback, "update banner", "Failed to update banner");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in uploadBanner: " << 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.banner_url, u.banner_position, "
"u.banner_zoom, u.banner_position_x, u.graffiti_url, u.created_at, "
"u.is_pgp_only, u.pgp_only_enabled_at, u.user_color, u.ubercoin_balance "
"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"]["bannerUrl"] = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
resp["profile"]["bannerPosition"] = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
resp["profile"]["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
resp["profile"]["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
resp["profile"]["graffitiUrl"] = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_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>();
resp["profile"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get profile");
} 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.key_origin, pk.created_at "
2026-01-05 22:54:27 -05:00
"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);
2026-01-05 22:54:27 -05:00
for (const auto& row : r) {
Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>();
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
2026-01-05 22:54:27 -05:00
key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key);
}
2026-01-05 22:54:27 -05:00
resp["keys"] = keys;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get user PGP keys", "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
2025-08-13 00:10:25 -04:00
setAuthCookie(response, newToken);
callback(response);
2025-08-10 07:55:39 -04:00
} 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;
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
Json::Value colors(Json::arrayValue);
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
// 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
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
resp["colors"] = colors;
callback(jsonResp(resp));
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getAvailableColors: " << e.what();
callback(jsonError("Internal server error"));
}
2026-01-05 22:54:27 -05:00
}
void UserController::getBotApiKeys(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();
// First check if user has bot role
*dbClient << "SELECT is_bot FROM users WHERE id = $1"
<< user.id
>> [dbClient, user, callback](const Result& r) {
if (r.empty() || !r[0]["is_bot"].as<bool>()) {
callback(jsonError("Bot role required", k403Forbidden));
return;
}
// Get user's API keys (including expiration and scopes)
// Note: is_active filter kept for backwards compatibility with any old soft-deleted keys
*dbClient << "SELECT id, name, scopes, created_at, last_used_at, expires_at FROM bot_api_keys "
"WHERE user_id = $1 AND is_active = true ORDER BY created_at DESC"
<< user.id
>> [callback](const Result& r2) {
Json::Value resp;
resp["success"] = true;
Json::Value keys(Json::arrayValue);
for (const auto& row : r2) {
Json::Value key;
key["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
key["name"] = row["name"].as<std::string>();
key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].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["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as<std::string>();
keys.append(key);
}
resp["keys"] = keys;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get bot API keys", "Failed to get API keys");
}
>> DB_ERROR(callback, "check bot role");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getBotApiKeys: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::createBotApiKey(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("name") || (*json)["name"].asString().empty()) {
callback(jsonError("API key name is required"));
return;
}
std::string keyName = (*json)["name"].asString();
// Validate name length
if (keyName.length() > 100) {
callback(jsonError("API key name too long (max 100 characters)"));
return;
}
// SECURITY FIX: Validate key name format (alphanumeric, underscore, hyphen only)
std::regex namePattern("^[a-zA-Z0-9_-]+$");
if (!std::regex_match(keyName, namePattern)) {
callback(jsonError("API key name can only contain letters, numbers, underscores, and hyphens"));
return;
}
auto dbClient = app().getDbClient();
// First check if user has bot role AND count existing keys
*dbClient << "SELECT u.is_bot, (SELECT COUNT(*) FROM bot_api_keys WHERE user_id = $1) as key_count "
"FROM users u WHERE u.id = $1"
<< user.id
>> [dbClient, user, keyName, callback](const Result& r) {
if (r.empty() || !r[0]["is_bot"].as<bool>()) {
callback(jsonError("Bot role required", k403Forbidden));
return;
}
// SECURITY FIX: Enforce max 2 API keys per user
int64_t keyCount = r[0]["key_count"].as<int64_t>();
if (keyCount >= 2) {
callback(jsonError("Maximum of 2 API keys allowed per user. Delete an existing key first.", k403Forbidden));
return;
}
// SECURITY FIX: Generate cryptographically secure API key using OpenSSL RAND_bytes
unsigned char bytes[32];
if (RAND_bytes(bytes, sizeof(bytes)) != 1) {
callback(jsonError("Failed to generate secure API key", k500InternalServerError));
return;
}
std::stringstream ss;
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)
std::string apiKeyHash = hashApiKey(apiKey);
// Insert the HASHED API key with 1 year expiration
*dbClient << "INSERT INTO bot_api_keys (user_id, api_key, name, expires_at) "
"VALUES ($1, $2, $3, CURRENT_TIMESTAMP + INTERVAL '1 year') RETURNING id, expires_at"
<< user.id << apiKeyHash << keyName
>> [callback, apiKey, keyName](const Result& r2) {
if (r2.empty()) {
callback(jsonError("Failed to create API key"));
return;
}
Json::Value resp;
resp["success"] = true;
resp["key"]["id"] = static_cast<Json::Int64>(r2[0]["id"].as<int64_t>());
resp["key"]["name"] = keyName;
resp["key"]["apiKey"] = apiKey; // Only shown once!
resp["key"]["expiresAt"] = r2[0]["expires_at"].as<std::string>();
resp["message"] = "API key created. Save it now - it won't be shown again!";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "create bot API key", "Failed to create API key");
}
>> DB_ERROR(callback, "check bot role");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in createBotApiKey: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::deleteBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &keyId) {
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t keyIdInt;
try {
keyIdInt = std::stoll(keyId);
} catch (...) {
callback(jsonError("Invalid key ID"));
return;
}
auto dbClient = app().getDbClient();
// Hard delete the key (only if it belongs to the user)
*dbClient << "DELETE FROM bot_api_keys WHERE id = $1 AND user_id = $2"
<< keyIdInt << user.id
>> [callback](const Result& r) {
if (r.affectedRows() == 0) {
callback(jsonError("API key not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
resp["message"] = "API key deleted";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "delete bot API key", "Failed to delete API key");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in deleteBotApiKey: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::validateBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
auto json = req->getJsonObject();
if (!json || !json->isMember("apiKey")) {
callback(jsonError("API key is required"));
return;
}
std::string apiKey = (*json)["apiKey"].asString();
// SECURITY FIX: Hash the incoming key to compare with stored hash
std::string apiKeyHash = hashApiKey(apiKey);
auto dbClient = app().getDbClient();
// Validate API key and get user info
// SECURITY FIX: Compare hashed key, check user not disabled, key not expired
*dbClient << "SELECT b.id as key_id, b.user_id, b.scopes, u.username, u.user_color, u.avatar_url "
"FROM bot_api_keys b "
"JOIN users u ON b.user_id = u.id "
"WHERE b.api_key = $1 AND b.is_active = true AND u.is_disabled = false "
"AND (b.expires_at IS NULL OR b.expires_at > CURRENT_TIMESTAMP)"
<< apiKeyHash
>> [callback, apiKey](const Result& r) {
if (r.empty()) {
Json::Value resp;
resp["success"] = false;
resp["valid"] = false;
resp["error"] = "Invalid API key";
callback(jsonResp(resp));
return;
}
const auto& row = r[0];
Json::Value resp;
resp["success"] = true;
resp["valid"] = true;
resp["keyId"] = static_cast<Json::Int64>(row["key_id"].as<int64_t>());
resp["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
resp["username"] = row["username"].as<std::string>();
resp["userColor"] = row["user_color"].isNull() ? "#808080" : row["user_color"].as<std::string>();
resp["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
resp["scopes"] = row["scopes"].isNull() ? "chat:rw" : row["scopes"].as<std::string>();
callback(jsonResp(resp));
// Update last_used_at asynchronously
int64_t keyId = row["key_id"].as<int64_t>();
app().getDbClient()->execSqlAsync(
"UPDATE bot_api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1",
[](const Result&) {},
[](const DrogonDbException& e) {
LOG_ERROR << "Failed to update API key last_used_at: " << e.base().what();
},
keyId
);
}
>> DB_ERROR_MSG(callback, "validate bot API key", "Failed to validate API key");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in validateBotApiKey: " << e.what();
callback(jsonError("Internal server error"));
}
}
// Internal API: Process pending uberban for a user
// Called by chat-service when an authenticated user connects
// If user has pending_uberban=true, disables account and clears the flag
// Chat-service handles adding fingerprint to banned set separately
void UserController::processPendingUberban(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId) {
try {
int64_t targetUserId = std::stoll(userId);
auto dbClient = app().getDbClient();
// Check if user has pending_uberban
*dbClient << "SELECT pending_uberban FROM users WHERE id = $1"
<< targetUserId
>> [callback, dbClient, targetUserId](const Result& r) {
if (r.empty()) {
Json::Value resp;
resp["processed"] = false;
resp["reason"] = "User not found";
callback(jsonResp(resp));
return;
}
bool pendingUberban = r[0]["pending_uberban"].isNull() ? false : r[0]["pending_uberban"].as<bool>();
if (!pendingUberban) {
Json::Value resp;
resp["processed"] = false;
resp["reason"] = "No pending uberban";
callback(jsonResp(resp));
return;
}
// User has pending uberban - disable account and clear flag
*dbClient << "UPDATE users SET is_disabled = true, pending_uberban = false WHERE id = $1"
<< targetUserId
>> [callback, targetUserId](const Result&) {
LOG_INFO << "Processed pending uberban for user " << targetUserId;
Json::Value resp;
resp["processed"] = true;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "process pending uberban", "Failed to process pending uberban");
}
>> DB_ERROR_MSG(callback, "check pending uberban", "Failed to check pending uberban");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in processPendingUberban: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::submitSticker(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();
// Check if user has sticker creator role
*dbClient << "SELECT is_sticker_creator FROM users WHERE id = $1"
<< user.id
>> [req, user, callback](const Result& r) {
if (r.empty() || !r[0]["is_sticker_creator"].as<bool>()) {
callback(jsonError("Sticker creator role required", k403Forbidden));
return;
}
// Parse multipart form
MultiPartParser fileUpload;
if (fileUpload.parse(req) != 0 || fileUpload.getFiles().empty()) {
callback(jsonError("No files uploaded"));
return;
}
auto files = fileUpload.getFiles();
auto dbClient = app().getDbClient();
// Validate all file sizes (4MB limit each)
const size_t maxFileSize = 4 * 1024 * 1024;
for (const auto& file : files) {
if (file.fileLength() > maxFileSize) {
callback(jsonError("File too large: " + file.getFileName() + " (max 4MB)"));
return;
}
}
// Ensure directory exists
std::filesystem::create_directories("./uploads/sticker-submissions");
Json::Value resp;
resp["success"] = true;
Json::Value submissions(Json::arrayValue);
// Process each uploaded file
int validCount = 0;
for (const auto& file : files) {
std::string paramName = file.getFileName();
std::string stickerName = fileUpload.getParameter<std::string>("name_" + paramName);
if (stickerName.empty()) {
stickerName = paramName.substr(0, paramName.find_last_of('.'));
}
// Validate name
if (stickerName.length() > 50) {
stickerName = stickerName.substr(0, 50);
}
// Validate file content using magic bytes (not just extension)
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength());
if (!validation.valid) {
LOG_WARN << "Sticker submission rejected for " << paramName << ": invalid image magic bytes";
continue; // Skip invalid file types
}
// Use the extension from magic byte validation (ignores client-provided extension)
std::string ext = validation.extension;
// Generate unique filename and save to submissions folder
std::string uniqueName = drogon::utils::getUuid() + ext;
std::string filePath = "/uploads/sticker-submissions/" + uniqueName;
std::string fullPath = "./uploads/sticker-submissions/" + uniqueName;
// Save file
file.saveAs(fullPath);
validCount++;
// Insert into sticker_submissions table
*dbClient << "INSERT INTO sticker_submissions (name, file_path, submitted_by) "
"VALUES ($1, $2, $3) RETURNING id, name, file_path, created_at"
<< stickerName << filePath << user.id
>> [submissions, stickerName, filePath](const Result& r) mutable {
if (!r.empty()) {
const auto& row = r[0];
Json::Value sub;
sub["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
sub["name"] = row["name"].as<std::string>();
sub["filePath"] = row["file_path"].as<std::string>();
sub["createdAt"] = row["created_at"].as<std::string>();
submissions.append(sub);
}
}
>> [](const DrogonDbException& e) {
LOG_ERROR << "Failed to submit sticker: " << e.base().what();
};
}
if (validCount == 0) {
callback(jsonError("No valid image files uploaded. Only JPEG, PNG, GIF, and WebP are allowed."));
return;
}
resp["message"] = "Sticker(s) submitted for review";
resp["submissions"] = submissions;
resp["count"] = validCount;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "check sticker creator role");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in submitSticker: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::getMySubmissions(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();
// Check if user has sticker creator role
*dbClient << "SELECT is_sticker_creator FROM users WHERE id = $1"
<< user.id
>> [user, callback](const Result& r) {
if (r.empty() || !r[0]["is_sticker_creator"].as<bool>()) {
callback(jsonError("Sticker creator role required", k403Forbidden));
return;
}
// Get user's submissions
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, name, file_path, status, denial_reason, created_at "
"FROM sticker_submissions WHERE submitted_by = $1 "
"ORDER BY created_at DESC"
<< user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value submissions(Json::arrayValue);
for (const auto& row : r) {
Json::Value sub;
sub["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
sub["name"] = row["name"].as<std::string>();
sub["filePath"] = row["file_path"].as<std::string>();
sub["status"] = row["status"].as<std::string>();
sub["denialReason"] = row["denial_reason"].isNull() ? "" : row["denial_reason"].as<std::string>();
sub["createdAt"] = row["created_at"].as<std::string>();
submissions.append(sub);
}
resp["submissions"] = submissions;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get submissions", "Failed to get submissions");
}
>> DB_ERROR(callback, "check sticker creator role");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getMySubmissions: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::uploadGraffiti(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];
// Graffiti files are small (88x33 PNG), limit to 50KB
if (file.fileLength() > 50 * 1024) {
callback(jsonError("File too large (max 50KB)"));
return;
}
// Validate file content using magic bytes
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength());
if (!validation.valid) {
LOG_WARN << "Graffiti upload rejected: invalid image magic bytes";
callback(jsonError("Invalid image file. Only PNG images are allowed."));
return;
}
// Only allow PNG for graffiti (transparency support)
if (validation.detectedType != "png") {
callback(jsonError("Invalid file type (PNG only)"));
return;
}
// Ensure uploads directory exists
const std::string uploadDir = "/app/uploads/graffiti";
if (!ensureDirectoryExists(uploadDir, true)) {
callback(jsonError("Failed to create upload directory"));
return;
}
// Generate unique filename
std::string filename = generateRandomFilename("png");
std::string fullPath = uploadDir + "/" + filename;
// Ensure the file doesn't already exist
if (std::filesystem::exists(fullPath)) {
filename = generateRandomFilename("png");
fullPath = uploadDir + "/" + filename;
}
try {
const char* fileData = file.fileData();
size_t fileSize = file.fileLength();
if (!fileData || fileSize == 0) {
callback(jsonError("Empty file uploaded"));
return;
}
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 graffiti file";
callback(jsonError("Failed to write file"));
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 << "Graffiti saved successfully to: " << fullPath;
} catch (const std::exception& e) {
LOG_ERROR << "Exception while saving graffiti: " << e.what();
if (std::filesystem::exists(fullPath)) {
std::filesystem::remove_all(fullPath);
}
callback(jsonError("Failed to save graffiti"));
return;
}
std::string graffitiUrl = "/uploads/graffiti/" + filename;
// Delete old graffiti file if exists
auto dbClient = app().getDbClient();
*dbClient << "SELECT graffiti_url FROM users WHERE id = $1"
<< user.id
>> [callback, graffitiUrl, userId = user.id](const Result& r) {
if (!r.empty() && !r[0]["graffiti_url"].isNull()) {
std::string oldUrl = r[0]["graffiti_url"].as<std::string>();
if (!oldUrl.empty()) {
std::string oldPath = "/app" + oldUrl;
if (std::filesystem::exists(oldPath)) {
std::filesystem::remove(oldPath);
LOG_INFO << "Deleted old graffiti: " << oldPath;
}
}
}
// Update database
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET graffiti_url = $1 WHERE id = $2"
<< graffitiUrl << userId
>> [callback, graffitiUrl, userId](const Result&) {
AuthService::getInstance().fetchUserInfo(userId,
[callback, graffitiUrl](bool success, const UserInfo& updatedUser) {
if (success) {
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
Json::Value resp;
resp["success"] = true;
resp["graffitiUrl"] = graffitiUrl;
resp["token"] = newToken;
auto response = jsonResp(resp);
setAuthCookie(response, newToken);
callback(response);
} else {
Json::Value resp;
resp["success"] = true;
resp["graffitiUrl"] = graffitiUrl;
callback(jsonResp(resp));
}
});
}
>> DB_ERROR_MSG(callback, "update graffiti", "Failed to update graffiti");
}
>> DB_ERROR(callback, "get old graffiti");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in uploadGraffiti: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::deleteGraffiti(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();
// Get current graffiti URL to delete the file
*dbClient << "SELECT graffiti_url FROM users WHERE id = $1"
<< user.id
>> [callback, userId = user.id](const Result& r) {
if (!r.empty() && !r[0]["graffiti_url"].isNull()) {
std::string oldUrl = r[0]["graffiti_url"].as<std::string>();
if (!oldUrl.empty()) {
std::string oldPath = "/app" + oldUrl;
if (std::filesystem::exists(oldPath)) {
std::filesystem::remove(oldPath);
LOG_INFO << "Deleted graffiti file: " << oldPath;
}
}
}
// Clear graffiti URL in database
auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET graffiti_url = NULL WHERE id = $1"
<< userId
>> [callback, userId](const Result&) {
AuthService::getInstance().fetchUserInfo(userId,
[callback](bool success, const UserInfo& updatedUser) {
if (success) {
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
Json::Value resp;
resp["success"] = true;
resp["token"] = newToken;
auto response = jsonResp(resp);
setAuthCookie(response, newToken);
callback(response);
} else {
Json::Value resp;
resp["success"] = true;
callback(jsonResp(resp));
}
});
}
>> DB_ERROR_MSG(callback, "delete graffiti", "Failed to delete graffiti");
}
>> DB_ERROR(callback, "get graffiti for delete");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in deleteGraffiti: " << e.what();
callback(jsonError("Internal server error"));
}
}
// ============================================
// ÜBERCOIN VIRTUAL CURRENCY ENDPOINTS
// ============================================
// Calculate burn rate based on account age in days
// Formula: max(1, 99 * e^(-account_age_days / 180))
// Minimum burn rate is 1% (never goes to 0)
double UserController::calculateBurnRate(int accountAgeDays) {
double burnRate = 99.0 * std::exp(-static_cast<double>(accountAgeDays) / 180.0);
return std::max(1.0, burnRate);
}
// Calculate account age in days from created_at timestamp
int UserController::calculateAccountAgeDays(const std::string& createdAt) {
try {
// Parse ISO 8601 timestamp (e.g., "2025-01-15T10:30:00+00:00")
std::tm tm = {};
std::istringstream ss(createdAt);
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
if (ss.fail()) {
LOG_WARN << "Failed to parse created_at timestamp: " << createdAt;
return 0;
}
std::time_t createdTime = std::mktime(&tm);
std::time_t now = std::time(nullptr);
// Calculate difference in days
double diffSeconds = std::difftime(now, createdTime);
int diffDays = static_cast<int>(diffSeconds / (60 * 60 * 24));
return std::max(0, diffDays);
} catch (const std::exception& e) {
LOG_ERROR << "Exception in calculateAccountAgeDays: " << e.what();
return 0;
}
}
void UserController::sendUbercoin(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo sender = getUserFromRequest(req);
if (sender.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
if (!(*json).isMember("recipient") || !(*json).isMember("amount")) {
callback(jsonError("Missing recipient or amount"));
return;
}
std::string recipientUsername = (*json)["recipient"].asString();
double amount = (*json)["amount"].asDouble();
// Validate amount (positive, max 3 decimal places)
if (amount <= 0) {
callback(jsonError("Amount must be positive"));
return;
}
// Round to 3 decimal places (ceiling)
amount = std::ceil(amount * 1000.0) / 1000.0;
if (amount > 1000000000.0) {
callback(jsonError("Amount exceeds maximum allowed"));
return;
}
auto dbClient = app().getDbClient();
// Get sender's balance
*dbClient << "SELECT ubercoin_balance FROM users WHERE id = $1"
<< sender.id
>> [dbClient, sender, recipientUsername, amount, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Sender not found"));
return;
}
double senderBalance = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
if (senderBalance < amount) {
callback(jsonError("Insufficient balance"));
return;
}
// Get recipient info
*dbClient << "SELECT id, username, created_at, ubercoin_balance FROM users WHERE username = $1"
<< recipientUsername
>> [dbClient, sender, amount, senderBalance, callback](const Result& r2) {
if (r2.empty()) {
callback(jsonError("Recipient not found", k404NotFound));
return;
}
int64_t recipientId = r2[0]["id"].as<int64_t>();
// Prevent self-tipping
if (recipientId == sender.id) {
callback(jsonError("Cannot send übercoin to yourself"));
return;
}
std::string createdAt = r2[0]["created_at"].as<std::string>();
double recipientBalance = r2[0]["ubercoin_balance"].isNull() ? 0.0 : r2[0]["ubercoin_balance"].as<double>();
// Calculate account age and burn rate
UserController ctrl;
int accountAgeDays = ctrl.calculateAccountAgeDays(createdAt);
double burnRate = ctrl.calculateBurnRate(accountAgeDays);
// Calculate amounts (ceiling for received amount)
double burnedAmount = amount * burnRate / 100.0;
double receivedAmount = amount - burnedAmount;
// Round to 3 decimal places (ceiling for received)
receivedAmount = std::ceil(receivedAmount * 1000.0) / 1000.0;
burnedAmount = amount - receivedAmount;
double newSenderBalance = senderBalance - amount;
double newRecipientBalance = recipientBalance + receivedAmount;
// Begin transaction: deduct from sender, add to recipient, add burned to treasury
*dbClient << "UPDATE users SET ubercoin_balance = $1 WHERE id = $2"
<< newSenderBalance << sender.id
>> [dbClient, recipientId, newRecipientBalance, burnedAmount, amount, receivedAmount, burnRate, newSenderBalance, callback](const Result&) {
*dbClient << "UPDATE users SET ubercoin_balance = $1 WHERE id = $2"
<< newRecipientBalance << recipientId
>> [dbClient, burnedAmount, amount, receivedAmount, burnRate, newSenderBalance, callback](const Result&) {
// Add burned amount to treasury
*dbClient << "UPDATE ubercoin_treasury SET balance = balance + $1 WHERE id = 1 RETURNING balance"
<< burnedAmount
>> [amount, receivedAmount, burnedAmount, burnRate, newSenderBalance, callback](const Result& r3) {
double treasuryBalance = 0.0;
if (!r3.empty()) {
treasuryBalance = r3[0]["balance"].as<double>();
}
Json::Value resp;
resp["success"] = true;
resp["sent"] = amount;
resp["burned"] = burnedAmount;
resp["received"] = receivedAmount;
resp["burnRatePercent"] = burnRate;
resp["newBalance"] = newSenderBalance;
resp["treasuryBalance"] = treasuryBalance;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update treasury", "Failed to complete transaction");
}
>> DB_ERROR_MSG(callback, "update recipient balance", "Failed to complete transaction");
}
>> DB_ERROR_MSG(callback, "update sender balance", "Failed to complete transaction");
}
>> DB_ERROR(callback, "get recipient");
}
>> DB_ERROR(callback, "get sender balance");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in sendUbercoin: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::previewUbercoin(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo sender = getUserFromRequest(req);
if (sender.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
if (!(*json).isMember("recipient") || !(*json).isMember("amount")) {
callback(jsonError("Missing recipient or amount"));
return;
}
std::string recipientUsername = (*json)["recipient"].asString();
double amount = (*json)["amount"].asDouble();
if (amount <= 0) {
callback(jsonError("Amount must be positive"));
return;
}
// Round to 3 decimal places (ceiling)
amount = std::ceil(amount * 1000.0) / 1000.0;
auto dbClient = app().getDbClient();
*dbClient << "SELECT created_at FROM users WHERE username = $1"
<< recipientUsername
>> [amount, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("User not found", k404NotFound));
return;
}
std::string createdAt = r[0]["created_at"].as<std::string>();
UserController ctrl;
int accountAgeDays = ctrl.calculateAccountAgeDays(createdAt);
double burnRate = ctrl.calculateBurnRate(accountAgeDays);
double burnedAmount = amount * burnRate / 100.0;
double receivedAmount = amount - burnedAmount;
// Round to 3 decimal places (ceiling for received)
receivedAmount = std::ceil(receivedAmount * 1000.0) / 1000.0;
burnedAmount = amount - receivedAmount;
Json::Value resp;
resp["success"] = true;
resp["amount"] = amount;
resp["burnRatePercent"] = burnRate;
resp["burnedAmount"] = burnedAmount;
resp["receivedAmount"] = receivedAmount;
resp["recipientAccountAgeDays"] = accountAgeDays;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user for preview");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in previewUbercoin: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::getTreasury(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
auto dbClient = app().getDbClient();
// Get treasury info and user count
*dbClient << "SELECT balance, total_destroyed, last_growth_at, last_distribution_at FROM ubercoin_treasury WHERE id = 1"
>> [dbClient, callback](const Result& r) {
double balance = 0.0;
double totalDestroyed = 0.0;
std::string lastGrowthAt;
std::string lastDistributionAt;
if (!r.empty()) {
balance = r[0]["balance"].isNull() ? 0.0 : r[0]["balance"].as<double>();
totalDestroyed = r[0]["total_destroyed"].isNull() ? 0.0 : r[0]["total_destroyed"].as<double>();
lastGrowthAt = r[0]["last_growth_at"].isNull() ? "" : r[0]["last_growth_at"].as<std::string>();
lastDistributionAt = r[0]["last_distribution_at"].isNull() ? "" : r[0]["last_distribution_at"].as<std::string>();
}
// Get total user count
*dbClient << "SELECT COUNT(*) as user_count FROM users"
>> [balance, totalDestroyed, lastGrowthAt, lastDistributionAt, callback](const Result& r2) {
int64_t totalUsers = 0;
if (!r2.empty()) {
totalUsers = r2[0]["user_count"].as<int64_t>();
}
// Calculate estimated share per user
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0;
estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.0;
// Calculate next Sunday (next distribution) in UTC
std::time_t now = std::time(nullptr);
std::tm nowUtc;
#ifdef _WIN32
gmtime_s(&nowUtc, &now);
#else
gmtime_r(&now, &nowUtc);
#endif
// Days until Sunday (0 = Sunday)
// If today is Sunday, next distribution is next Sunday (7 days)
int daysUntilSunday = (7 - nowUtc.tm_wday) % 7;
if (daysUntilSunday == 0) daysUntilSunday = 7;
// Calculate next Sunday at midnight UTC
std::tm nextSundayTm = nowUtc;
nextSundayTm.tm_mday += daysUntilSunday;
nextSundayTm.tm_hour = 0;
nextSundayTm.tm_min = 0;
nextSundayTm.tm_sec = 0;
// Normalize the tm struct (handles month overflow etc)
// Note: timegm is the UTC version of mktime
#ifdef _WIN32
std::time_t nextSunday = _mkgmtime(&nextSundayTm);
#else
std::time_t nextSunday = timegm(&nextSundayTm);
#endif
// Format as ISO 8601 UTC
char nextDistBuffer[32];
std::tm nextSundayUtc;
#ifdef _WIN32
gmtime_s(&nextSundayUtc, &nextSunday);
#else
gmtime_r(&nextSunday, &nextSundayUtc);
#endif
std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtc);
Json::Value resp;
resp["success"] = true;
resp["balance"] = balance;
resp["totalDestroyed"] = totalDestroyed;
resp["totalUsers"] = static_cast<Json::Int64>(totalUsers);
resp["estimatedShare"] = estimatedShare;
resp["nextDistribution"] = std::string(nextDistBuffer);
resp["lastGrowthAt"] = lastGrowthAt;
resp["lastDistributionAt"] = lastDistributionAt;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user count");
}
>> DB_ERROR(callback, "get treasury");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getTreasury: " << e.what();
callback(jsonError("Internal server error"));
}
}
// Treasury cron: Apply 3.3% daily growth (should be called by cron at midnight Mon-Sat)
void UserController::treasuryApplyGrowth(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
// Verify admin access
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized - Admin only", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
// Apply 3.3% growth to treasury balance
*dbClient << "UPDATE ubercoin_treasury SET balance = balance * 1.033, last_growth_at = NOW() WHERE id = 1 RETURNING balance"
>> [callback](const Result& r) {
double newBalance = 0.0;
if (!r.empty()) {
newBalance = r[0]["balance"].as<double>();
}
Json::Value resp;
resp["success"] = true;
resp["message"] = "Treasury growth applied (3.3%)";
resp["newBalance"] = newBalance;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "apply treasury growth");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in treasuryApplyGrowth: " << e.what();
callback(jsonError("Internal server error"));
}
}
// Treasury cron: Distribute to all users (should be called by cron on Sunday at midnight)
void UserController::treasuryDistribute(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
// Verify admin access
UserInfo user = getUserFromRequest(req);
if (user.id == 0 || !user.isAdmin) {
callback(jsonError("Unauthorized - Admin only", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
// Get treasury balance and user count
*dbClient << "SELECT balance FROM ubercoin_treasury WHERE id = 1"
>> [dbClient, callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Treasury not found"));
return;
}
double treasuryBalance = r[0]["balance"].as<double>();
if (treasuryBalance <= 0) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Treasury empty, nothing to distribute";
resp["distributed"] = 0;
resp["destroyed"] = 0;
callback(jsonResp(resp));
return;
}
// Get all users with their created_at for burn rate calculation
*dbClient << "SELECT id, created_at, ubercoin_balance FROM users"
>> [dbClient, treasuryBalance, callback](const Result& users) {
if (users.empty()) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "No users to distribute to";
resp["distributed"] = 0;
resp["destroyed"] = 0;
callback(jsonResp(resp));
return;
}
int64_t userCount = users.size();
double sharePerUser = treasuryBalance / static_cast<double>(userCount);
double totalDistributed = 0.0;
double totalDestroyed = 0.0;
UserController ctrl;
// Calculate distributions for each user
for (const auto& row : users) {
int64_t userId = row["id"].as<int64_t>();
std::string createdAt = row["created_at"].as<std::string>();
double currentBalance = row["ubercoin_balance"].isNull() ? 0.0 : row["ubercoin_balance"].as<double>();
int accountAgeDays = ctrl.calculateAccountAgeDays(createdAt);
double burnRate = ctrl.calculateBurnRate(accountAgeDays);
// Calculate received amount (after burn) - ceiling for user benefit
double receivedAmount = sharePerUser * (100.0 - burnRate) / 100.0;
receivedAmount = std::ceil(receivedAmount * 1000.0) / 1000.0;
double destroyedAmount = sharePerUser - receivedAmount;
double newBalance = currentBalance + receivedAmount;
// Update user balance
*dbClient << "UPDATE users SET ubercoin_balance = $1 WHERE id = $2"
<< newBalance << userId
>> [](const Result&) {}
>> [](const DrogonDbException& e) {
LOG_ERROR << "Failed to update user balance in distribution: " << e.base().what();
};
totalDistributed += receivedAmount;
totalDestroyed += destroyedAmount;
}
// Reset treasury balance to 0 and update total_destroyed
*dbClient << "UPDATE ubercoin_treasury SET balance = 0, total_destroyed = total_destroyed + $1, last_distribution_at = NOW() WHERE id = 1"
<< totalDestroyed
>> [totalDistributed, totalDestroyed, userCount, callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Treasury distributed successfully";
resp["userCount"] = static_cast<Json::Int64>(userCount);
resp["distributed"] = totalDistributed;
resp["destroyed"] = totalDestroyed;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "reset treasury");
}
>> DB_ERROR(callback, "get users for distribution");
}
>> DB_ERROR(callback, "get treasury balance");
} catch (const std::exception& e) {
LOG_ERROR << "Exception in treasuryDistribute: " << e.what();
callback(jsonError("Internal server error"));
}
}
// ============================================
// REFERRAL CODE SYSTEM
// ============================================
namespace {
const double REFERRAL_CODE_COST = 500.0; // Ubercoin cost for purchasing a referral code
}
std::string UserController::generateReferralCode(int length) {
// Charset excludes confusing chars: 0/O, 1/I
static const char charset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
static const size_t charsetLen = sizeof(charset) - 1;
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dist(0, charsetLen - 1);
std::string code;
code.reserve(length);
for (int i = 0; i < length; ++i) {
code += charset[dist(gen)];
}
return code;
}
void UserController::getReferralCodes(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 rc.id, rc.code, rc.is_used, rc.created_at, rc.used_at, "
"u.username as used_by_username "
"FROM referral_codes rc "
"LEFT JOIN users u ON rc.used_by = u.id "
"WHERE rc.owner_id = $1 "
"ORDER BY rc.created_at DESC"
<< user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value codes(Json::arrayValue);
for (const auto& row : r) {
Json::Value code;
code["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
code["code"] = row["code"].as<std::string>();
code["isUsed"] = row["is_used"].as<bool>();
code["createdAt"] = row["created_at"].as<std::string>();
if (!row["used_at"].isNull()) {
code["usedAt"] = row["used_at"].as<std::string>();
}
if (!row["used_by_username"].isNull()) {
code["usedByUsername"] = row["used_by_username"].as<std::string>();
}
codes.append(code);
}
resp["codes"] = codes;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get referral codes", "Failed to get referral codes");
}
void UserController::purchaseReferralCode(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();
// Check user balance first
*dbClient << "SELECT ubercoin_balance FROM users WHERE id = $1"
<< user.id
>> [this, callback, dbClient, userId = user.id](const Result& r) {
if (r.empty()) {
callback(jsonError("User not found"));
return;
}
double balance = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
if (balance < REFERRAL_CODE_COST) {
callback(jsonError("Insufficient ubercoin balance. You need 500 ubercoin."));
return;
}
// Deduct balance
*dbClient << "UPDATE users SET ubercoin_balance = ubercoin_balance - $1 WHERE id = $2"
<< REFERRAL_CODE_COST << userId
>> [this, callback, dbClient, userId](const Result&) {
// Generate unique code with retry
std::string code = generateReferralCode();
// Insert code
*dbClient << "INSERT INTO referral_codes (owner_id, code) "
"VALUES ($1, $2) RETURNING id, code, created_at"
<< userId << code
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["code"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
resp["code"]["code"] = r[0]["code"].as<std::string>();
resp["code"]["createdAt"] = r[0]["created_at"].as<std::string>();
resp["code"]["isUsed"] = false;
resp["cost"] = REFERRAL_CODE_COST;
callback(jsonResp(resp));
}
>> [callback, dbClient, userId](const DrogonDbException& e) {
// Refund on failure
LOG_ERROR << "Failed to create referral code: " << e.base().what();
*dbClient << "UPDATE users SET ubercoin_balance = ubercoin_balance + $1 WHERE id = $2"
<< REFERRAL_CODE_COST << userId
>> [](const Result&) {}
>> [](const DrogonDbException&) {};
callback(jsonError("Failed to create referral code. Ubercoin refunded."));
};
}
>> DB_ERROR_MSG(callback, "deduct balance", "Failed to deduct balance");
}
>> DB_ERROR_MSG(callback, "check balance", "Failed to check balance");
}
void UserController::validateReferralCode(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr || !(*jsonPtr).isMember("code")) {
callback(jsonError("Code required"));
return;
}
std::string code = (*jsonPtr)["code"].asString();
// Normalize code to uppercase
std::transform(code.begin(), code.end(), code.begin(), ::toupper);
auto dbClient = app().getDbClient();
*dbClient << "SELECT id FROM referral_codes WHERE code = $1 AND is_used = false"
<< code
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["valid"] = !r.empty();
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "validate code", "Failed to validate code");
}
void UserController::registerWithReferral(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto dbClient = app().getDbClient();
// Check referral system is enabled and registration is disabled
*dbClient << "SELECT registration_enabled, referral_system_enabled FROM chat_settings WHERE id = 1"
>> [req, callback, dbClient](const Result& r) {
bool regEnabled = r.empty() ? true : r[0]["registration_enabled"].as<bool>();
bool refEnabled = r.empty() ? false :
(r[0]["referral_system_enabled"].isNull() ? false : r[0]["referral_system_enabled"].as<bool>());
if (regEnabled) {
callback(jsonError("Standard registration is available"));
return;
}
if (!refEnabled) {
callback(jsonError("Referral registration is not enabled"));
return;
}
// Parse request
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
callback(jsonError("Invalid JSON"));
return;
}
const auto& json = *jsonPtr;
std::string code = json.get("code", "").asString();
std::string username = json.get("username", "").asString();
std::string password = json.get("password", "").asString();
std::string publicKey = json.get("publicKey", "").asString();
std::string fingerprint = json.get("fingerprint", "").asString();
if (code.empty() || username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing required fields"));
return;
}
// Normalize code
std::transform(code.begin(), code.end(), code.begin(), ::toupper);
// Validate code exists and is unused
*dbClient << "SELECT id FROM referral_codes WHERE code = $1 AND is_used = false"
<< code
>> [callback, dbClient, code, username, password, publicKey, fingerprint](const Result& codeResult) {
if (codeResult.empty()) {
callback(jsonError("Invalid or already used referral code"));
return;
}
int64_t codeId = codeResult[0]["id"].as<int64_t>();
// Perform registration using AuthService
AuthService::getInstance().registerUser(username, password, publicKey, fingerprint,
[callback, dbClient, codeId](bool success, const std::string& error, int64_t userId) {
if (!success) {
callback(jsonError(error));
return;
}
// Mark code as used
*dbClient << "UPDATE referral_codes SET is_used = true, used_by = $1, "
"used_at = CURRENT_TIMESTAMP WHERE id = $2"
<< userId << codeId
>> [callback, userId](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["userId"] = static_cast<Json::Int64>(userId);
resp["message"] = "Registration successful. Please login.";
callback(jsonResp(resp));
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to mark referral code as used: " << e.base().what();
// Registration succeeded but marking failed - still return success
Json::Value resp;
resp["success"] = true;
resp["message"] = "Registration successful. Please login.";
callback(jsonResp(resp));
};
});
}
>> DB_ERROR_MSG(callback, "validate code", "Failed to validate referral code");
}
>> DB_ERROR_MSG(callback, "check settings", "Failed to check registration settings");
}
void UserController::getReferralSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
// Public endpoint - no auth required
auto dbClient = app().getDbClient();
*dbClient << "SELECT registration_enabled, referral_system_enabled FROM chat_settings WHERE id = 1"
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
if (r.empty()) {
resp["registrationEnabled"] = true;
resp["referralSystemEnabled"] = false;
} else {
resp["registrationEnabled"] = r[0]["registration_enabled"].as<bool>();
resp["referralSystemEnabled"] = r[0]["referral_system_enabled"].isNull()
? false : r[0]["referral_system_enabled"].as<bool>();
}
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get settings", "Failed to get settings");
2025-08-03 21:53:15 -04:00
}