nu
This commit is contained in:
parent
4c23ab840a
commit
e8864cc853
15 changed files with 4004 additions and 1593 deletions
|
|
@ -32,6 +32,7 @@ UserInfo AdminController::getUserFromRequest(const HttpRequestPtr &req) {
|
|||
return user;
|
||||
}
|
||||
|
||||
// Update getUsers in AdminController.cpp:
|
||||
void AdminController::getUsers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
|
|
@ -41,7 +42,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
}
|
||||
|
||||
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 "
|
||||
"FROM users u ORDER BY u.created_at DESC"
|
||||
>> [callback](const Result& r) {
|
||||
|
|
@ -56,6 +57,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
user["isAdmin"] = row["is_admin"].as<bool>();
|
||||
user["isStreamer"] = row["is_streamer"].as<bool>();
|
||||
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>());
|
||||
users.append(user);
|
||||
}
|
||||
|
|
@ -68,7 +70,6 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
callback(jsonError("Failed to get users"));
|
||||
};
|
||||
}
|
||||
|
||||
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
const std::string &realmId) {
|
||||
// Remove authentication requirement for public viewing
|
||||
|
|
@ -349,18 +349,44 @@ void RealmController::getRealm(const HttpRequestPtr &req,
|
|||
void RealmController::updateRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
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);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
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;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Realm updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
}
|
||||
|
||||
void RealmController::deleteRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
|
|
|
|||
|
|
@ -68,43 +68,92 @@ UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
|
|||
|
||||
void UserController::register_(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
LOG_DEBUG << "Registration request received";
|
||||
|
||||
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();
|
||||
|
||||
// Validate that none of the strings are 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;
|
||||
}
|
||||
|
||||
// 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](bool success, const std::string& error, int64_t userId) {
|
||||
[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));
|
||||
}
|
||||
});
|
||||
|
||||
} 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();
|
||||
|
||||
|
|
@ -113,9 +162,12 @@ void UserController::login(const HttpRequestPtr &req,
|
|||
return;
|
||||
}
|
||||
|
||||
LOG_INFO << "Login attempt for user: " << username;
|
||||
|
||||
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) {
|
||||
LOG_INFO << "Login successful for user: " << username;
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["token"] = token;
|
||||
|
|
@ -127,21 +179,36 @@ void UserController::login(const HttpRequestPtr &req,
|
|||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode; // Use colorCode for consistency with database field name
|
||||
callback(jsonResp(resp));
|
||||
} 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::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()) {
|
||||
|
|
@ -161,16 +228,28 @@ void UserController::pgpChallenge(const HttpRequestPtr &req,
|
|||
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();
|
||||
|
|
@ -194,24 +273,29 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
|
|||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode; // Add colorCode to PGP login response
|
||||
callback(jsonResp(resp));
|
||||
} 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;
|
||||
}
|
||||
|
||||
// Get fresh user data from database
|
||||
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"
|
||||
<< user.id
|
||||
>> [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"]["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"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get user data: " << e.base().what();
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
|
|
@ -252,7 +342,7 @@ void UserController::updateProfile(const HttpRequestPtr &req,
|
|||
return;
|
||||
}
|
||||
|
||||
std::string bio = (*json)["bio"].asString();
|
||||
std::string bio = (*json).isMember("bio") ? (*json)["bio"].asString() : "";
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*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();
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
|
|
@ -283,6 +378,11 @@ void UserController::updatePassword(const HttpRequestPtr &req,
|
|||
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();
|
||||
|
||||
|
|
@ -301,10 +401,15 @@ void UserController::updatePassword(const HttpRequestPtr &req,
|
|||
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));
|
||||
|
|
@ -317,7 +422,7 @@ void UserController::togglePgpOnly(const HttpRequestPtr &req,
|
|||
return;
|
||||
}
|
||||
|
||||
bool enable = (*json)["enable"].asBool();
|
||||
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"
|
||||
|
|
@ -338,10 +443,15 @@ void UserController::togglePgpOnly(const HttpRequestPtr &req,
|
|||
LOG_ERROR << "Failed to update PGP setting: " << e.base().what();
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
|
|
@ -354,6 +464,11 @@ void UserController::addPgpKey(const HttpRequestPtr &req,
|
|||
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();
|
||||
|
||||
|
|
@ -389,10 +504,15 @@ void UserController::addPgpKey(const HttpRequestPtr &req,
|
|||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
|
|
@ -423,10 +543,15 @@ void UserController::getPgpKeys(const HttpRequestPtr &req,
|
|||
LOG_ERROR << "Failed to get PGP keys: " << e.base().what();
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
|
|
@ -548,15 +673,19 @@ void UserController::uploadAvatar(const HttpRequestPtr &req,
|
|||
LOG_ERROR << "Failed to update avatar: " << e.base().what();
|
||||
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 &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username) {
|
||||
// Public endpoint - no authentication required
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
*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"
|
||||
<< username
|
||||
>> [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"]["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>();
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
|
|
@ -580,11 +710,16 @@ void UserController::getProfile(const HttpRequestPtr &,
|
|||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
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 &,
|
||||
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.created_at "
|
||||
|
|
@ -611,4 +746,103 @@ void UserController::getUserPgpKeys(const HttpRequestPtr &,
|
|||
LOG_ERROR << "Failed to get user PGP keys: " << e.base().what();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ public:
|
|||
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
|
||||
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", 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
|
||||
|
||||
void register_(const HttpRequestPtr &req,
|
||||
|
|
@ -62,6 +64,13 @@ public:
|
|||
void getUserPgpKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
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:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
};
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
#include <drogon/utils/Utilities.h>
|
||||
#include <regex>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -27,9 +29,128 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
|||
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,
|
||||
const std::string& publicKey, const std::string& fingerprint,
|
||||
std::function<void(bool, const std::string&, int64_t)> callback) {
|
||||
try {
|
||||
LOG_DEBUG << "Starting user registration for: " << username;
|
||||
|
||||
// Validate username
|
||||
if (username.length() < 3 || username.length() > 30) {
|
||||
|
|
@ -49,79 +170,139 @@ void AuthService::registerUser(const std::string& username, const std::string& p
|
|||
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();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false, "Database connection error", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
*dbClient << "SELECT id FROM users WHERE username = $1"
|
||||
*dbClient << "SELECT id FROM users WHERE username = $1 LIMIT 1"
|
||||
<< username
|
||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r) {
|
||||
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r) {
|
||||
try {
|
||||
if (!r.empty()) {
|
||||
LOG_WARN << "Username already exists: " << username;
|
||||
callback(false, "Username already exists", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG << "Username available, checking fingerprint";
|
||||
|
||||
// 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
|
||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r2) {
|
||||
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r2) {
|
||||
try {
|
||||
if (!r2.empty()) {
|
||||
LOG_WARN << "Fingerprint already exists";
|
||||
callback(false, "This PGP key is already registered", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG << "Fingerprint available, hashing 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
|
||||
auto trans = dbClient->newTransaction();
|
||||
LOG_DEBUG << "Password hashed, inserting user";
|
||||
|
||||
// Insert user with explicit false values for booleans
|
||||
*trans << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only) VALUES ($1, $2, false, false, false) RETURNING id"
|
||||
<< username << hash
|
||||
>> [trans, publicKey, fingerprint, callback](const Result& r3) {
|
||||
// Insert user first (without transaction for simplicity)
|
||||
*dbClient << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only, user_color) "
|
||||
"VALUES ($1, $2, false, false, false, $3) RETURNING id"
|
||||
<< username << hash << color
|
||||
>> [dbClient, publicKey, fingerprint, callback, username](const Result& r3) {
|
||||
try {
|
||||
if (r3.empty()) {
|
||||
LOG_ERROR << "Failed to insert user";
|
||||
callback(false, "Failed to create user", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t userId = r3[0]["id"].as<int64_t>();
|
||||
LOG_INFO << "User created with ID: " << userId;
|
||||
|
||||
// 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
|
||||
>> [trans, callback, userId](const Result&) {
|
||||
// Transaction commits automatically
|
||||
>> [callback, userId, username](const Result&) {
|
||||
LOG_INFO << "User registration complete for: " << username << " (ID: " << 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();
|
||||
// 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);
|
||||
};
|
||||
} 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();
|
||||
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) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
LOG_ERROR << "Database error checking fingerprint: " << e.base().what();
|
||||
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) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
LOG_ERROR << "Database error checking username: " << e.base().what();
|
||||
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,
|
||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||
try {
|
||||
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 "
|
||||
"FROM users WHERE username = $1"
|
||||
*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 LIMIT 1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
try {
|
||||
if (r.empty()) {
|
||||
callback(false, "", UserInfo{});
|
||||
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>();
|
||||
|
||||
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{});
|
||||
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.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>();
|
||||
|
||||
std::string token = generateToken(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) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
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,
|
||||
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false, "", "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate random challenge
|
||||
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,
|
||||
const std::string& challenge,
|
||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||
try {
|
||||
// Get stored challenge from Redis
|
||||
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
||||
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
||||
try {
|
||||
if (storedChallenge.empty() || storedChallenge != challenge) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
|
|
@ -221,10 +432,17 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
// For now, we'll trust the client-side verification
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
||||
"FROM users WHERE username = $1"
|
||||
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 username = $1 LIMIT 1"
|
||||
<< username
|
||||
>> [callback, this](const Result& r) {
|
||||
try {
|
||||
if (r.empty()) {
|
||||
callback(false, "", UserInfo{});
|
||||
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.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>();
|
||||
|
||||
std::string token = generateToken(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) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
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) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("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("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
||||
.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_});
|
||||
|
||||
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) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
try {
|
||||
auto decoded = jwt::decode(token);
|
||||
|
||||
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") ?
|
||||
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;
|
||||
} catch (const std::exception& e) {
|
||||
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,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
try {
|
||||
// Validate new password
|
||||
std::string error;
|
||||
if (!validatePassword(newPassword, error)) {
|
||||
|
|
@ -310,11 +558,17 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false, "Database connection error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
*dbClient << "SELECT password_hash FROM users WHERE id = $1"
|
||||
*dbClient << "SELECT password_hash FROM users WHERE id = $1 LIMIT 1"
|
||||
<< userId
|
||||
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
||||
try {
|
||||
if (r.empty()) {
|
||||
callback(false, "User not found");
|
||||
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>();
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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"
|
||||
<< 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();
|
||||
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) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
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{});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
|
||||
struct UserInfo {
|
||||
int64_t id;
|
||||
int64_t id = 0;
|
||||
std::string username;
|
||||
bool isAdmin;
|
||||
bool isStreamer;
|
||||
bool isPgpOnly;
|
||||
bool isAdmin = false;
|
||||
bool isStreamer = false;
|
||||
bool isPgpOnly = false;
|
||||
std::string bio;
|
||||
std::string avatarUrl;
|
||||
std::string pgpOnlyEnabledAt;
|
||||
std::string colorCode;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
|
|
@ -23,41 +24,38 @@ public:
|
|||
return instance;
|
||||
}
|
||||
|
||||
// User registration
|
||||
void registerUser(const std::string& username, const std::string& password,
|
||||
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,
|
||||
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,
|
||||
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, const std::string& challenge,
|
||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
||||
void verifyPgpLogin(const std::string& username, const std::string& signature,
|
||||
const std::string& challenge,
|
||||
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);
|
||||
|
||||
// Update password
|
||||
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword,
|
||||
std::function<void(bool success, const std::string& error)> callback);
|
||||
// New method to fetch complete user info including color
|
||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||
|
||||
// Check password requirements
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
void updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
// Generate JWT token
|
||||
std::string generateToken(const UserInfo& user);
|
||||
void updateUserColor(int64_t userId, const std::string& newColor,
|
||||
std::function<void(bool, const std::string&, const std::string&)> callback);
|
||||
|
||||
void generateUniqueColor(std::function<void(const std::string& color)> callback);
|
||||
|
||||
private:
|
||||
AuthService() = default;
|
||||
~AuthService() = default;
|
||||
AuthService(const AuthService&) = delete;
|
||||
AuthService& operator=(const AuthService&) = delete;
|
||||
|
||||
std::string jwtSecret_;
|
||||
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
pgp_only_enabled_at TIMESTAMP WITH TIME ZONE,
|
||||
bio TEXT DEFAULT '',
|
||||
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,
|
||||
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_is_streamer ON users(is_streamer);
|
||||
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_fingerprint ON pgp_keys(fingerprint);
|
||||
CREATE INDEX idx_realms_user_id ON realms(user_id);
|
||||
|
|
|
|||
|
|
@ -95,6 +95,48 @@ function createAuthStore() {
|
|||
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) {
|
||||
update(state => ({
|
||||
...state,
|
||||
|
|
@ -126,3 +168,8 @@ export const isStreamer = derived(
|
|||
auth,
|
||||
$auth => $auth.user?.isStreamer || false
|
||||
);
|
||||
|
||||
export const userColor = derived(
|
||||
auth,
|
||||
$auth => $auth.user?.colorCode || '#561D5E'
|
||||
);
|
||||
93
frontend/src/lib/stores/user.js
Normal file
93
frontend/src/lib/stores/user.js
Normal 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'
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
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 '../app.css';
|
||||
|
||||
|
|
@ -87,13 +87,27 @@
|
|||
justify-content: center;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
transition: border-color 0.2s;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -126,7 +140,8 @@
|
|||
margin-bottom: 0.25rem;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +149,15 @@
|
|||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray);
|
||||
|
|
@ -169,7 +193,13 @@
|
|||
{#if !$auth.loading}
|
||||
{#if $isAuthenticated}
|
||||
<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}
|
||||
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
|
||||
{:else}
|
||||
|
|
@ -181,6 +211,10 @@
|
|||
<div class="dropdown">
|
||||
<div class="dropdown-header">
|
||||
<a href="/profile/{$auth.user.username}" class="dropdown-username">
|
||||
<span
|
||||
class="user-color-dot"
|
||||
style="background: {$userColor}"
|
||||
></span>
|
||||
{$auth.user.username}
|
||||
</a>
|
||||
<div class="dropdown-role">
|
||||
|
|
|
|||
|
|
@ -407,6 +407,22 @@
|
|||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
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 {
|
||||
|
|
@ -438,10 +454,24 @@
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.stream-header h1 {
|
||||
|
|
@ -462,11 +492,38 @@
|
|||
height: 48px;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
|
|
@ -566,6 +623,39 @@
|
|||
padding: 4rem 2rem;
|
||||
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>
|
||||
|
||||
{#if loading}
|
||||
|
|
@ -581,7 +671,7 @@
|
|||
{:else if realm}
|
||||
<div class="stream-container">
|
||||
<div class="player-section">
|
||||
<div class="player-wrapper">
|
||||
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||
<div class="player-area">
|
||||
<div class="dummy-player"></div>
|
||||
<div class="player-container">
|
||||
|
|
@ -590,15 +680,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stream-info-section">
|
||||
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||
<div class="stream-header">
|
||||
<h1>{realm.name}</h1>
|
||||
<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}
|
||||
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
|
||||
<img src={realm.avatarUrl} alt={realm.username} />
|
||||
{:else}
|
||||
<div class="streamer-avatar"></div>
|
||||
{realm.username?.charAt(0).toUpperCase() || '?'}
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="streamer-name">{realm.username}</div>
|
||||
<div class="viewer-count">
|
||||
|
|
@ -612,13 +709,14 @@
|
|||
|
||||
<div class="sidebar">
|
||||
<div class="stats-section">
|
||||
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
|
||||
<h3>Stream Stats</h3>
|
||||
|
||||
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
||||
{#if stats.isLive}
|
||||
<span>●</span> Live
|
||||
Live
|
||||
{:else}
|
||||
<span>●</span> Offline
|
||||
Offline
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -644,7 +742,7 @@
|
|||
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.codec}
|
||||
{#if stats.codec && stats.codec !== 'N/A'}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Codec</span>
|
||||
<span class="stat-value">{stats.codec}</span>
|
||||
|
|
|
|||
|
|
@ -227,6 +227,23 @@ function formatBitrate(bitrate) {
|
|||
display: grid;
|
||||
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 {
|
||||
background: #111;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@
|
|||
font-weight: 600;
|
||||
color: var(--white);
|
||||
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 {
|
||||
|
|
@ -129,6 +142,26 @@
|
|||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -176,6 +209,19 @@
|
|||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
|
|
@ -327,7 +373,12 @@
|
|||
<div class="error">{error}</div>
|
||||
{:else if profile}
|
||||
<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}
|
||||
<img src={profile.avatarUrl} alt="{profile.username}" />
|
||||
{:else}
|
||||
|
|
@ -340,9 +391,13 @@
|
|||
<p class="member-since">
|
||||
Member since {new Date(profile.createdAt).toLocaleDateString()}
|
||||
</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}
|
||||
<div class="pgp-only-badge">
|
||||
<span>🔒</span>
|
||||
<span>🔐</span>
|
||||
<span>PGP-Only Authentication</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -367,7 +422,7 @@
|
|||
</div>
|
||||
|
||||
{#if activeTab === 'bio'}
|
||||
<div class="bio-section">
|
||||
<div class="bio-section" style="--user-color: {profile.colorCode || '#561D5E'}">
|
||||
<h3>About</h3>
|
||||
{#if profile.bio}
|
||||
<p>{profile.bio}</p>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@
|
|||
let newPassword = '';
|
||||
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
|
||||
let pgpKeys = [];
|
||||
let pgpOnly = false;
|
||||
|
|
@ -67,6 +85,8 @@
|
|||
pgpOnly = data.user.isPgpOnly === true;
|
||||
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
|
||||
avatarPreview = data.user.avatarUrl || '';
|
||||
userColor = data.user.colorCode || '#561D5E';
|
||||
newColor = userColor;
|
||||
currentUser = data.user;
|
||||
|
||||
// Update auth store with fresh data
|
||||
|
|
@ -81,6 +101,8 @@
|
|||
pgpOnly = $auth.user.isPgpOnly === true;
|
||||
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
|
||||
avatarPreview = $auth.user.avatarUrl || '';
|
||||
userColor = $auth.user.colorCode || '#561D5E';
|
||||
newColor = userColor;
|
||||
currentUser = $auth.user;
|
||||
}
|
||||
}
|
||||
|
|
@ -244,6 +266,55 @@
|
|||
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
|
||||
function handlePgpCheckboxClick(event) {
|
||||
if (pgpOnly) {
|
||||
|
|
@ -777,6 +848,68 @@
|
|||
align-items: center;
|
||||
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>
|
||||
|
||||
<div class="container">
|
||||
|
|
@ -790,6 +923,13 @@
|
|||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'appearance'}
|
||||
on:click={() => activeTab = 'appearance'}
|
||||
>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'password'}
|
||||
|
|
@ -884,6 +1024,131 @@
|
|||
{loading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</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'}
|
||||
<div class="card">
|
||||
<h2>Change Password</h2>
|
||||
|
|
@ -891,7 +1156,7 @@
|
|||
{#if pgpOnly}
|
||||
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
|
||||
<p>
|
||||
<span class="check-icon">🔒</span>
|
||||
<span class="check-icon">🔐</span>
|
||||
<strong>PGP-only mode is enabled</strong>
|
||||
</p>
|
||||
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue