#include "UserController.h" #include "../services/DatabaseService.h" #include "../services/CensorService.h" #include "../common/HttpHelpers.h" #include "../common/AuthHelpers.h" #include "../common/FileUtils.h" #include "../common/FileValidation.h" #include #include #include #include #include #include #include #include #include #include using namespace drogon::orm; namespace { // 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(apiKey.c_str()), apiKey.length(), hash); std::stringstream ss; for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { ss << std::hex << std::setfill('0') << std::setw(2) << static_cast(hash[i]); } return ss.str(); } // 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); } } void UserController::register_(const HttpRequestPtr &req, std::function &&callback) { try { LOG_DEBUG << "Registration request received"; // 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(); } 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(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"); } catch (const std::exception& e) { LOG_ERROR << "Exception in register_: " << e.what(); callback(jsonError("Internal server error")); } catch (...) { LOG_ERROR << "Unknown exception in register_"; callback(jsonError("Internal server error")); } } void UserController::login(const HttpRequestPtr &req, std::function &&callback) { try { LOG_DEBUG << "Login request received"; auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } // Check if fields exist before accessing if (!(*json).isMember("username") || !(*json).isMember("password")) { callback(jsonError("Missing credentials")); return; } std::string username = (*json)["username"].asString(); std::string password = (*json)["password"].asString(); if (username.empty() || password.empty()) { callback(jsonError("Missing credentials")); return; } LOG_INFO << "Login attempt for user: " << username; AuthService::getInstance().loginUser(username, password, [callback, username](bool success, const std::string& token, const UserInfo& user) { if (success) { LOG_INFO << "Login successful for user: " << username; Json::Value resp; resp["success"] = true; // Send token in body for WebSocket auth (cookie still used for HTTP requests) resp["token"] = token; resp["user"]["id"] = static_cast(user.id); resp["user"]["username"] = user.username; resp["user"]["isAdmin"] = user.isAdmin; resp["user"]["isStreamer"] = user.isStreamer; resp["user"]["isRestreamer"] = user.isRestreamer; resp["user"]["isBot"] = user.isBot; resp["user"]["isTexter"] = user.isTexter; resp["user"]["isPgpOnly"] = user.isPgpOnly; resp["user"]["bio"] = user.bio; resp["user"]["avatarUrl"] = user.avatarUrl; 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; resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; resp["user"]["colorCode"] = user.colorCode; auto response = jsonResp(resp); setAuthCookie(response, token); callback(response); } else { LOG_WARN << "Login failed for user: " << username; callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized)); } }); } catch (const std::exception& e) { LOG_ERROR << "Exception in login: " << e.what(); callback(jsonError("Internal server error")); } catch (...) { LOG_ERROR << "Unknown exception in login"; callback(jsonError("Internal server error")); } } void UserController::logout(const HttpRequestPtr &req, std::function &&callback) { try { Json::Value resp; resp["success"] = true; resp["message"] = "Logged out successfully"; auto response = jsonResp(resp); clearAuthCookie(response); callback(response); } catch (const std::exception& e) { LOG_ERROR << "Exception in logout: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::pgpChallenge(const HttpRequestPtr &req, std::function &&callback) { try { auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } if (!(*json).isMember("username")) { callback(jsonError("Username required")); return; } std::string username = (*json)["username"].asString(); if (username.empty()) { callback(jsonError("Username required")); return; } AuthService::getInstance().initiatePgpLogin(username, [callback](bool success, const std::string& challenge, const std::string& publicKey) { if (success) { Json::Value resp; resp["success"] = true; resp["challenge"] = challenge; resp["publicKey"] = publicKey; callback(jsonResp(resp)); } else { callback(jsonError("User not found or PGP not enabled", k404NotFound)); } }); } catch (const std::exception& e) { LOG_ERROR << "Exception in pgpChallenge: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::pgpVerify(const HttpRequestPtr &req, std::function &&callback) { try { auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } if (!(*json).isMember("username") || !(*json).isMember("signature") || !(*json).isMember("challenge")) { callback(jsonError("Missing required fields")); return; } std::string username = (*json)["username"].asString(); std::string signature = (*json)["signature"].asString(); std::string challenge = (*json)["challenge"].asString(); if (username.empty() || signature.empty() || challenge.empty()) { callback(jsonError("Missing required fields")); return; } AuthService::getInstance().verifyPgpLogin(username, signature, challenge, [callback](bool success, const std::string& token, const UserInfo& user) { if (success) { Json::Value resp; resp["success"] = true; // Send token in body for WebSocket auth (cookie still used for HTTP requests) resp["token"] = token; resp["user"]["id"] = static_cast(user.id); resp["user"]["username"] = user.username; resp["user"]["isAdmin"] = user.isAdmin; resp["user"]["isStreamer"] = user.isStreamer; resp["user"]["isRestreamer"] = user.isRestreamer; resp["user"]["isBot"] = user.isBot; resp["user"]["isTexter"] = user.isTexter; resp["user"]["isPgpOnly"] = user.isPgpOnly; resp["user"]["bio"] = user.bio; resp["user"]["avatarUrl"] = user.avatarUrl; 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; resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; resp["user"]["colorCode"] = user.colorCode; auto response = jsonResp(resp); setAuthCookie(response, token); callback(response); } else { callback(jsonError("Invalid signature", k401Unauthorized)); } }); } catch (const std::exception& e) { LOG_ERROR << "Exception in pgpVerify: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::getCurrentUser(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT id, username, is_admin, is_streamer, is_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 " "FROM users WHERE id = $1" << user.id >> [callback](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; } Json::Value resp; resp["success"] = true; resp["user"]["id"] = static_cast(r[0]["id"].as()); resp["user"]["username"] = r[0]["username"].as(); resp["user"]["isAdmin"] = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as(); resp["user"]["isStreamer"] = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as(); resp["user"]["isRestreamer"] = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as(); resp["user"]["isBot"] = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as(); resp["user"]["isTexter"] = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as(); resp["user"]["isStickerCreator"] = r[0]["is_sticker_creator"].isNull() ? false : r[0]["is_sticker_creator"].as(); resp["user"]["isUploader"] = r[0]["is_uploader"].isNull() ? false : r[0]["is_uploader"].as(); resp["user"]["isWatchCreator"] = r[0]["is_watch_creator"].isNull() ? false : r[0]["is_watch_creator"].as(); resp["user"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as(); resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); resp["user"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); resp["user"]["bannerUrl"] = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as(); resp["user"]["bannerPosition"] = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as(); resp["user"]["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as(); resp["user"]["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as(); resp["user"]["graffitiUrl"] = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as(); resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as(); resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as(); callback(jsonResp(resp)); } >> DB_ERROR(callback, "get user data"); } catch (const std::exception& e) { LOG_ERROR << "Exception in getCurrentUser: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::getToken(const HttpRequestPtr &req, std::function &&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")); } } void UserController::updateProfile(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } std::string bio = (*json).isMember("bio") ? (*json)["bio"].asString() : ""; 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; } auto dbClient = app().getDbClient(); // 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(); resp["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as(); resp["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as(); } 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"); } } catch (const std::exception& e) { LOG_ERROR << "Exception in updateProfile: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::updatePassword(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } if (!(*json).isMember("oldPassword") || !(*json).isMember("newPassword")) { callback(jsonError("Missing passwords")); return; } std::string oldPassword = (*json)["oldPassword"].asString(); std::string newPassword = (*json)["newPassword"].asString(); if (oldPassword.empty() || newPassword.empty()) { callback(jsonError("Missing passwords")); return; } AuthService::getInstance().updatePassword(user.id, oldPassword, newPassword, [callback](bool success, const std::string& error) { if (success) { Json::Value resp; resp["success"] = true; callback(jsonResp(resp)); } else { callback(jsonError(error)); } }); } catch (const std::exception& e) { LOG_ERROR << "Exception in updatePassword: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::togglePgpOnly(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } bool enable = (*json).isMember("enable") ? (*json)["enable"].asBool() : false; auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at" << enable << user.id >> [callback, enable](const Result& r) { Json::Value resp; resp["success"] = true; resp["pgpOnly"] = enable; // Return the timestamp if it was just enabled if (enable && !r.empty() && !r[0]["pgp_only_enabled_at"].isNull()) { resp["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].as(); } callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update PGP setting", "Failed to update setting"); } catch (const std::exception& e) { LOG_ERROR << "Exception in togglePgpOnly: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::addPgpKey(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } if (!(*json).isMember("publicKey") || !(*json).isMember("fingerprint")) { callback(jsonError("Missing key data")); return; } std::string publicKey = (*json)["publicKey"].asString(); std::string fingerprint = (*json)["fingerprint"].asString(); std::string origin = (*json).get("origin", "imported").asString(); // Validate origin value if (origin != "generated" && origin != "imported") { origin = "imported"; } if (publicKey.empty() || fingerprint.empty()) { callback(jsonError("Missing key data")); return; } auto dbClient = app().getDbClient(); // Check if fingerprint already exists *dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1" << fingerprint >> [dbClient, user, publicKey, fingerprint, origin, callback](const Result& r) { if (!r.empty()) { callback(jsonError("This PGP key is already registered")); return; } *dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint, key_origin) VALUES ($1, $2, $3, $4)" << user.id << publicKey << fingerprint << origin >> [callback](const Result&) { Json::Value resp; resp["success"] = true; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "add PGP key", "Failed to add PGP key"); } >> DB_ERROR(callback, "check PGP key"); } catch (const std::exception& e) { LOG_ERROR << "Exception in addPgpKey: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::getPgpKeys(const HttpRequestPtr &req, std::function &&callback) { 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 " "WHERE user_id = $1 ORDER BY created_at DESC" << user.id >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value keys(Json::arrayValue); for (const auto& row : r) { Json::Value key; key["publicKey"] = row["public_key"].as(); key["fingerprint"] = row["fingerprint"].as(); key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as(); key["createdAt"] = row["created_at"].as(); keys.append(key); } resp["keys"] = keys; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get PGP keys", "Failed to get PGP keys"); } catch (const std::exception& e) { LOG_ERROR << "Exception in getPgpKeys: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::uploadAvatar(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } MultiPartParser parser; parser.parse(req); if (parser.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } const auto& file = parser.getFiles()[0]; // Validate file size (250KB max) if (file.fileLength() > 250 * 1024) { callback(jsonError("File too large (max 250KB)")); return; } // Validate file 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") { 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/avatars"; 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 avatar correctly")); return; } // Set file permissions to 644 std::filesystem::permissions(fullPath, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::group_read | std::filesystem::perms::others_read ); LOG_INFO << "Avatar saved successfully to: " << fullPath; LOG_INFO << "File size: " << std::filesystem::file_size(fullPath) << " bytes"; } catch (const std::exception& e) { LOG_ERROR << "Exception while saving avatar: " << e.what(); // Clean up any partial files/directories if (std::filesystem::exists(fullPath)) { std::filesystem::remove_all(fullPath); } callback(jsonError("Failed to save avatar")); return; } // Store as proper URL path std::string avatarUrl = "/uploads/avatars/" + filename; // First get old avatar path, then update and clean up auto dbClient = app().getDbClient(); *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(); } // 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"); } >> DB_ERROR(callback, "get old avatar"); } catch (const std::exception& e) { LOG_ERROR << "Exception in uploadAvatar: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::uploadBanner(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } MultiPartParser parser; parser.parse(req); if (parser.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } const auto& file = parser.getFiles()[0]; // Validate file size (500KB max for banners) if (file.fileLength() > 500 * 1024) { callback(jsonError("File too large (max 500KB)")); return; } // 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.")); return; } // 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 &&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(); resp["profile"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as(); resp["profile"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); resp["profile"]["bannerUrl"] = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as(); resp["profile"]["bannerPosition"] = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as(); resp["profile"]["bannerZoom"] = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as(); resp["profile"]["bannerPositionX"] = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as(); resp["profile"]["graffitiUrl"] = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as(); resp["profile"]["createdAt"] = r[0]["created_at"].as(); resp["profile"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as(); resp["profile"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as(); resp["profile"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); resp["profile"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as(); 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 &&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 " "FROM pgp_keys pk JOIN users u ON pk.user_id = u.id " "WHERE u.username = $1 ORDER BY pk.created_at DESC" << username >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value keys(Json::arrayValue); for (const auto& row : r) { Json::Value key; key["publicKey"] = row["public_key"].as(); key["fingerprint"] = row["fingerprint"].as(); key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as(); key["createdAt"] = row["created_at"].as(); keys.append(key); } 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 &&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(updatedUser.id); resp["user"]["username"] = updatedUser.username; resp["user"]["isAdmin"] = updatedUser.isAdmin; resp["user"]["isStreamer"] = updatedUser.isStreamer; resp["user"]["colorCode"] = updatedUser.colorCode; auto response = jsonResp(resp); // Update auth cookie with new token setAuthCookie(response, newToken); callback(response); } else { // Color was updated but couldn't fetch full user info Json::Value resp; resp["success"] = true; resp["color"] = finalColor; resp["message"] = "Color updated but please refresh for new token"; callback(jsonResp(resp)); } }); } else { callback(jsonError(error)); } }); } catch (const std::exception& e) { LOG_ERROR << "Exception in updateColor: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::getAvailableColors(const HttpRequestPtr &, std::function &&callback) { try { // Define available colors for user profiles Json::Value resp; resp["success"] = true; Json::Value colors(Json::arrayValue); // Add predefined color options colors.append("#561D5E"); // Default purple colors.append("#1E88E5"); // Blue colors.append("#43A047"); // Green colors.append("#E53935"); // Red colors.append("#FB8C00"); // Orange colors.append("#8E24AA"); // Purple variant colors.append("#00ACC1"); // Cyan colors.append("#FFB300"); // Amber colors.append("#546E7A"); // Blue Grey colors.append("#D81B60"); // Pink resp["colors"] = colors; callback(jsonResp(resp)); } catch (const std::exception& e) { LOG_ERROR << "Exception in getAvailableColors: " << e.what(); callback(jsonError("Internal server error")); } } void UserController::getBotApiKeys(const HttpRequestPtr &req, std::function &&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()) { 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(row["id"].as()); key["name"] = row["name"].as(); key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as(); key["createdAt"] = row["created_at"].as(); key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as(); key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as(); 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 &&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()) { callback(jsonError("Bot role required", k403Forbidden)); return; } // SECURITY FIX: Enforce max 2 API keys per user int64_t keyCount = r[0]["key_count"].as(); 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(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(r2[0]["id"].as()); resp["key"]["name"] = keyName; resp["key"]["apiKey"] = apiKey; // Only shown once! resp["key"]["expiresAt"] = r2[0]["expires_at"].as(); 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 &&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 &&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(row["key_id"].as()); resp["userId"] = static_cast(row["user_id"].as()); resp["username"] = row["username"].as(); resp["userColor"] = row["user_color"].isNull() ? "#808080" : row["user_color"].as(); resp["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); resp["scopes"] = row["scopes"].isNull() ? "chat:rw" : row["scopes"].as(); callback(jsonResp(resp)); // Update last_used_at asynchronously int64_t keyId = row["key_id"].as(); 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 &&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(); 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 &&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()) { 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("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(row["id"].as()); sub["name"] = row["name"].as(); sub["filePath"] = row["file_path"].as(); sub["createdAt"] = row["created_at"].as(); 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 &&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()) { 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(row["id"].as()); sub["name"] = row["name"].as(); sub["filePath"] = row["file_path"].as(); sub["status"] = row["status"].as(); sub["denialReason"] = row["denial_reason"].isNull() ? "" : row["denial_reason"].as(); sub["createdAt"] = row["created_at"].as(); 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 &&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(); 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 &&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(); 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(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(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 &&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(); 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(); // Prevent self-tipping if (recipientId == sender.id) { callback(jsonError("Cannot send übercoin to yourself")); return; } std::string createdAt = r2[0]["created_at"].as(); double recipientBalance = r2[0]["ubercoin_balance"].isNull() ? 0.0 : r2[0]["ubercoin_balance"].as(); // 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(); } 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 &&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(); 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 &&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(); totalDestroyed = r[0]["total_destroyed"].isNull() ? 0.0 : r[0]["total_destroyed"].as(); lastGrowthAt = r[0]["last_growth_at"].isNull() ? "" : r[0]["last_growth_at"].as(); lastDistributionAt = r[0]["last_distribution_at"].isNull() ? "" : r[0]["last_distribution_at"].as(); } // 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(); } // Calculate estimated share per user double estimatedShare = totalUsers > 0 ? balance / static_cast(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(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 &&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(); } 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 &&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(); 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(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(); std::string createdAt = row["created_at"].as(); double currentBalance = row["ubercoin_balance"].isNull() ? 0.0 : row["ubercoin_balance"].as(); 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(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 &&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(row["id"].as()); code["code"] = row["code"].as(); code["isUsed"] = row["is_used"].as(); code["createdAt"] = row["created_at"].as(); if (!row["used_at"].isNull()) { code["usedAt"] = row["used_at"].as(); } if (!row["used_by_username"].isNull()) { code["usedByUsername"] = row["used_by_username"].as(); } 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 &&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(); 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(r[0]["id"].as()); resp["code"]["code"] = r[0]["code"].as(); resp["code"]["createdAt"] = r[0]["created_at"].as(); 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 &&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 &&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 refEnabled = r.empty() ? false : (r[0]["referral_system_enabled"].isNull() ? false : r[0]["referral_system_enabled"].as()); 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(); // 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(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 &&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(); resp["referralSystemEnabled"] = r[0]["referral_system_enabled"].isNull() ? false : r[0]["referral_system_enabled"].as(); } callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get settings", "Failed to get settings"); }