2588 lines
No EOL
123 KiB
C++
2588 lines
No EOL
123 KiB
C++
#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 <drogon/MultiPart.h>
|
|
#include <drogon/Cookie.h>
|
|
#include <fstream>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <iomanip>
|
|
#include <filesystem>
|
|
#include <openssl/rand.h>
|
|
#include <openssl/sha.h>
|
|
#include <regex>
|
|
|
|
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<const unsigned char*>(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<int>(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<void(const HttpResponsePtr &)> &&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<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");
|
|
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in register_: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
} catch (...) {
|
|
LOG_ERROR << "Unknown exception in register_";
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::login(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
LOG_DEBUG << "Login request received";
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
// Check if fields exist before accessing
|
|
if (!(*json).isMember("username") || !(*json).isMember("password")) {
|
|
callback(jsonError("Missing credentials"));
|
|
return;
|
|
}
|
|
|
|
std::string username = (*json)["username"].asString();
|
|
std::string password = (*json)["password"].asString();
|
|
|
|
if (username.empty() || password.empty()) {
|
|
callback(jsonError("Missing credentials"));
|
|
return;
|
|
}
|
|
|
|
LOG_INFO << "Login attempt for user: " << username;
|
|
|
|
AuthService::getInstance().loginUser(username, password,
|
|
[callback, username](bool success, const std::string& token, const UserInfo& user) {
|
|
if (success) {
|
|
LOG_INFO << "Login successful for user: " << username;
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
|
resp["token"] = token;
|
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
|
resp["user"]["username"] = user.username;
|
|
resp["user"]["isAdmin"] = user.isAdmin;
|
|
resp["user"]["isStreamer"] = user.isStreamer;
|
|
resp["user"]["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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Logged out successfully";
|
|
|
|
auto response = jsonResp(resp);
|
|
clearAuthCookie(response);
|
|
callback(response);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in logout: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("username")) {
|
|
callback(jsonError("Username required"));
|
|
return;
|
|
}
|
|
|
|
std::string username = (*json)["username"].asString();
|
|
|
|
if (username.empty()) {
|
|
callback(jsonError("Username required"));
|
|
return;
|
|
}
|
|
|
|
AuthService::getInstance().initiatePgpLogin(username,
|
|
[callback](bool success, const std::string& challenge, const std::string& publicKey) {
|
|
if (success) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["challenge"] = challenge;
|
|
resp["publicKey"] = publicKey;
|
|
callback(jsonResp(resp));
|
|
} else {
|
|
callback(jsonError("User not found or PGP not enabled", k404NotFound));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in pgpChallenge: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::pgpVerify(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("username") ||
|
|
!(*json).isMember("signature") ||
|
|
!(*json).isMember("challenge")) {
|
|
callback(jsonError("Missing required fields"));
|
|
return;
|
|
}
|
|
|
|
std::string username = (*json)["username"].asString();
|
|
std::string signature = (*json)["signature"].asString();
|
|
std::string challenge = (*json)["challenge"].asString();
|
|
|
|
if (username.empty() || signature.empty() || challenge.empty()) {
|
|
callback(jsonError("Missing required fields"));
|
|
return;
|
|
}
|
|
|
|
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
|
|
[callback](bool success, const std::string& token, const UserInfo& user) {
|
|
if (success) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
|
resp["token"] = token;
|
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
|
resp["user"]["username"] = user.username;
|
|
resp["user"]["isAdmin"] = user.isAdmin;
|
|
resp["user"]["isStreamer"] = user.isStreamer;
|
|
resp["user"]["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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_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<Json::Int64>(r[0]["id"].as<int64_t>());
|
|
resp["user"]["username"] = r[0]["username"].as<std::string>();
|
|
resp["user"]["isAdmin"] = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
|
resp["user"]["isStreamer"] = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
|
resp["user"]["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>();
|
|
resp["user"]["isPgpOnly"] = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
|
resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
|
resp["user"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
|
resp["user"]["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>();
|
|
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>();
|
|
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>();
|
|
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<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"));
|
|
}
|
|
}
|
|
|
|
void UserController::updateProfile(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
std::string bio = (*json).isMember("bio") ? (*json)["bio"].asString() : "";
|
|
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<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");
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updateProfile: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::updatePassword(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("oldPassword") || !(*json).isMember("newPassword")) {
|
|
callback(jsonError("Missing passwords"));
|
|
return;
|
|
}
|
|
|
|
std::string oldPassword = (*json)["oldPassword"].asString();
|
|
std::string newPassword = (*json)["newPassword"].asString();
|
|
|
|
if (oldPassword.empty() || newPassword.empty()) {
|
|
callback(jsonError("Missing passwords"));
|
|
return;
|
|
}
|
|
|
|
AuthService::getInstance().updatePassword(user.id, oldPassword, newPassword,
|
|
[callback](bool success, const std::string& error) {
|
|
if (success) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
callback(jsonResp(resp));
|
|
} else {
|
|
callback(jsonError(error));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updatePassword: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::togglePgpOnly(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
bool enable = (*json).isMember("enable") ? (*json)["enable"].asBool() : false;
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at"
|
|
<< enable << user.id
|
|
>> [callback, enable](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["pgpOnly"] = enable;
|
|
|
|
// Return the timestamp if it was just enabled
|
|
if (enable && !r.empty() && !r[0]["pgp_only_enabled_at"].isNull()) {
|
|
resp["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].as<std::string>();
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> 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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("publicKey") || !(*json).isMember("fingerprint")) {
|
|
callback(jsonError("Missing key data"));
|
|
return;
|
|
}
|
|
|
|
std::string publicKey = (*json)["publicKey"].asString();
|
|
std::string fingerprint = (*json)["fingerprint"].asString();
|
|
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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT public_key, fingerprint, 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<std::string>();
|
|
key["fingerprint"] = row["fingerprint"].as<std::string>();
|
|
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
|
|
key["createdAt"] = row["created_at"].as<std::string>();
|
|
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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser parser;
|
|
parser.parse(req);
|
|
|
|
if (parser.getFiles().empty()) {
|
|
callback(jsonError("No file uploaded"));
|
|
return;
|
|
}
|
|
|
|
const auto& file = parser.getFiles()[0];
|
|
|
|
// Validate file size (250KB max)
|
|
if (file.fileLength() > 250 * 1024) {
|
|
callback(jsonError("File too large (max 250KB)"));
|
|
return;
|
|
}
|
|
|
|
// Validate file 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<std::string>();
|
|
}
|
|
|
|
// 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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser parser;
|
|
parser.parse(req);
|
|
|
|
if (parser.getFiles().empty()) {
|
|
callback(jsonError("No file uploaded"));
|
|
return;
|
|
}
|
|
|
|
const auto& file = parser.getFiles()[0];
|
|
|
|
// Validate file size (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<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 "
|
|
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
|
"WHERE u.username = $1 ORDER BY pk.created_at DESC"
|
|
<< username
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value keys(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value key;
|
|
key["publicKey"] = row["public_key"].as<std::string>();
|
|
key["fingerprint"] = row["fingerprint"].as<std::string>();
|
|
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
|
|
key["createdAt"] = row["created_at"].as<std::string>();
|
|
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<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
if (!(*json).isMember("color")) {
|
|
callback(jsonError("Color is required"));
|
|
return;
|
|
}
|
|
|
|
std::string newColor = (*json)["color"].asString();
|
|
|
|
if (newColor.empty()) {
|
|
callback(jsonError("Color is required"));
|
|
return;
|
|
}
|
|
|
|
AuthService::getInstance().updateUserColor(user.id, newColor,
|
|
[callback, user](bool success, const std::string& error, const std::string& finalColor) {
|
|
if (success) {
|
|
// Fetch updated user info
|
|
AuthService::getInstance().fetchUserInfo(user.id,
|
|
[callback, finalColor](bool fetchSuccess, const UserInfo& updatedUser) {
|
|
if (fetchSuccess) {
|
|
// Generate new token with updated user info including color
|
|
std::string newToken = AuthService::getInstance().generateToken(updatedUser);
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["color"] = finalColor;
|
|
resp["user"]["id"] = static_cast<Json::Int64>(updatedUser.id);
|
|
resp["user"]["username"] = updatedUser.username;
|
|
resp["user"]["isAdmin"] = updatedUser.isAdmin;
|
|
resp["user"]["isStreamer"] = updatedUser.isStreamer;
|
|
resp["user"]["colorCode"] = updatedUser.colorCode;
|
|
|
|
auto response = jsonResp(resp);
|
|
// Update auth cookie with new token
|
|
setAuthCookie(response, newToken);
|
|
callback(response);
|
|
} else {
|
|
// Color was updated but couldn't fetch full user info
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["color"] = finalColor;
|
|
resp["message"] = "Color updated but please refresh for new token";
|
|
callback(jsonResp(resp));
|
|
}
|
|
});
|
|
} else {
|
|
callback(jsonError(error));
|
|
}
|
|
});
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in updateColor: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
void UserController::getAvailableColors(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
try {
|
|
// Define available colors for user profiles
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
Json::Value colors(Json::arrayValue);
|
|
|
|
// Add predefined color options
|
|
colors.append("#561D5E"); // Default purple
|
|
colors.append("#1E88E5"); // Blue
|
|
colors.append("#43A047"); // Green
|
|
colors.append("#E53935"); // Red
|
|
colors.append("#FB8C00"); // Orange
|
|
colors.append("#8E24AA"); // Purple variant
|
|
colors.append("#00ACC1"); // Cyan
|
|
colors.append("#FFB300"); // Amber
|
|
colors.append("#546E7A"); // Blue Grey
|
|
colors.append("#D81B60"); // Pink
|
|
|
|
resp["colors"] = colors;
|
|
callback(jsonResp(resp));
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Exception in getAvailableColors: " << e.what();
|
|
callback(jsonError("Internal server error"));
|
|
}
|
|
}
|
|
|
|
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");
|
|
} |