This commit is contained in:
doomtube 2025-08-10 07:55:39 -04:00
parent 4c23ab840a
commit e8864cc853
15 changed files with 4004 additions and 1593 deletions

View file

@ -32,6 +32,7 @@ UserInfo AdminController::getUserFromRequest(const HttpRequestPtr &req) {
return user; return user;
} }
// Update getUsers in AdminController.cpp:
void AdminController::getUsers(const HttpRequestPtr &req, void AdminController::getUsers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
@ -41,7 +42,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
} }
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, " *dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, u.color_code, "
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count " "(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count "
"FROM users u ORDER BY u.created_at DESC" "FROM users u ORDER BY u.created_at DESC"
>> [callback](const Result& r) { >> [callback](const Result& r) {
@ -56,6 +57,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
user["isAdmin"] = row["is_admin"].as<bool>(); user["isAdmin"] = row["is_admin"].as<bool>();
user["isStreamer"] = row["is_streamer"].as<bool>(); user["isStreamer"] = row["is_streamer"].as<bool>();
user["createdAt"] = row["created_at"].as<std::string>(); user["createdAt"] = row["created_at"].as<std::string>();
user["colorCode"] = row["color_code"].isNull() ? "#561D5E" : row["color_code"].as<std::string>();
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>()); user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
users.append(user); users.append(user);
} }
@ -68,7 +70,6 @@ void AdminController::getUsers(const HttpRequestPtr &req,
callback(jsonError("Failed to get users")); callback(jsonError("Failed to get users"));
}; };
} }
void AdminController::getActiveStreams(const HttpRequestPtr &req, void AdminController::getActiveStreams(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);

View file

@ -308,7 +308,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
}; };
} }
void RealmController::getRealm(const HttpRequestPtr &req, void RealmController::getRealm(const HttpRequestPtr &, // Remove parameter name since it's unused
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) { const std::string &realmId) {
// Remove authentication requirement for public viewing // Remove authentication requirement for public viewing
@ -349,18 +349,44 @@ void RealmController::getRealm(const HttpRequestPtr &req,
void RealmController::updateRealm(const HttpRequestPtr &req, void RealmController::updateRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) { const std::string &realmId) {
// Since we removed display_name and description, there's nothing to update
// We could just return success or remove this endpoint entirely
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
return; return;
} }
// Parse realm ID
int64_t id;
try {
id = std::stoll(realmId);
} catch (...) {
callback(jsonError("Invalid realm ID", k400BadRequest));
return;
}
// Verify the realm exists and belongs to the user
auto dbClient = app().getDbClient();
*dbClient << "SELECT id FROM realms WHERE id = $1 AND user_id = $2"
<< id << user.id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Realm not found or access denied", k404NotFound));
return;
}
// Currently no fields to update since we removed display_name and description
// This endpoint is kept for potential future updates
// For now, just return success
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
resp["message"] = "Realm updated successfully";
callback(jsonResp(resp)); callback(jsonResp(resp));
} }
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
}
void RealmController::deleteRealm(const HttpRequestPtr &req, void RealmController::deleteRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,

View file

@ -68,43 +68,92 @@ UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
void UserController::register_(const HttpRequestPtr &req, void UserController::register_(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
LOG_DEBUG << "Registration request received";
auto json = req->getJsonObject(); auto json = req->getJsonObject();
if (!json) { if (!json) {
LOG_WARN << "Invalid JSON in registration request";
callback(jsonError("Invalid JSON")); callback(jsonError("Invalid JSON"));
return; 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 username = (*json)["username"].asString();
std::string password = (*json)["password"].asString(); std::string password = (*json)["password"].asString();
std::string publicKey = (*json)["publicKey"].asString(); std::string publicKey = (*json)["publicKey"].asString();
std::string fingerprint = (*json)["fingerprint"].asString(); std::string fingerprint = (*json)["fingerprint"].asString();
// Validate that none of the strings are empty
if (username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) { if (username.empty() || password.empty() || publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing required fields")); LOG_WARN << "Empty required fields in registration request";
callback(jsonError("All fields are required"));
return; 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, AuthService::getInstance().registerUser(username, password, publicKey, fingerprint,
[callback](bool success, const std::string& error, int64_t userId) { [callback, username](bool success, const std::string& error, int64_t userId) {
if (success) { if (success) {
LOG_INFO << "User registered successfully: " << username << " (ID: " << userId << ")";
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
resp["userId"] = static_cast<Json::Int64>(userId); resp["userId"] = static_cast<Json::Int64>(userId);
callback(jsonResp(resp)); callback(jsonResp(resp));
} else { } else {
LOG_WARN << "Registration failed for " << username << ": " << error;
callback(jsonError(error)); callback(jsonError(error));
} }
}); });
} catch (const std::exception& e) {
LOG_ERROR << "Exception in register_: " << e.what();
callback(jsonError("Internal server error"));
} catch (...) {
LOG_ERROR << "Unknown exception in register_";
callback(jsonError("Internal server error"));
}
} }
void UserController::login(const HttpRequestPtr &req, void UserController::login(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
LOG_DEBUG << "Login request received";
auto json = req->getJsonObject(); auto json = req->getJsonObject();
if (!json) { if (!json) {
callback(jsonError("Invalid JSON")); callback(jsonError("Invalid JSON"));
return; 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 username = (*json)["username"].asString();
std::string password = (*json)["password"].asString(); std::string password = (*json)["password"].asString();
@ -113,9 +162,12 @@ void UserController::login(const HttpRequestPtr &req,
return; return;
} }
LOG_INFO << "Login attempt for user: " << username;
AuthService::getInstance().loginUser(username, password, AuthService::getInstance().loginUser(username, password,
[callback](bool success, const std::string& token, const UserInfo& user) { [callback, username](bool success, const std::string& token, const UserInfo& user) {
if (success) { if (success) {
LOG_INFO << "Login successful for user: " << username;
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
resp["token"] = token; resp["token"] = token;
@ -127,21 +179,36 @@ void UserController::login(const HttpRequestPtr &req,
resp["user"]["bio"] = user.bio; resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl; resp["user"]["avatarUrl"] = user.avatarUrl;
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
resp["user"]["colorCode"] = user.colorCode; // Use colorCode for consistency with database field name
callback(jsonResp(resp)); callback(jsonResp(resp));
} else { } else {
LOG_WARN << "Login failed for user: " << username;
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized)); 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::pgpChallenge(const HttpRequestPtr &req, void UserController::pgpChallenge(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
auto json = req->getJsonObject(); auto json = req->getJsonObject();
if (!json) { if (!json) {
callback(jsonError("Invalid JSON")); callback(jsonError("Invalid JSON"));
return; return;
} }
if (!(*json).isMember("username")) {
callback(jsonError("Username required"));
return;
}
std::string username = (*json)["username"].asString(); std::string username = (*json)["username"].asString();
if (username.empty()) { if (username.empty()) {
@ -161,16 +228,28 @@ void UserController::pgpChallenge(const HttpRequestPtr &req,
callback(jsonError("User not found or PGP not enabled", k404NotFound)); 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, void UserController::pgpVerify(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
auto json = req->getJsonObject(); auto json = req->getJsonObject();
if (!json) { if (!json) {
callback(jsonError("Invalid JSON")); callback(jsonError("Invalid JSON"));
return; 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 username = (*json)["username"].asString();
std::string signature = (*json)["signature"].asString(); std::string signature = (*json)["signature"].asString();
std::string challenge = (*json)["challenge"].asString(); std::string challenge = (*json)["challenge"].asString();
@ -194,24 +273,29 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
resp["user"]["bio"] = user.bio; resp["user"]["bio"] = user.bio;
resp["user"]["avatarUrl"] = user.avatarUrl; resp["user"]["avatarUrl"] = user.avatarUrl;
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
resp["user"]["colorCode"] = user.colorCode; // Add colorCode to PGP login response
callback(jsonResp(resp)); callback(jsonResp(resp));
} else { } else {
callback(jsonError("Invalid signature", k401Unauthorized)); 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, void UserController::getCurrentUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
return; return;
} }
// Get fresh user data from database
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at " *dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
"FROM users WHERE id = $1" "FROM users WHERE id = $1"
<< user.id << user.id
>> [callback](const Result& r) { >> [callback](const Result& r) {
@ -230,16 +314,22 @@ void UserController::getCurrentUser(const HttpRequestPtr &req,
resp["user"]["bio"] = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>(); 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"]["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>(); resp["user"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
callback(jsonResp(resp)); callback(jsonResp(resp));
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to get user data: " << e.base().what(); LOG_ERROR << "Failed to get user data: " << e.base().what();
callback(jsonError("Database error")); callback(jsonError("Database error"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getCurrentUser: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::updateProfile(const HttpRequestPtr &req, void UserController::updateProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -252,7 +342,7 @@ void UserController::updateProfile(const HttpRequestPtr &req,
return; return;
} }
std::string bio = (*json)["bio"].asString(); std::string bio = (*json).isMember("bio") ? (*json)["bio"].asString() : "";
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET bio = $1 WHERE id = $2" *dbClient << "UPDATE users SET bio = $1 WHERE id = $2"
@ -267,10 +357,15 @@ void UserController::updateProfile(const HttpRequestPtr &req,
LOG_ERROR << "Failed to update profile: " << e.base().what(); LOG_ERROR << "Failed to update profile: " << e.base().what();
callback(jsonError("Failed to update profile")); callback(jsonError("Failed to update profile"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in updateProfile: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::updatePassword(const HttpRequestPtr &req, void UserController::updatePassword(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -283,6 +378,11 @@ void UserController::updatePassword(const HttpRequestPtr &req,
return; return;
} }
if (!(*json).isMember("oldPassword") || !(*json).isMember("newPassword")) {
callback(jsonError("Missing passwords"));
return;
}
std::string oldPassword = (*json)["oldPassword"].asString(); std::string oldPassword = (*json)["oldPassword"].asString();
std::string newPassword = (*json)["newPassword"].asString(); std::string newPassword = (*json)["newPassword"].asString();
@ -301,10 +401,15 @@ void UserController::updatePassword(const HttpRequestPtr &req,
callback(jsonError(error)); 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, void UserController::togglePgpOnly(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -317,7 +422,7 @@ void UserController::togglePgpOnly(const HttpRequestPtr &req,
return; return;
} }
bool enable = (*json)["enable"].asBool(); bool enable = (*json).isMember("enable") ? (*json)["enable"].asBool() : false;
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at" *dbClient << "UPDATE users SET is_pgp_only = $1 WHERE id = $2 RETURNING pgp_only_enabled_at"
@ -338,10 +443,15 @@ void UserController::togglePgpOnly(const HttpRequestPtr &req,
LOG_ERROR << "Failed to update PGP setting: " << e.base().what(); LOG_ERROR << "Failed to update PGP setting: " << e.base().what();
callback(jsonError("Failed to update setting")); callback(jsonError("Failed to update setting"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in togglePgpOnly: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::addPgpKey(const HttpRequestPtr &req, void UserController::addPgpKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -354,6 +464,11 @@ void UserController::addPgpKey(const HttpRequestPtr &req,
return; return;
} }
if (!(*json).isMember("publicKey") || !(*json).isMember("fingerprint")) {
callback(jsonError("Missing key data"));
return;
}
std::string publicKey = (*json)["publicKey"].asString(); std::string publicKey = (*json)["publicKey"].asString();
std::string fingerprint = (*json)["fingerprint"].asString(); std::string fingerprint = (*json)["fingerprint"].asString();
@ -389,10 +504,15 @@ void UserController::addPgpKey(const HttpRequestPtr &req,
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error")); callback(jsonError("Database error"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in addPgpKey: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::getPgpKeys(const HttpRequestPtr &req, void UserController::getPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -423,10 +543,15 @@ void UserController::getPgpKeys(const HttpRequestPtr &req,
LOG_ERROR << "Failed to get PGP keys: " << e.base().what(); LOG_ERROR << "Failed to get PGP keys: " << e.base().what();
callback(jsonError("Failed to get PGP keys")); callback(jsonError("Failed to get PGP keys"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getPgpKeys: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::uploadAvatar(const HttpRequestPtr &req, void UserController::uploadAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);
if (user.id == 0) { if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized)); callback(jsonError("Unauthorized", k401Unauthorized));
@ -548,15 +673,19 @@ void UserController::uploadAvatar(const HttpRequestPtr &req,
LOG_ERROR << "Failed to update avatar: " << e.base().what(); LOG_ERROR << "Failed to update avatar: " << e.base().what();
callback(jsonError("Failed to update avatar")); callback(jsonError("Failed to update avatar"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in uploadAvatar: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::getProfile(const HttpRequestPtr &, void UserController::getProfile(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) { const std::string &username) {
// Public endpoint - no authentication required try {
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT u.username, u.bio, u.avatar_url, u.created_at, " *dbClient << "SELECT u.username, u.bio, u.avatar_url, u.created_at, "
"u.is_pgp_only, u.pgp_only_enabled_at " "u.is_pgp_only, u.pgp_only_enabled_at, u.user_color "
"FROM users u WHERE u.username = $1" "FROM users u WHERE u.username = $1"
<< username << username
>> [callback](const Result& r) { >> [callback](const Result& r) {
@ -573,6 +702,7 @@ void UserController::getProfile(const HttpRequestPtr &,
resp["profile"]["createdAt"] = r[0]["created_at"].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"]["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"]["pgpOnlyEnabledAt"] = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
resp["profile"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
callback(jsonResp(resp)); callback(jsonResp(resp));
} }
@ -580,11 +710,16 @@ void UserController::getProfile(const HttpRequestPtr &,
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error")); callback(jsonError("Database error"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getProfile: " << e.what();
callback(jsonError("Internal server error"));
}
} }
void UserController::getUserPgpKeys(const HttpRequestPtr &, void UserController::getUserPgpKeys(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) { const std::string &username) {
try {
// Public endpoint - no authentication required // Public endpoint - no authentication required
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT pk.public_key, pk.fingerprint, pk.created_at " *dbClient << "SELECT pk.public_key, pk.fingerprint, pk.created_at "
@ -611,4 +746,103 @@ void UserController::getUserPgpKeys(const HttpRequestPtr &,
LOG_ERROR << "Failed to get user PGP keys: " << e.base().what(); LOG_ERROR << "Failed to get user PGP keys: " << e.base().what();
callback(jsonError("Failed to get PGP keys")); callback(jsonError("Failed to get PGP keys"));
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in getUserPgpKeys: " << e.what();
callback(jsonError("Internal server error"));
}
}
void UserController::updateColor(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
if (!(*json).isMember("color")) {
callback(jsonError("Color is required"));
return;
}
std::string newColor = (*json)["color"].asString();
if (newColor.empty()) {
callback(jsonError("Color is required"));
return;
}
AuthService::getInstance().updateUserColor(user.id, newColor,
[callback, user](bool success, const std::string& error, const std::string& finalColor) {
if (success) {
// Fetch updated user info and generate new token with updated color
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["token"] = newToken; // Return new token with updated color
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;
callback(jsonResp(resp));
} 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"));
}
} }

View file

@ -20,6 +20,8 @@ public:
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post); ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get); ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get); ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
ADD_METHOD_TO(UserController::updateColor, "/api/user/color", Put);
ADD_METHOD_TO(UserController::getAvailableColors, "/api/colors/available", Get);
METHOD_LIST_END METHOD_LIST_END
void register_(const HttpRequestPtr &req, void register_(const HttpRequestPtr &req,
@ -62,6 +64,13 @@ public:
void getUserPgpKeys(const HttpRequestPtr &req, void getUserPgpKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username); const std::string &username);
void updateColor(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getAvailableColors(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
private: private:
UserInfo getUserFromRequest(const HttpRequestPtr &req); UserInfo getUserFromRequest(const HttpRequestPtr &req);
}; };

View file

@ -4,6 +4,8 @@
#include <drogon/utils/Utilities.h> #include <drogon/utils/Utilities.h>
#include <regex> #include <regex>
#include <random> #include <random>
#include <functional>
#include <memory>
using namespace drogon; using namespace drogon;
using namespace drogon::orm; using namespace drogon::orm;
@ -27,9 +29,128 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
return true; return true;
} }
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
try {
auto dbClient = app().getDbClient();
// Create a structure to hold the state for recursive attempts
struct ColorGenerator : public std::enable_shared_from_this<ColorGenerator> {
std::mt19937 gen;
std::uniform_int_distribution<> dis;
std::function<void(const std::string&)> callback;
DbClientPtr dbClient;
int attempts;
ColorGenerator(std::function<void(const std::string&)> cb, DbClientPtr db)
: gen(std::random_device{}()),
dis(0, 0xFFFFFF),
callback(cb),
dbClient(db),
attempts(0) {}
void tryGenerate() {
auto self = shared_from_this();
// Limit attempts to prevent infinite recursion
if (++attempts > 100) {
LOG_ERROR << "Failed to generate unique color after 100 attempts";
callback("#561D5E"); // Fallback to default
return;
}
// Generate random color
int colorValue = dis(gen);
char colorHex[8];
snprintf(colorHex, sizeof(colorHex), "#%06X", colorValue);
std::string color(colorHex);
// Check if color exists
*dbClient << "SELECT id FROM users WHERE user_color = $1 LIMIT 1"
<< color
>> [self, color](const Result& r) {
if (r.empty()) {
// Color is unique, use it
self->callback(color);
} else {
// Color exists, try again
self->tryGenerate();
}
}
>> [self](const DrogonDbException& e) {
LOG_ERROR << "Database error checking color: " << e.base().what();
// Fallback to a default color
self->callback("#561D5E");
};
}
};
auto generator = std::make_shared<ColorGenerator>(callback, dbClient);
generator->tryGenerate();
} catch (const std::exception& e) {
LOG_ERROR << "Exception in generateUniqueColor: " << e.what();
callback("#561D5E"); // Fallback to default
}
}
void AuthService::updateUserColor(int64_t userId, const std::string& newColor,
std::function<void(bool, const std::string&, const std::string&)> callback) {
try {
// Validate color format
if (newColor.length() != 7 || newColor[0] != '#') {
callback(false, "Invalid color format. Use #RRGGBB", "");
return;
}
// Check if color is valid hex
for (size_t i = 1; i < 7; i++) {
if (!std::isxdigit(newColor[i])) {
callback(false, "Invalid color format. Use #RRGGBB", "");
return;
}
}
auto dbClient = app().getDbClient();
// Check if color is already taken
*dbClient << "SELECT id FROM users WHERE user_color = $1 AND id != $2 LIMIT 1"
<< newColor << userId
>> [dbClient, userId, newColor, callback](const Result& r) {
if (!r.empty()) {
callback(false, "This color is already taken", "");
return;
}
// Update the color
*dbClient << "UPDATE users SET user_color = $1 WHERE id = $2 RETURNING user_color"
<< newColor << userId
>> [callback, newColor](const Result& r2) {
if (!r2.empty()) {
callback(true, "", newColor);
} else {
callback(false, "Failed to update color", "");
}
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to update color: " << e.base().what();
callback(false, "Database error", "");
};
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, "Database error", "");
};
} catch (const std::exception& e) {
LOG_ERROR << "Exception in updateUserColor: " << e.what();
callback(false, "Internal server error", "");
}
}
void AuthService::registerUser(const std::string& username, const std::string& password, void AuthService::registerUser(const std::string& username, const std::string& password,
const std::string& publicKey, const std::string& fingerprint, const std::string& publicKey, const std::string& fingerprint,
std::function<void(bool, const std::string&, int64_t)> callback) { std::function<void(bool, const std::string&, int64_t)> callback) {
try {
LOG_DEBUG << "Starting user registration for: " << username;
// Validate username // Validate username
if (username.length() < 3 || username.length() > 30) { if (username.length() < 3 || username.length() > 30) {
@ -49,79 +170,139 @@ void AuthService::registerUser(const std::string& username, const std::string& p
return; return;
} }
LOG_DEBUG << "Validation passed, generating unique color";
// Generate unique color first
generateUniqueColor([this, username, password, publicKey, fingerprint, callback](const std::string& color) {
try {
LOG_DEBUG << "Got unique color: " << color << ", checking username availability";
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
if (!dbClient) {
LOG_ERROR << "Database client is null";
callback(false, "Database connection error", 0);
return;
}
// Check if username exists // Check if username exists
*dbClient << "SELECT id FROM users WHERE username = $1" *dbClient << "SELECT id FROM users WHERE username = $1 LIMIT 1"
<< username << username
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r) { >> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r) {
try {
if (!r.empty()) { if (!r.empty()) {
LOG_WARN << "Username already exists: " << username;
callback(false, "Username already exists", 0); callback(false, "Username already exists", 0);
return; return;
} }
LOG_DEBUG << "Username available, checking fingerprint";
// Check if fingerprint exists // Check if fingerprint exists
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1" *dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1 LIMIT 1"
<< fingerprint << fingerprint
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r2) { >> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r2) {
try {
if (!r2.empty()) { if (!r2.empty()) {
LOG_WARN << "Fingerprint already exists";
callback(false, "This PGP key is already registered", 0); callback(false, "This PGP key is already registered", 0);
return; return;
} }
LOG_DEBUG << "Fingerprint available, hashing password";
// Hash password // Hash password
std::string hash = BCrypt::generateHash(password); std::string hash;
try {
hash = BCrypt::generateHash(password);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to hash password: " << e.what();
callback(false, "Failed to process password", 0);
return;
}
// Begin transaction LOG_DEBUG << "Password hashed, inserting user";
auto trans = dbClient->newTransaction();
// Insert user with explicit false values for booleans // Insert user first (without transaction for simplicity)
*trans << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only) VALUES ($1, $2, false, false, false) RETURNING id" *dbClient << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only, user_color) "
<< username << hash "VALUES ($1, $2, false, false, false, $3) RETURNING id"
>> [trans, publicKey, fingerprint, callback](const Result& r3) { << username << hash << color
>> [dbClient, publicKey, fingerprint, callback, username](const Result& r3) {
try {
if (r3.empty()) { if (r3.empty()) {
LOG_ERROR << "Failed to insert user";
callback(false, "Failed to create user", 0); callback(false, "Failed to create user", 0);
return; return;
} }
int64_t userId = r3[0]["id"].as<int64_t>(); int64_t userId = r3[0]["id"].as<int64_t>();
LOG_INFO << "User created with ID: " << userId;
// Insert PGP key // Insert PGP key
*trans << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)" *dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
<< userId << publicKey << fingerprint << userId << publicKey << fingerprint
>> [trans, callback, userId](const Result&) { >> [callback, userId, username](const Result&) {
// Transaction commits automatically LOG_INFO << "User registration complete for: " << username << " (ID: " << userId << ")";
callback(true, "", userId); callback(true, "", userId);
} }
>> [trans, callback](const DrogonDbException& e) { >> [dbClient, userId, callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to insert PGP key: " << e.base().what(); LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
// Try to clean up the user
*dbClient << "DELETE FROM users WHERE id = $1" << userId >> [](const Result&) {} >> [](const DrogonDbException&) {};
callback(false, "Failed to save PGP key", 0); callback(false, "Failed to save PGP key", 0);
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception processing user insert result: " << e.what();
callback(false, "Registration failed", 0);
} }
>> [trans, callback](const DrogonDbException& e) { }
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Failed to insert user: " << e.base().what(); LOG_ERROR << "Failed to insert user: " << e.base().what();
callback(false, "Registration failed", 0); callback(false, "Registration failed", 0);
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in fingerprint check callback: " << e.what();
callback(false, "Registration failed", 0);
}
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error checking fingerprint: " << e.base().what();
callback(false, "Database error", 0); callback(false, "Database error", 0);
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in username check callback: " << e.what();
callback(false, "Registration failed", 0);
}
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error checking username: " << e.base().what();
callback(false, "Database error", 0); callback(false, "Database error", 0);
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in color generation callback: " << e.what();
callback(false, "Registration failed", 0);
}
});
} catch (const std::exception& e) {
LOG_ERROR << "Exception in registerUser: " << e.what();
callback(false, "Registration failed", 0);
}
} }
void AuthService::loginUser(const std::string& username, const std::string& password, void AuthService::loginUser(const std::string& username, const std::string& password,
std::function<void(bool, const std::string&, const UserInfo&)> callback) { std::function<void(bool, const std::string&, const UserInfo&)> callback) {
try {
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
if (!dbClient) {
LOG_ERROR << "Database client is null";
callback(false, "", UserInfo{});
return;
}
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at " *dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
"FROM users WHERE username = $1" "FROM users WHERE username = $1 LIMIT 1"
<< username << username
>> [password, callback, this](const Result& r) { >> [password, callback, this](const Result& r) {
try {
if (r.empty()) { if (r.empty()) {
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
return; return;
@ -138,7 +319,16 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
std::string hash = r[0]["password_hash"].as<std::string>(); std::string hash = r[0]["password_hash"].as<std::string>();
if (!BCrypt::validatePassword(password, hash)) { bool valid = false;
try {
valid = BCrypt::validatePassword(password, hash);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to validate password: " << e.what();
callback(false, "", UserInfo{});
return;
}
if (!valid) {
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
return; return;
} }
@ -152,19 +342,34 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>(); user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>(); user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>(); user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
std::string token = generateToken(user); std::string token = generateToken(user);
callback(true, token, user); callback(true, token, user);
} catch (const std::exception& e) {
LOG_ERROR << "Exception in login callback: " << e.what();
callback(false, "", UserInfo{});
}
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error: " << e.base().what();
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in loginUser: " << e.what();
callback(false, "", UserInfo{});
}
} }
void AuthService::initiatePgpLogin(const std::string& username, void AuthService::initiatePgpLogin(const std::string& username,
std::function<void(bool, const std::string&, const std::string&)> callback) { std::function<void(bool, const std::string&, const std::string&)> callback) {
try {
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
if (!dbClient) {
LOG_ERROR << "Database client is null";
callback(false, "", "");
return;
}
// Generate random challenge // Generate random challenge
auto bytes = drogon::utils::genRandomString(32); auto bytes = drogon::utils::genRandomString(32);
@ -201,14 +406,20 @@ void AuthService::initiatePgpLogin(const std::string& username,
}; };
} }
); );
} catch (const std::exception& e) {
LOG_ERROR << "Exception in initiatePgpLogin: " << e.what();
callback(false, "", "");
}
} }
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature, void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
const std::string& challenge, const std::string& challenge,
std::function<void(bool, const std::string&, const UserInfo&)> callback) { std::function<void(bool, const std::string&, const UserInfo&)> callback) {
try {
// Get stored challenge from Redis // Get stored challenge from Redis
RedisHelper::getKeyAsync("pgp_challenge:" + username, RedisHelper::getKeyAsync("pgp_challenge:" + username,
[username, signature, challenge, callback, this](const std::string& storedChallenge) { [username, signature, challenge, callback, this](const std::string& storedChallenge) {
try {
if (storedChallenge.empty() || storedChallenge != challenge) { if (storedChallenge.empty() || storedChallenge != challenge) {
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
return; return;
@ -221,10 +432,17 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
// For now, we'll trust the client-side verification // For now, we'll trust the client-side verification
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at " if (!dbClient) {
"FROM users WHERE username = $1" LOG_ERROR << "Database client is null";
callback(false, "", UserInfo{});
return;
}
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
"FROM users WHERE username = $1 LIMIT 1"
<< username << username
>> [callback, this](const Result& r) { >> [callback, this](const Result& r) {
try {
if (r.empty()) { if (r.empty()) {
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
return; return;
@ -239,19 +457,33 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>(); user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>(); user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>(); user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
std::string token = generateToken(user); std::string token = generateToken(user);
callback(true, token, user); callback(true, token, user);
} catch (const std::exception& e) {
LOG_ERROR << "Exception processing user data: " << e.what();
callback(false, "", UserInfo{});
}
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error: " << e.base().what();
callback(false, "", UserInfo{}); callback(false, "", UserInfo{});
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in Redis callback: " << e.what();
callback(false, "", UserInfo{});
}
} }
); );
} catch (const std::exception& e) {
LOG_ERROR << "Exception in verifyPgpLogin: " << e.what();
callback(false, "", UserInfo{});
}
} }
std::string AuthService::generateToken(const UserInfo& user) { std::string AuthService::generateToken(const UserInfo& user) {
try {
if (jwtSecret_.empty()) { if (jwtSecret_.empty()) {
const char* envSecret = std::getenv("JWT_SECRET"); const char* envSecret = std::getenv("JWT_SECRET");
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret"; jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
@ -266,18 +498,25 @@ std::string AuthService::generateToken(const UserInfo& user) {
.set_payload_claim("username", jwt::claim(user.username)) .set_payload_claim("username", jwt::claim(user.username))
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin))) .set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer))) .set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
.set_payload_claim("color_code", jwt::claim(
user.colorCode.empty() ? "#561D5E" : user.colorCode
)) // Ensure color is never empty
.sign(jwt::algorithm::hs256{jwtSecret_}); .sign(jwt::algorithm::hs256{jwtSecret_});
return token; return token;
} catch (const std::exception& e) {
LOG_ERROR << "Failed to generate token: " << e.what();
return "";
}
} }
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) { bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
try {
if (jwtSecret_.empty()) { if (jwtSecret_.empty()) {
const char* envSecret = std::getenv("JWT_SECRET"); const char* envSecret = std::getenv("JWT_SECRET");
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret"; jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
} }
try {
auto decoded = jwt::decode(token); auto decoded = jwt::decode(token);
auto verifier = jwt::verify() auto verifier = jwt::verify()
@ -292,6 +531,14 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ? userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
decoded.get_payload_claim("is_streamer").as_string() == "1" : false; decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
// Get color from token if available, otherwise will need to fetch from DB
if (decoded.has_payload_claim("color_code")) {
userInfo.colorCode = decoded.get_payload_claim("color_code").as_string();
} else {
// For older tokens without color, default value
userInfo.colorCode = "#561D5E";
}
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
LOG_DEBUG << "Token validation failed: " << e.what(); LOG_DEBUG << "Token validation failed: " << e.what();
@ -302,6 +549,7 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword, void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
const std::string& newPassword, const std::string& newPassword,
std::function<void(bool, const std::string&)> callback) { std::function<void(bool, const std::string&)> callback) {
try {
// Validate new password // Validate new password
std::string error; std::string error;
if (!validatePassword(newPassword, error)) { if (!validatePassword(newPassword, error)) {
@ -310,11 +558,17 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
} }
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
if (!dbClient) {
LOG_ERROR << "Database client is null";
callback(false, "Database connection error");
return;
}
// Verify old password // Verify old password
*dbClient << "SELECT password_hash FROM users WHERE id = $1" *dbClient << "SELECT password_hash FROM users WHERE id = $1 LIMIT 1"
<< userId << userId
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) { >> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
try {
if (r.empty()) { if (r.empty()) {
callback(false, "User not found"); callback(false, "User not found");
return; return;
@ -322,13 +576,29 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
std::string hash = r[0]["password_hash"].as<std::string>(); std::string hash = r[0]["password_hash"].as<std::string>();
if (!BCrypt::validatePassword(oldPassword, hash)) { bool valid = false;
try {
valid = BCrypt::validatePassword(oldPassword, hash);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to validate password: " << e.what();
callback(false, "Password validation error");
return;
}
if (!valid) {
callback(false, "Incorrect password"); callback(false, "Incorrect password");
return; return;
} }
// Update password // Update password
std::string newHash = BCrypt::generateHash(newPassword); std::string newHash;
try {
newHash = BCrypt::generateHash(newPassword);
} catch (const std::exception& e) {
LOG_ERROR << "Failed to hash new password: " << e.what();
callback(false, "Failed to process new password");
return;
}
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2" *dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
<< newHash << userId << newHash << userId
@ -339,9 +609,63 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
LOG_ERROR << "Failed to update password: " << e.base().what(); LOG_ERROR << "Failed to update password: " << e.base().what();
callback(false, "Failed to update password"); callback(false, "Failed to update password");
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in password update callback: " << e.what();
callback(false, "Failed to update password");
}
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what(); LOG_ERROR << "Database error: " << e.base().what();
callback(false, "Database error"); callback(false, "Database error");
}; };
} catch (const std::exception& e) {
LOG_ERROR << "Exception in updatePassword: " << e.what();
callback(false, "Failed to update password");
}
}
void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback) {
try {
auto dbClient = app().getDbClient();
if (!dbClient) {
LOG_ERROR << "Database client is null";
callback(false, UserInfo{});
return;
}
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
"FROM users WHERE id = $1 LIMIT 1"
<< userId
>> [callback](const Result& r) {
try {
if (r.empty()) {
callback(false, UserInfo{});
return;
}
UserInfo user;
user.id = r[0]["id"].as<int64_t>();
user.username = r[0]["username"].as<std::string>();
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
callback(true, user);
} catch (const std::exception& e) {
LOG_ERROR << "Exception processing user data: " << e.what();
callback(false, UserInfo{});
}
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, UserInfo{});
};
} catch (const std::exception& e) {
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
callback(false, UserInfo{});
}
} }

View file

@ -1,19 +1,20 @@
#pragma once #pragma once
#include <drogon/drogon.h>
#include <bcrypt/BCrypt.hpp>
#include <jwt-cpp/jwt.h>
#include <string> #include <string>
#include <functional>
#include <memory> #include <memory>
#include <jwt-cpp/jwt.h>
#include <bcrypt/BCrypt.hpp>
struct UserInfo { struct UserInfo {
int64_t id; int64_t id = 0;
std::string username; std::string username;
bool isAdmin; bool isAdmin = false;
bool isStreamer; bool isStreamer = false;
bool isPgpOnly; bool isPgpOnly = false;
std::string bio; std::string bio;
std::string avatarUrl; std::string avatarUrl;
std::string pgpOnlyEnabledAt; std::string pgpOnlyEnabledAt;
std::string colorCode;
}; };
class AuthService { class AuthService {
@ -23,41 +24,38 @@ public:
return instance; return instance;
} }
// User registration
void registerUser(const std::string& username, const std::string& password, void registerUser(const std::string& username, const std::string& password,
const std::string& publicKey, const std::string& fingerprint, const std::string& publicKey, const std::string& fingerprint,
std::function<void(bool success, const std::string& error, int64_t userId)> callback); std::function<void(bool, const std::string&, int64_t)> callback);
// User login with password
void loginUser(const std::string& username, const std::string& password, void loginUser(const std::string& username, const std::string& password,
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback); std::function<void(bool, const std::string&, const UserInfo&)> callback);
// User login with PGP (returns challenge)
void initiatePgpLogin(const std::string& username, void initiatePgpLogin(const std::string& username,
std::function<void(bool success, const std::string& challenge, const std::string& publicKey)> callback); std::function<void(bool, const std::string&, const std::string&)> callback);
// Verify PGP signature void verifyPgpLogin(const std::string& username, const std::string& signature,
void verifyPgpLogin(const std::string& username, const std::string& signature, const std::string& challenge, const std::string& challenge,
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback); std::function<void(bool, const std::string&, const UserInfo&)> callback);
// Validate JWT token std::string generateToken(const UserInfo& user);
bool validateToken(const std::string& token, UserInfo& userInfo); bool validateToken(const std::string& token, UserInfo& userInfo);
// Update password // New method to fetch complete user info including color
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword, void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
std::function<void(bool success, const std::string& error)> callback);
// Check password requirements void updatePassword(int64_t userId, const std::string& oldPassword,
bool validatePassword(const std::string& password, std::string& error); const std::string& newPassword,
std::function<void(bool, const std::string&)> callback);
// Generate JWT token void updateUserColor(int64_t userId, const std::string& newColor,
std::string generateToken(const UserInfo& user); std::function<void(bool, const std::string&, const std::string&)> callback);
void generateUniqueColor(std::function<void(const std::string& color)> callback);
private: private:
AuthService() = default; AuthService() = default;
~AuthService() = default;
AuthService(const AuthService&) = delete;
AuthService& operator=(const AuthService&) = delete;
std::string jwtSecret_; std::string jwtSecret_;
bool validatePassword(const std::string& password, std::string& error);
}; };

View file

@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
pgp_only_enabled_at TIMESTAMP WITH TIME ZONE, pgp_only_enabled_at TIMESTAMP WITH TIME ZONE,
bio TEXT DEFAULT '', bio TEXT DEFAULT '',
avatar_url VARCHAR(255), avatar_url VARCHAR(255),
user_color VARCHAR(7) UNIQUE NOT NULL, -- Unique hex color for each user
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );
@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS stream_keys (
CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_is_streamer ON users(is_streamer); CREATE INDEX idx_users_is_streamer ON users(is_streamer);
CREATE INDEX idx_users_is_pgp_only ON users(is_pgp_only); CREATE INDEX idx_users_is_pgp_only ON users(is_pgp_only);
CREATE INDEX idx_users_user_color ON users(user_color);
CREATE INDEX idx_pgp_keys_user_id ON pgp_keys(user_id); CREATE INDEX idx_pgp_keys_user_id ON pgp_keys(user_id);
CREATE INDEX idx_pgp_keys_fingerprint ON pgp_keys(fingerprint); CREATE INDEX idx_pgp_keys_fingerprint ON pgp_keys(fingerprint);
CREATE INDEX idx_realms_user_id ON realms(user_id); CREATE INDEX idx_realms_user_id ON realms(user_id);

View file

@ -95,6 +95,48 @@ function createAuthStore() {
return { success: false, error: data.error || 'Registration failed' }; return { success: false, error: data.error || 'Registration failed' };
}, },
async updateColor(color) {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/color', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ color })
});
const data = await response.json();
if (response.ok && data.success) {
// IMPORTANT: Store the new token that includes the updated color
if (data.token) {
localStorage.setItem('auth_token', data.token);
// Update the store with new token and user data
update(state => ({
...state,
token: data.token,
user: {
...state.user,
userColor: data.color,
colorCode: data.color // Make sure both fields are updated
}
}));
} else {
// Fallback if no new token (shouldn't happen with current backend)
update(state => ({
...state,
user: { ...state.user, userColor: data.color, colorCode: data.color }
}));
}
return { success: true, color: data.color };
}
return { success: false, error: data.error || 'Failed to update color' };
},
updateUser(userData) { updateUser(userData) {
update(state => ({ update(state => ({
...state, ...state,
@ -126,3 +168,8 @@ export const isStreamer = derived(
auth, auth,
$auth => $auth.user?.isStreamer || false $auth => $auth.user?.isStreamer || false
); );
export const userColor = derived(
auth,
$auth => $auth.user?.colorCode || '#561D5E'
);

View file

@ -0,0 +1,93 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
function createUserStore() {
// Initialize from localStorage if in browser
const initialUser = browser ? JSON.parse(localStorage.getItem('user') || 'null') : null;
const { subscribe, set, update } = writable(initialUser);
return {
subscribe,
set: (user) => {
if (browser && user) {
localStorage.setItem('user', JSON.stringify(user));
} else if (browser) {
localStorage.removeItem('user');
}
set(user);
},
update: (fn) => {
update(currentUser => {
const newUser = fn(currentUser);
if (browser && newUser) {
localStorage.setItem('user', JSON.stringify(newUser));
}
return newUser;
});
},
updateColor: async (newColor) => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return false;
try {
const response = await fetch('/api/user/color', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ color: newColor })
});
const data = await response.json();
if (data.success) {
// Update the store with new user data
if (data.user) {
// Full user data returned
set(data.user);
} else {
// Only color returned, update existing user
update(u => u ? { ...u, userColor: data.color } : null);
}
return true;
}
return false;
} catch (error) {
console.error('Failed to update color:', error);
return false;
}
},
refresh: async () => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return;
try {
const response = await fetch('/api/user/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
if (data.success && data.user) {
set(data.user);
return data.user;
}
}
} catch (error) {
console.error('Failed to refresh user:', error);
}
return null;
}
};
}
export const userStore = createUserStore();
// Derived store for just the color
export const userColor = derived(
userStore,
$user => $user?.userColor || '#561D5E'
);

View file

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth'; import { auth, isAuthenticated, isAdmin, isStreamer, userColor } from '$lib/stores/auth';
import { page } from '$app/stores'; import { page } from '$app/stores';
import '../app.css'; import '../app.css';
@ -87,13 +87,27 @@
justify-content: center; justify-content: center;
color: var(--white); color: var(--white);
font-weight: 600; font-weight: 600;
transition: border-color 0.2s; transition: all 0.2s;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
position: relative;
}
.user-avatar-btn.has-color {
background: var(--user-color);
}
.user-avatar-btn.has-color.with-image {
border-color: var(--user-color);
border-width: 3px;
} }
.user-avatar-btn:hover { .user-avatar-btn:hover {
border-color: var(--primary); transform: scale(1.05);
}
.user-avatar-btn.has-color:hover {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
} }
.user-avatar-btn img { .user-avatar-btn img {
@ -126,7 +140,8 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
color: var(--white); color: var(--white);
text-decoration: none; text-decoration: none;
display: block; display: flex;
align-items: center;
transition: color 0.2s; transition: color 0.2s;
} }
@ -134,6 +149,15 @@
color: var(--primary); color: var(--primary);
} }
.user-color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-role { .dropdown-role {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--gray); color: var(--gray);
@ -169,7 +193,13 @@
{#if !$auth.loading} {#if !$auth.loading}
{#if $isAuthenticated} {#if $isAuthenticated}
<div class="user-menu"> <div class="user-menu">
<button class="user-avatar-btn" on:click={toggleDropdown}> <button
class="user-avatar-btn"
class:has-color={$userColor}
class:with-image={$auth.user.avatarUrl}
style="--user-color: {$userColor}"
on:click={toggleDropdown}
>
{#if $auth.user.avatarUrl} {#if $auth.user.avatarUrl}
<img src={$auth.user.avatarUrl} alt={$auth.user.username} /> <img src={$auth.user.avatarUrl} alt={$auth.user.username} />
{:else} {:else}
@ -181,6 +211,10 @@
<div class="dropdown"> <div class="dropdown">
<div class="dropdown-header"> <div class="dropdown-header">
<a href="/profile/{$auth.user.username}" class="dropdown-username"> <a href="/profile/{$auth.user.username}" class="dropdown-username">
<span
class="user-color-dot"
style="background: {$userColor}"
></span>
{$auth.user.username} {$auth.user.username}
</a> </a>
<div class="dropdown-role"> <div class="dropdown-role">

View file

@ -407,6 +407,22 @@
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem; margin-bottom: 1rem;
position: relative;
}
.player-wrapper::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg,
var(--user-color, var(--primary)) 0%,
var(--user-color, var(--primary)) 50%,
rgba(255, 255, 255, 0.1) 100%
);
z-index: 1;
} }
.player-area { .player-area {
@ -438,10 +454,24 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
position: relative;
overflow: hidden;
}
.stream-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
} }
.stream-header { .stream-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding-left: 1rem;
} }
.stream-header h1 { .stream-header h1 {
@ -462,11 +492,38 @@
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
background: var(--gray); background: var(--gray);
position: relative;
overflow: hidden;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--white);
transition: all 0.3s ease;
}
.streamer-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
border-width: 3px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
.streamer-avatar.has-color.with-image {
border-width: 3px;
}
.streamer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.streamer-name { .streamer-name {
font-weight: 600; font-weight: 600;
color: var(--white); color: var(--white);
font-size: 1.1rem;
} }
.viewer-count { .viewer-count {
@ -566,6 +623,39 @@
padding: 4rem 2rem; padding: 4rem 2rem;
color: var(--gray); color: var(--gray);
} }
/* Color accent for chat/info sections */
.color-accent-bar {
height: 3px;
background: var(--user-color, var(--primary));
margin: 0 0 1rem 0;
border-radius: 2px;
opacity: 0.8;
}
/* Pulse animation for live indicator */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
.status-indicator.active::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
</style> </style>
{#if loading} {#if loading}
@ -581,7 +671,7 @@
{:else if realm} {:else if realm}
<div class="stream-container"> <div class="stream-container">
<div class="player-section"> <div class="player-section">
<div class="player-wrapper"> <div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="player-area"> <div class="player-area">
<div class="dummy-player"></div> <div class="dummy-player"></div>
<div class="player-container"> <div class="player-container">
@ -590,15 +680,22 @@
</div> </div>
</div> </div>
<div class="stream-info-section"> <div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="stream-header"> <div class="stream-header">
<h1>{realm.name}</h1> <h1>{realm.name}</h1>
<div class="streamer-info"> <div class="streamer-info">
<div
class="streamer-avatar"
class:has-color={realm.colorCode}
class:with-image={realm.avatarUrl}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl} {#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" /> <img src={realm.avatarUrl} alt={realm.username} />
{:else} {:else}
<div class="streamer-avatar"></div> {realm.username?.charAt(0).toUpperCase() || '?'}
{/if} {/if}
</div>
<div> <div>
<div class="streamer-name">{realm.username}</div> <div class="streamer-name">{realm.username}</div>
<div class="viewer-count"> <div class="viewer-count">
@ -612,13 +709,14 @@
<div class="sidebar"> <div class="sidebar">
<div class="stats-section"> <div class="stats-section">
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
<h3>Stream Stats</h3> <h3>Stream Stats</h3>
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}> <div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
{#if stats.isLive} {#if stats.isLive}
<span></span> Live Live
{:else} {:else}
<span></span> Offline Offline
{/if} {/if}
</div> </div>
@ -644,7 +742,7 @@
<span class="stat-value">{stats.fps.toFixed(1)} fps</span> <span class="stat-value">{stats.fps.toFixed(1)} fps</span>
</div> </div>
{/if} {/if}
{#if stats.codec} {#if stats.codec && stats.codec !== 'N/A'}
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Codec</span> <span class="stat-label">Codec</span>
<span class="stat-value">{stats.codec}</span> <span class="stat-value">{stats.codec}</span>

View file

@ -227,6 +227,23 @@ function formatBitrate(bitrate) {
display: grid; display: grid;
gap: 2rem; gap: 2rem;
} }
.realm-owner-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.85rem;
margin-left: 1rem;
}
.realm-owner-color {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--white);
}
.realm-card { .realm-card {
background: #111; background: #111;

View file

@ -112,6 +112,19 @@
font-weight: 600; font-weight: 600;
color: var(--white); color: var(--white);
overflow: hidden; overflow: hidden;
border: 3px solid var(--border);
position: relative;
transition: all 0.3s ease;
}
.profile-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.profile-avatar.has-color.with-image {
border-width: 4px;
} }
.profile-avatar img { .profile-avatar img {
@ -129,6 +142,26 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.color-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
}
.pgp-only-badge { .pgp-only-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -176,6 +209,19 @@
background: #111; background: #111;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
position: relative;
overflow: hidden;
}
.bio-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--user-color, var(--primary));
opacity: 0.6;
} }
.bio-section h3 { .bio-section h3 {
@ -327,7 +373,12 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{:else if profile} {:else if profile}
<div class="profile-header"> <div class="profile-header">
<div class="profile-avatar"> <div
class="profile-avatar"
class:has-color={profile.colorCode}
class:with-image={profile.avatarUrl}
style="--user-color: {profile.colorCode || '#561D5E'}"
>
{#if profile.avatarUrl} {#if profile.avatarUrl}
<img src={profile.avatarUrl} alt="{profile.username}" /> <img src={profile.avatarUrl} alt="{profile.username}" />
{:else} {:else}
@ -340,9 +391,13 @@
<p class="member-since"> <p class="member-since">
Member since {new Date(profile.createdAt).toLocaleDateString()} Member since {new Date(profile.createdAt).toLocaleDateString()}
</p> </p>
<div class="color-badge">
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
</div>
{#if profile.isPgpOnly} {#if profile.isPgpOnly}
<div class="pgp-only-badge"> <div class="pgp-only-badge">
<span>🔒</span> <span>🔐</span>
<span>PGP-Only Authentication</span> <span>PGP-Only Authentication</span>
</div> </div>
{/if} {/if}
@ -367,7 +422,7 @@
</div> </div>
{#if activeTab === 'bio'} {#if activeTab === 'bio'}
<div class="bio-section"> <div class="bio-section" style="--user-color: {profile.colorCode || '#561D5E'}">
<h3>About</h3> <h3>About</h3>
{#if profile.bio} {#if profile.bio}
<p>{profile.bio}</p> <p>{profile.bio}</p>

View file

@ -22,6 +22,24 @@
let newPassword = ''; let newPassword = '';
let confirmPassword = ''; let confirmPassword = '';
// Color picker
let userColor = '#561D5E';
let newColor = '';
let showColorPicker = false;
let colorLoading = false;
let colorError = '';
let colorMessage = '';
// Popular colors to choose from
const suggestedColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#FD79A8',
'#A29BFE', '#6C5CE7', '#FAB1A0', '#74B9FF', '#A8E6CF', '#FFD3B6',
'#FF8CC3', '#00B894', '#00CEC9', '#0984E3', '#6C5CE7', '#E17055',
'#FDCB6E', '#55A3FF', '#FD79A8', '#BADC58', '#F8B739', '#FA8231',
'#EB3B5A', '#FC5C65', '#45AAF2', '#4B7BEC', '#A55EEA', '#D63031',
'#74B9FF', '#A29BFE', '#FD79A8', '#E17055', '#00B894', '#00CEC9'
];
// PGP // PGP
let pgpKeys = []; let pgpKeys = [];
let pgpOnly = false; let pgpOnly = false;
@ -67,6 +85,8 @@
pgpOnly = data.user.isPgpOnly === true; pgpOnly = data.user.isPgpOnly === true;
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || ''; pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
avatarPreview = data.user.avatarUrl || ''; avatarPreview = data.user.avatarUrl || '';
userColor = data.user.colorCode || '#561D5E';
newColor = userColor;
currentUser = data.user; currentUser = data.user;
// Update auth store with fresh data // Update auth store with fresh data
@ -81,6 +101,8 @@
pgpOnly = $auth.user.isPgpOnly === true; pgpOnly = $auth.user.isPgpOnly === true;
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || ''; pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
avatarPreview = $auth.user.avatarUrl || ''; avatarPreview = $auth.user.avatarUrl || '';
userColor = $auth.user.colorCode || '#561D5E';
newColor = userColor;
currentUser = $auth.user; currentUser = $auth.user;
} }
} }
@ -244,6 +266,55 @@
loading = false; loading = false;
} }
async function updateColor() {
if (!newColor || newColor === userColor) {
colorError = 'Please select a different color';
return;
}
colorLoading = true;
colorError = '';
colorMessage = '';
const result = await auth.updateColor(newColor);
if (result.success) {
userColor = result.color;
colorMessage = 'Color updated successfully!';
showColorPicker = false;
// Refresh the current user data to ensure consistency
if (currentUser) {
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
}
// Clear message after 3 seconds
setTimeout(() => { colorMessage = ''; }, 3000);
} else {
colorError = result.error || 'Failed to update color';
}
colorLoading = false;
}
function selectSuggestedColor(color) {
newColor = color;
}
function handleColorInput(event) {
const value = event.target.value;
// Ensure it starts with # and is uppercase
if (value.length > 0 && value[0] !== '#') {
newColor = '#' + value;
} else {
newColor = value.toUpperCase();
}
}
function isValidColor(color) {
return /^#[0-9A-F]{6}$/i.test(color);
}
// Fixed: Handle checkbox click to show warning // Fixed: Handle checkbox click to show warning
function handlePgpCheckboxClick(event) { function handlePgpCheckboxClick(event) {
if (pgpOnly) { if (pgpOnly) {
@ -777,6 +848,68 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
/* Color picker styles */
.color-picker-container {
display: flex;
align-items: center;
gap: 1rem;
}
.color-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid var(--border);
cursor: pointer;
transition: transform 0.2s;
}
.color-preview:hover {
transform: scale(1.05);
}
.color-input {
padding: 0.75rem;
font-family: monospace;
text-transform: uppercase;
}
.color-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
margin-top: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.color-option {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
border-color: var(--white);
}
.color-option.selected {
border-color: var(--white);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
</style> </style>
<div class="container"> <div class="container">
@ -790,6 +923,13 @@
> >
Profile Profile
</button> </button>
<button
class="tab-button"
class:active={activeTab === 'appearance'}
on:click={() => activeTab = 'appearance'}
>
Appearance
</button>
<button <button
class="tab-button" class="tab-button"
class:active={activeTab === 'password'} class:active={activeTab === 'password'}
@ -884,6 +1024,131 @@
{loading ? 'Saving...' : 'Save Profile'} {loading ? 'Saving...' : 'Save Profile'}
</button> </button>
</div> </div>
{:else if activeTab === 'appearance'}
<div class="card">
<h2>Appearance Settings</h2>
{#if colorMessage}
<div class="success" style="margin-bottom: 1rem;">{colorMessage}</div>
{/if}
{#if colorError}
<div class="error" style="margin-bottom: 1rem;">{colorError}</div>
{/if}
<div class="form-group">
<label>Profile Color</label>
<p style="color: var(--gray); font-size: 0.9rem; margin-bottom: 1rem;">
Your unique color appears next to your name across the platform
</p>
<div class="color-picker-container">
<div style="display: flex; align-items: center; gap: 1rem;">
<div
class="color-preview"
style="background: {userColor};"
title="Current color"
></div>
<div>
<div style="font-family: monospace; font-size: 1.1rem;">
{userColor}
</div>
<div style="font-size: 0.85rem; color: var(--gray);">
Current color
</div>
</div>
</div>
<button
class="btn btn-secondary"
on:click={() => { showColorPicker = !showColorPicker; newColor = userColor; }}
>
{showColorPicker ? 'Cancel' : 'Change Color'}
</button>
</div>
</div>
{#if showColorPicker}
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
<h3 style="margin-bottom: 1rem;">Choose a new color</h3>
<div class="form-group">
<label for="color-input">Custom Color</label>
<div class="color-input-wrapper">
<div
class="color-preview"
style="background: {isValidColor(newColor) ? newColor : '#000'};"
></div>
<input
id="color-input"
type="text"
class="color-input"
value={newColor}
on:input={handleColorInput}
placeholder="#000000"
maxlength="7"
pattern="^#[0-9A-Fa-f]{6}$"
/>
<input
type="color"
value={newColor}
on:input={(e) => newColor = e.target.value.toUpperCase()}
style="width: 50px; height: 40px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px;"
/>
</div>
<small style="color: var(--gray);">
Enter a hex color code (e.g., #FF6B6B)
</small>
</div>
<div class="form-group">
<label>Suggested Colors</label>
<div class="color-grid">
{#each suggestedColors as color}
<button
class="color-option"
class:selected={newColor === color}
style="background: {color};"
on:click={() => selectSuggestedColor(color)}
title={color}
></button>
{/each}
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
<button
on:click={updateColor}
disabled={colorLoading || !isValidColor(newColor) || newColor === userColor}
>
{colorLoading ? 'Updating...' : 'Save Color'}
</button>
<button
class="btn btn-secondary"
on:click={() => { showColorPicker = false; newColor = userColor; }}
disabled={colorLoading}
>
Cancel
</button>
</div>
{#if newColor && newColor !== userColor}
<div style="margin-top: 1rem; padding: 1rem; background: rgba(0, 0, 0, 0.3); border-radius: 4px;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div
class="color-preview"
style="background: {newColor};"
></div>
<div>
<div style="font-size: 0.85rem; color: var(--gray);">Preview</div>
<div style="font-family: monospace;">{newColor}</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else if activeTab === 'password'} {:else if activeTab === 'password'}
<div class="card"> <div class="card">
<h2>Change Password</h2> <h2>Change Password</h2>
@ -891,7 +1156,7 @@
{#if pgpOnly} {#if pgpOnly}
<div class="pgp-enabled-info" style="margin-bottom: 2rem;"> <div class="pgp-enabled-info" style="margin-bottom: 2rem;">
<p> <p>
<span class="check-icon">🔒</span> <span class="check-icon">🔐</span>
<strong>PGP-only mode is enabled</strong> <strong>PGP-only mode is enabled</strong>
</p> </p>
<p style="font-size: 0.9rem; margin-bottom: 0;"> <p style="font-size: 0.9rem; margin-bottom: 0;">

1380
text.txt

File diff suppressed because it is too large Load diff