3329 lines
No EOL
152 KiB
C++
3329 lines
No EOL
152 KiB
C++
#include "AdminController.h"
|
|
#include "../services/OmeClient.h"
|
|
#include "../services/RedisHelper.h"
|
|
#include "../services/CensorService.h"
|
|
#include "../common/HttpHelpers.h"
|
|
#include "../common/AuthHelpers.h"
|
|
#include "../common/FileValidation.h"
|
|
#include <filesystem>
|
|
#include <algorithm>
|
|
#include <fstream>
|
|
#include <ctime>
|
|
#include <chrono>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <zip.h>
|
|
|
|
using namespace drogon::orm;
|
|
|
|
namespace {
|
|
// Notify chat-service to refresh its sticker cache
|
|
void notifyChatServiceStickerUpdate() {
|
|
auto client = HttpClient::newHttpClient("http://chat-service:8081");
|
|
auto req = HttpRequest::newHttpRequest();
|
|
req->setMethod(Post);
|
|
req->setPath("/api/chat/admin/stickers/refresh");
|
|
|
|
client->sendRequest(req, [](ReqResult result, const HttpResponsePtr& resp) {
|
|
if (result != ReqResult::Ok) {
|
|
LOG_WARN << "Failed to notify chat-service of sticker update: " << (int)result;
|
|
} else {
|
|
LOG_INFO << "Chat-service sticker cache refresh triggered";
|
|
}
|
|
});
|
|
}
|
|
|
|
// Notify chat-service to refresh its censored words cache
|
|
void notifyChatServiceCensoredWordsUpdate() {
|
|
auto client = HttpClient::newHttpClient("http://chat-service:8081");
|
|
auto req = HttpRequest::newHttpRequest();
|
|
req->setMethod(Post);
|
|
req->setPath("/api/chat/admin/censored-words/refresh");
|
|
|
|
client->sendRequest(req, [](ReqResult result, const HttpResponsePtr& resp) {
|
|
if (result != ReqResult::Ok) {
|
|
LOG_WARN << "Failed to notify chat-service of censored words update: " << (int)result;
|
|
} else {
|
|
LOG_INFO << "Chat-service censored words cache refresh triggered";
|
|
}
|
|
});
|
|
}
|
|
|
|
// Rate limiter for censored words updates (10 per minute per admin)
|
|
struct CensoredWordsRateLimiter {
|
|
std::mutex mutex;
|
|
std::unordered_map<int, std::vector<std::chrono::steady_clock::time_point>> requestTimes;
|
|
static constexpr int MAX_REQUESTS = 10;
|
|
static constexpr int WINDOW_SECONDS = 60;
|
|
|
|
bool isAllowed(int userId) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
auto now = std::chrono::steady_clock::now();
|
|
auto cutoff = now - std::chrono::seconds(WINDOW_SECONDS);
|
|
|
|
auto& times = requestTimes[userId];
|
|
// Remove old entries
|
|
times.erase(
|
|
std::remove_if(times.begin(), times.end(),
|
|
[cutoff](const auto& t) { return t < cutoff; }),
|
|
times.end()
|
|
);
|
|
|
|
if (times.size() >= MAX_REQUESTS) {
|
|
return false;
|
|
}
|
|
|
|
times.push_back(now);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
CensoredWordsRateLimiter censoredWordsRateLimiter;
|
|
|
|
// SECURITY FIX #26: Rate limiter for admin role operations (30 per minute per admin)
|
|
struct AdminRoleRateLimiter {
|
|
std::mutex mutex;
|
|
std::unordered_map<int, std::vector<std::chrono::steady_clock::time_point>> requestTimes;
|
|
static constexpr int MAX_REQUESTS = 30;
|
|
static constexpr int WINDOW_SECONDS = 60;
|
|
|
|
bool isAllowed(int userId) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
auto now = std::chrono::steady_clock::now();
|
|
auto cutoff = now - std::chrono::seconds(WINDOW_SECONDS);
|
|
|
|
auto& times = requestTimes[userId];
|
|
// Remove old entries
|
|
times.erase(
|
|
std::remove_if(times.begin(), times.end(),
|
|
[cutoff](const auto& t) { return t < cutoff; }),
|
|
times.end()
|
|
);
|
|
|
|
if (times.size() >= MAX_REQUESTS) {
|
|
return false;
|
|
}
|
|
|
|
times.push_back(now);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
AdminRoleRateLimiter adminRoleRateLimiter;
|
|
|
|
// SECURITY FIX #27: HTML entity escaping to prevent XSS in site title
|
|
std::string htmlEscape(const std::string& input) {
|
|
std::string output;
|
|
output.reserve(input.size() * 1.1); // Reserve a bit more space for entities
|
|
for (char c : input) {
|
|
switch (c) {
|
|
case '&': output += "&"; break;
|
|
case '<': output += "<"; break;
|
|
case '>': output += ">"; break;
|
|
case '"': output += """; break;
|
|
case '\'': output += "'"; break;
|
|
default: output += c; break;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
}
|
|
|
|
// Update getUsers in AdminController.cpp:
|
|
void AdminController::getUsers(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
LOG_DEBUG << "[getUsers] Auth check - user.id: " << user.id << ", isAdmin: " << user.isAdmin;
|
|
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
LOG_WARN << "[getUsers] Unauthorized access attempt";
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG << "[getUsers] Executing database query for users...";
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_sticker_creator, u.is_uploader, u.is_texter, u.is_watch_creator, u.is_disabled, u.pending_uberban, u.created_at, u.user_color, "
|
|
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count, "
|
|
"(SELECT COUNT(*) FROM referral_codes WHERE owner_id = u.id) as referral_code_count "
|
|
"FROM users u ORDER BY u.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
LOG_DEBUG << "[getUsers] Query successful, returned " << r.size() << " users";
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value users(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value user;
|
|
user["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
user["username"] = row["username"].as<std::string>();
|
|
user["isAdmin"] = row["is_admin"].as<bool>();
|
|
user["isModerator"] = row["is_moderator"].isNull() ? false : row["is_moderator"].as<bool>();
|
|
user["isStreamer"] = row["is_streamer"].as<bool>();
|
|
user["isRestreamer"] = row["is_restreamer"].isNull() ? false : row["is_restreamer"].as<bool>();
|
|
user["isBot"] = row["is_bot"].isNull() ? false : row["is_bot"].as<bool>();
|
|
user["isStickerCreator"] = row["is_sticker_creator"].isNull() ? false : row["is_sticker_creator"].as<bool>();
|
|
user["isUploader"] = row["is_uploader"].isNull() ? false : row["is_uploader"].as<bool>();
|
|
user["isTexter"] = row["is_texter"].isNull() ? false : row["is_texter"].as<bool>();
|
|
user["isWatchCreator"] = row["is_watch_creator"].isNull() ? false : row["is_watch_creator"].as<bool>();
|
|
user["isDisabled"] = row["is_disabled"].isNull() ? false : row["is_disabled"].as<bool>();
|
|
user["pendingUberban"] = row["pending_uberban"].isNull() ? false : row["pending_uberban"].as<bool>();
|
|
user["createdAt"] = row["created_at"].as<std::string>();
|
|
user["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
|
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
|
|
user["referralCodeCount"] = static_cast<Json::Int64>(row["referral_code_count"].as<int64_t>());
|
|
users.append(user);
|
|
LOG_DEBUG << "[getUsers] Added user: " << user["username"].asString();
|
|
}
|
|
|
|
resp["users"] = users;
|
|
LOG_DEBUG << "[getUsers] Sending response with " << users.size() << " users";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get users", "Failed to get users");
|
|
}
|
|
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// Get live realms from database
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT r.id, r.name, r.stream_key, r.viewer_count, "
|
|
"u.username FROM realms r "
|
|
"JOIN users u ON r.user_id = u.id "
|
|
"WHERE r.is_live = true"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value streams(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value stream;
|
|
stream["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
stream["name"] = row["name"].as<std::string>();
|
|
stream["streamKey"] = row["stream_key"].as<std::string>();
|
|
stream["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
|
|
stream["username"] = row["username"].as<std::string>();
|
|
streams.append(stream);
|
|
}
|
|
|
|
resp["streams"] = streams;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get active streams", "Failed to get active streams");
|
|
}
|
|
|
|
void AdminController::disconnectStream(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &streamKey) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// Add to Redis set for OpenResty to disconnect
|
|
RedisHelper::addToSet("streams_to_disconnect", streamKey);
|
|
|
|
// Also try direct disconnect
|
|
OmeClient::getInstance().disconnectStream(streamKey, [callback](bool) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Stream disconnect initiated";
|
|
callback(jsonResp(resp));
|
|
});
|
|
}
|
|
|
|
void AdminController::promoteToStreamer(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_streamer = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User promoted to streamer";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "promote user", "Failed to promote user");
|
|
}
|
|
|
|
void AdminController::demoteFromStreamer(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_streamer = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User demoted from streamer";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "demote user", "Failed to demote user");
|
|
}
|
|
|
|
void AdminController::promoteToRestreamer(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_restreamer = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User promoted to restreamer";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "promote user to restreamer", "Failed to promote user to restreamer");
|
|
}
|
|
|
|
void AdminController::demoteFromRestreamer(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_restreamer = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User demoted from restreamer";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "demote user from restreamer", "Failed to demote user from restreamer");
|
|
}
|
|
|
|
void AdminController::promoteToBot(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_bot = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted bot role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant bot role", "Failed to grant bot role");
|
|
}
|
|
|
|
void AdminController::demoteFromBot(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_bot = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User bot role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke bot role", "Failed to revoke bot role");
|
|
}
|
|
|
|
void AdminController::getAllBotApiKeys(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT b.id, b.user_id, b.name, b.scopes, b.is_active, b.last_used_at, b.expires_at, b.created_at, "
|
|
"u.username FROM bot_api_keys b "
|
|
"JOIN users u ON b.user_id = u.id "
|
|
"ORDER BY b.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value keys(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value key;
|
|
key["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
key["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
key["username"] = row["username"].as<std::string>();
|
|
key["name"] = row["name"].as<std::string>();
|
|
key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as<std::string>();
|
|
key["isActive"] = row["is_active"].as<bool>();
|
|
key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as<std::string>();
|
|
key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as<std::string>();
|
|
key["createdAt"] = row["created_at"].as<std::string>();
|
|
keys.append(key);
|
|
}
|
|
|
|
resp["keys"] = keys;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get all bot API keys", "Failed to get API keys");
|
|
}
|
|
|
|
void AdminController::deleteBotApiKey(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &keyId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t keyIdInt;
|
|
try {
|
|
keyIdInt = std::stoll(keyId);
|
|
} catch (...) {
|
|
callback(jsonError("Invalid key ID"));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
// Hard delete the key (admin action)
|
|
*dbClient << "DELETE FROM bot_api_keys WHERE id = $1"
|
|
<< keyIdInt
|
|
>> [callback](const Result& r) {
|
|
if (r.affectedRows() == 0) {
|
|
callback(jsonError("API key not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "API key deleted";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete bot API key", "Failed to delete API key");
|
|
}
|
|
|
|
void AdminController::uploadStickers(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser fileUpload;
|
|
if (fileUpload.parse(req) != 0 || fileUpload.getFiles().empty()) {
|
|
callback(jsonError("No files uploaded"));
|
|
return;
|
|
}
|
|
|
|
auto files = fileUpload.getFiles();
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Validate file sizes (4MB limit)
|
|
const size_t maxFileSize = 4 * 1024 * 1024; // 4MB
|
|
for (const auto& file : files) {
|
|
if (file.fileLength() > maxFileSize) {
|
|
callback(jsonError("File too large: " + file.getFileName() + " (max 4MB)"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value uploaded(Json::arrayValue);
|
|
|
|
// Ensure directory exists
|
|
std::filesystem::create_directories("./uploads/stickers");
|
|
|
|
// Process each uploaded file
|
|
int validCount = 0;
|
|
for (const auto& file : files) {
|
|
// Get the sticker name from the parameter (format: sticker_name[filename])
|
|
std::string paramName = file.getFileName();
|
|
std::string stickerName = fileUpload.getParameter<std::string>("name_" + paramName);
|
|
|
|
if (stickerName.empty()) {
|
|
// Use filename without extension as default name
|
|
stickerName = paramName.substr(0, paramName.find_last_of('.'));
|
|
}
|
|
|
|
// Validate file content using magic bytes (not just extension)
|
|
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength(), true);
|
|
if (!validation.valid) {
|
|
LOG_WARN << "Sticker upload rejected for " << paramName << ": invalid image magic bytes";
|
|
continue; // Skip invalid file types
|
|
}
|
|
|
|
// Use the extension from magic byte validation (ignores client-provided extension)
|
|
std::string ext = validation.extension;
|
|
std::string uniqueName = drogon::utils::getUuid() + ext;
|
|
std::string filePath = "/uploads/stickers/" + uniqueName;
|
|
std::string fullPath = "./uploads/stickers/" + uniqueName;
|
|
|
|
// Save file
|
|
file.saveAs(fullPath);
|
|
validCount++;
|
|
|
|
// Insert into database
|
|
*dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id"
|
|
<< stickerName << filePath
|
|
>> [uploaded, stickerName, filePath](const Result& r) mutable {
|
|
if (!r.empty()) {
|
|
Json::Value sticker;
|
|
sticker["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
|
sticker["name"] = stickerName;
|
|
sticker["filePath"] = filePath;
|
|
uploaded.append(sticker);
|
|
}
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to insert sticker: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
if (validCount == 0) {
|
|
callback(jsonError("No valid image files uploaded. Only JPEG, PNG, GIF, and WebP are allowed."));
|
|
return;
|
|
}
|
|
|
|
resp["stickers"] = uploaded;
|
|
resp["count"] = validCount;
|
|
|
|
// Notify chat-service to refresh sticker cache
|
|
notifyChatServiceStickerUpdate();
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
|
|
void AdminController::getStickers(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, name, file_path, created_at FROM stickers WHERE is_active = true ORDER BY name ASC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value stickers(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value sticker;
|
|
sticker["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
sticker["name"] = row["name"].as<std::string>();
|
|
sticker["filePath"] = row["file_path"].as<std::string>();
|
|
sticker["createdAt"] = row["created_at"].as<std::string>();
|
|
stickers.append(sticker);
|
|
}
|
|
|
|
resp["stickers"] = stickers;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get stickers", "Failed to get stickers");
|
|
}
|
|
|
|
void AdminController::deleteSticker(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &stickerId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(stickerId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// First get the file path before deleting
|
|
*dbClient << "SELECT file_path FROM stickers WHERE id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Sticker not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
|
|
// Hard delete from database
|
|
*dbClient << "DELETE FROM stickers WHERE id = $1"
|
|
<< id
|
|
>> [callback, filePath](const Result&) {
|
|
// Delete file from disk
|
|
try {
|
|
// Convert web path to filesystem path
|
|
// file_path is like "/uploads/stickers/uuid.ext"
|
|
std::string fullPath = "." + filePath;
|
|
|
|
if (std::filesystem::exists(fullPath)) {
|
|
std::filesystem::remove(fullPath);
|
|
LOG_INFO << "Deleted sticker file: " << fullPath;
|
|
} else {
|
|
LOG_WARN << "Sticker file not found for deletion: " << fullPath;
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_WARN << "Failed to delete sticker file " << filePath << ": " << e.what();
|
|
// Continue anyway - DB record is already deleted
|
|
}
|
|
|
|
// Notify chat-service to refresh sticker cache
|
|
notifyChatServiceStickerUpdate();
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Sticker permanently deleted";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete sticker", "Failed to delete sticker");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get sticker", "Failed to get sticker");
|
|
}
|
|
|
|
void AdminController::renameSticker(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &stickerId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
if (!jsonPtr) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
std::string newName = (*jsonPtr).get("name", "").asString();
|
|
if (newName.empty()) {
|
|
callback(jsonError("Name is required"));
|
|
return;
|
|
}
|
|
|
|
// Validate name (alphanumeric and underscores only, 1-50 chars)
|
|
if (newName.length() > 50) {
|
|
callback(jsonError("Name must be 50 characters or less"));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(stickerId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
*dbClient << "UPDATE stickers SET name = $1 WHERE id = $2 RETURNING id, name, file_path"
|
|
<< newName << id
|
|
>> [callback](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Sticker not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
// Notify chat-service to refresh sticker cache
|
|
notifyChatServiceStickerUpdate();
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["sticker"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
|
resp["sticker"]["name"] = r[0]["name"].as<std::string>();
|
|
resp["sticker"]["filePath"] = r[0]["file_path"].as<std::string>();
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to rename sticker: " << e.base().what();
|
|
if (std::string(e.base().what()).find("duplicate") != std::string::npos) {
|
|
callback(jsonError("A sticker with this name already exists"));
|
|
} else {
|
|
callback(jsonError("Failed to rename sticker"));
|
|
}
|
|
};
|
|
}
|
|
|
|
void AdminController::promoteToStickerCreator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_sticker_creator = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted sticker creator role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant sticker creator role", "Failed to grant sticker creator role");
|
|
}
|
|
|
|
void AdminController::demoteFromStickerCreator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_sticker_creator = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User sticker creator role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke sticker creator role", "Failed to revoke sticker creator role");
|
|
}
|
|
|
|
void AdminController::promoteToUploader(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_uploader = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted uploader role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant uploader role", "Failed to grant uploader role");
|
|
}
|
|
|
|
void AdminController::demoteFromUploader(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_uploader = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User uploader role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke uploader role", "Failed to revoke uploader role");
|
|
}
|
|
|
|
void AdminController::promoteToTexter(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_texter = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted texter role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant texter role", "Failed to grant texter role");
|
|
}
|
|
|
|
void AdminController::demoteFromTexter(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_texter = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User texter role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke texter role", "Failed to revoke texter role");
|
|
}
|
|
|
|
void AdminController::promoteToWatchCreator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_watch_creator = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted watch creator role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant watch creator role", "Failed to grant watch creator role");
|
|
}
|
|
|
|
void AdminController::demoteFromWatchCreator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_watch_creator = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User watch creator role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke watch creator role", "Failed to revoke watch creator role");
|
|
}
|
|
|
|
void AdminController::promoteToModerator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_moderator = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User granted site-wide moderator role";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "grant moderator role", "Failed to grant moderator role");
|
|
}
|
|
|
|
void AdminController::demoteFromModerator(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #26: Rate limit role operations
|
|
if (!adminRoleRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE users SET is_moderator = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User site-wide moderator role revoked";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "revoke moderator role", "Failed to revoke moderator role");
|
|
}
|
|
|
|
void AdminController::getStickerSubmissions(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT s.id, s.name, s.file_path, s.status, s.denial_reason, s.created_at, "
|
|
"u.id as submitter_id, u.username as submitter_username "
|
|
"FROM sticker_submissions s "
|
|
"JOIN users u ON s.submitted_by = u.id "
|
|
"WHERE s.status = 'pending' "
|
|
"ORDER BY s.created_at ASC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value submissions(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value sub;
|
|
sub["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
sub["name"] = row["name"].as<std::string>();
|
|
sub["filePath"] = row["file_path"].as<std::string>();
|
|
sub["status"] = row["status"].as<std::string>();
|
|
sub["createdAt"] = row["created_at"].as<std::string>();
|
|
sub["submitterId"] = static_cast<Json::Int64>(row["submitter_id"].as<int64_t>());
|
|
sub["submitterUsername"] = row["submitter_username"].as<std::string>();
|
|
submissions.append(sub);
|
|
}
|
|
|
|
resp["submissions"] = submissions;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get sticker submissions", "Failed to get sticker submissions");
|
|
}
|
|
|
|
void AdminController::approveStickerSubmission(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &submissionId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
std::string newName = "";
|
|
if (jsonPtr) {
|
|
newName = (*jsonPtr).get("name", "").asString();
|
|
}
|
|
|
|
int64_t id = std::stoll(submissionId);
|
|
int64_t reviewerId = user.id;
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// First get the submission details
|
|
*dbClient << "SELECT id, name, file_path FROM sticker_submissions WHERE id = $1 AND status = 'pending'"
|
|
<< id
|
|
>> [callback, dbClient, id, reviewerId, newName](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Submission not found or already processed", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string stickerName = newName.empty() ? r[0]["name"].as<std::string>() : newName;
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
|
|
// Insert into stickers table
|
|
*dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id"
|
|
<< stickerName << filePath
|
|
>> [callback, dbClient, id, reviewerId, stickerName, filePath](const Result& insertResult) {
|
|
if (insertResult.empty()) {
|
|
callback(jsonError("Failed to create sticker"));
|
|
return;
|
|
}
|
|
|
|
int64_t newStickerId = insertResult[0]["id"].as<int64_t>();
|
|
|
|
// Update submission status
|
|
*dbClient << "UPDATE sticker_submissions SET status = 'approved', "
|
|
"reviewed_by = $1, reviewed_at = CURRENT_TIMESTAMP WHERE id = $2"
|
|
<< reviewerId << id
|
|
>> [callback, newStickerId, stickerName, filePath](const Result&) {
|
|
// Notify chat-service to refresh sticker cache
|
|
notifyChatServiceStickerUpdate();
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Sticker approved and added";
|
|
resp["sticker"]["id"] = static_cast<Json::Int64>(newStickerId);
|
|
resp["sticker"]["name"] = stickerName;
|
|
resp["sticker"]["filePath"] = filePath;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update submission status: " << e.base().what();
|
|
callback(jsonError("Failed to update submission status"));
|
|
};
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to create sticker: " << e.base().what();
|
|
if (std::string(e.base().what()).find("duplicate") != std::string::npos) {
|
|
callback(jsonError("A sticker with this name already exists"));
|
|
} else {
|
|
callback(jsonError("Failed to create sticker"));
|
|
}
|
|
};
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get submission", "Failed to get submission");
|
|
}
|
|
|
|
void AdminController::denyStickerSubmission(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &submissionId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
std::string reason = "";
|
|
if (jsonPtr) {
|
|
reason = (*jsonPtr).get("reason", "").asString();
|
|
}
|
|
|
|
int64_t id = std::stoll(submissionId);
|
|
int64_t reviewerId = user.id;
|
|
auto dbClient = app().getDbClient();
|
|
|
|
*dbClient << "UPDATE sticker_submissions SET status = 'denied', "
|
|
"reviewed_by = $1, reviewed_at = CURRENT_TIMESTAMP, denial_reason = $2 "
|
|
"WHERE id = $3 AND status = 'pending'"
|
|
<< reviewerId << reason << id
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Sticker submission denied";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "deny submission", "Failed to deny submission");
|
|
}
|
|
|
|
void AdminController::uploadHonkSound(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser fileUpload;
|
|
if (fileUpload.parse(req) != 0 || fileUpload.getFiles().empty()) {
|
|
callback(jsonError("No file uploaded"));
|
|
return;
|
|
}
|
|
|
|
auto files = fileUpload.getFiles();
|
|
if (files.size() != 1) {
|
|
callback(jsonError("Please upload exactly one file"));
|
|
return;
|
|
}
|
|
|
|
auto& file = files[0];
|
|
std::string paramName = file.getFileName();
|
|
std::string honkName = fileUpload.getParameter<std::string>("name");
|
|
|
|
if (honkName.empty()) {
|
|
// Use filename without extension as default name
|
|
honkName = paramName.substr(0, paramName.find_last_of('.'));
|
|
}
|
|
|
|
// Validate file extension
|
|
std::string ext = paramName.substr(paramName.find_last_of('.'));
|
|
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
|
if (ext != ".mp3" && ext != ".wav" && ext != ".ogg") {
|
|
callback(jsonError("Only MP3, WAV, and OGG files are allowed"));
|
|
return;
|
|
}
|
|
|
|
// Generate unique filename
|
|
std::string uniqueName = drogon::utils::getUuid() + ext;
|
|
std::string filePath = "/uploads/honks/" + uniqueName;
|
|
std::string fullPath = "./uploads/honks/" + uniqueName;
|
|
|
|
// Ensure directory exists
|
|
std::filesystem::create_directories("./uploads/honks");
|
|
|
|
// Save file
|
|
file.saveAs(fullPath);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "INSERT INTO honk_sounds (name, file_path) VALUES ($1, $2) RETURNING id, name, file_path, is_active, created_at"
|
|
<< honkName << filePath
|
|
>> [callback](const Result& r) {
|
|
if (!r.empty()) {
|
|
const auto& row = r[0];
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["honk"]["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
resp["honk"]["name"] = row["name"].as<std::string>();
|
|
resp["honk"]["filePath"] = row["file_path"].as<std::string>();
|
|
resp["honk"]["isActive"] = row["is_active"].as<bool>();
|
|
resp["honk"]["createdAt"] = row["created_at"].as<std::string>();
|
|
callback(jsonResp(resp));
|
|
} else {
|
|
callback(jsonError("Failed to save honk sound"));
|
|
}
|
|
}
|
|
>> DB_ERROR_MSG(callback, "insert honk sound", "Failed to upload honk sound");
|
|
}
|
|
|
|
void AdminController::getHonkSounds(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, name, file_path, is_active, created_at FROM honk_sounds ORDER BY created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value honks(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value honk;
|
|
honk["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
honk["name"] = row["name"].as<std::string>();
|
|
honk["filePath"] = row["file_path"].as<std::string>();
|
|
honk["isActive"] = row["is_active"].as<bool>();
|
|
honk["createdAt"] = row["created_at"].as<std::string>();
|
|
honks.append(honk);
|
|
}
|
|
|
|
resp["honks"] = honks;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get honk sounds", "Failed to get honk sounds");
|
|
}
|
|
|
|
void AdminController::deleteHonkSound(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &honkId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(honkId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
*dbClient << "DELETE FROM honk_sounds WHERE id = $1"
|
|
<< id
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Honk sound deleted";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete honk sound", "Failed to delete honk sound");
|
|
}
|
|
|
|
void AdminController::setActiveHonkSound(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &honkId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(honkId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// The trigger will automatically deactivate other honks
|
|
*dbClient << "UPDATE honk_sounds SET is_active = true WHERE id = $1"
|
|
<< id
|
|
>> [callback](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Honk sound activated";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "activate honk sound", "Failed to activate honk sound");
|
|
}
|
|
|
|
void AdminController::getActiveHonkSound(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// This endpoint is public - no auth required
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, name, file_path FROM honk_sounds WHERE is_active = true LIMIT 1"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
if (!r.empty()) {
|
|
const auto& row = r[0];
|
|
resp["honk"]["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
resp["honk"]["name"] = row["name"].as<std::string>();
|
|
resp["honk"]["filePath"] = row["file_path"].as<std::string>();
|
|
} else {
|
|
resp["honk"] = Json::nullValue;
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get active honk sound", "Failed to get active honk sound");
|
|
}
|
|
|
|
void AdminController::getChatSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
if (!user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
*dbClient << "SELECT guest_prefix, default_retention_hours, "
|
|
"guests_allowed_site_wide, registration_enabled, referral_system_enabled FROM chat_settings WHERE id = 1"
|
|
>> [callback](const Result& r) {
|
|
if (r.empty()) {
|
|
Json::Value settings;
|
|
settings["guestPrefix"] = "Guest";
|
|
settings["defaultRetentionHours"] = 24;
|
|
settings["guestsAllowedSiteWide"] = true;
|
|
settings["registrationEnabled"] = true;
|
|
settings["referralSystemEnabled"] = false;
|
|
callback(jsonResp(settings));
|
|
return;
|
|
}
|
|
|
|
auto row = r[0];
|
|
Json::Value settings;
|
|
settings["guestPrefix"] = row["guest_prefix"].as<std::string>();
|
|
settings["defaultRetentionHours"] = row["default_retention_hours"].as<int>();
|
|
settings["guestsAllowedSiteWide"] = row["guests_allowed_site_wide"].as<bool>();
|
|
settings["registrationEnabled"] = row["registration_enabled"].as<bool>();
|
|
settings["referralSystemEnabled"] = row["referral_system_enabled"].isNull() ? false : row["referral_system_enabled"].as<bool>();
|
|
callback(jsonResp(settings));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get chat settings", "Failed to get chat settings");
|
|
}
|
|
|
|
void AdminController::updateChatSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
if (!user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
if (!jsonPtr) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
const auto& json = *jsonPtr;
|
|
|
|
std::string guestPrefix = json.get("guestPrefix", "Guest").asString();
|
|
int defaultRetentionHours = json.get("defaultRetentionHours", 24).asInt();
|
|
bool guestsAllowedSiteWide = json.get("guestsAllowedSiteWide", true).asBool();
|
|
bool registrationEnabled = json.get("registrationEnabled", true).asBool();
|
|
bool referralSystemEnabled = json.get("referralSystemEnabled", false).asBool();
|
|
|
|
// Validate: referral system can only be enabled if registration is disabled
|
|
if (referralSystemEnabled && registrationEnabled) {
|
|
callback(jsonError("Referral system can only be enabled when registration is disabled"));
|
|
return;
|
|
}
|
|
|
|
// SECURITY FIX #33: Sync-first transactional pattern
|
|
// Sync to chat-service FIRST, then update PostgreSQL only on success
|
|
// This prevents configuration drift where database is updated but chat-service is not
|
|
auto client = HttpClient::newHttpClient("http://chat-service:8081");
|
|
|
|
// Build request body with settings
|
|
Json::Value chatServiceBody;
|
|
chatServiceBody["guestPrefix"] = guestPrefix;
|
|
chatServiceBody["defaultRetentionHours"] = defaultRetentionHours;
|
|
chatServiceBody["guestsAllowedSiteWide"] = guestsAllowedSiteWide;
|
|
chatServiceBody["registrationEnabled"] = registrationEnabled;
|
|
chatServiceBody["referralSystemEnabled"] = referralSystemEnabled;
|
|
|
|
auto chatReq = HttpRequest::newHttpJsonRequest(chatServiceBody);
|
|
chatReq->setMethod(Put);
|
|
chatReq->setPath("/api/chat/admin/settings");
|
|
|
|
// Get auth token from original request
|
|
std::string token = req->getCookie("auth_token");
|
|
if (token.empty()) {
|
|
std::string auth = req->getHeader("Authorization");
|
|
if (!auth.empty() && auth.substr(0, 7) == "Bearer ") {
|
|
token = auth.substr(7);
|
|
}
|
|
}
|
|
chatReq->addHeader("Authorization", "Bearer " + token);
|
|
|
|
// First sync to chat-service
|
|
client->sendRequest(chatReq, [callback, guestPrefix, defaultRetentionHours,
|
|
guestsAllowedSiteWide, registrationEnabled, referralSystemEnabled]
|
|
(ReqResult result, const HttpResponsePtr& chatResp) {
|
|
Json::Value resp;
|
|
|
|
// SECURITY FIX #28/#33: If chat-service sync fails, abort without updating PostgreSQL
|
|
if (result != ReqResult::Ok) {
|
|
LOG_ERROR << "Failed to connect to chat-service: " << (int)result;
|
|
resp["success"] = false;
|
|
resp["error"] = "Chat service is unavailable. Settings not updated.";
|
|
callback(jsonResp(resp));
|
|
return;
|
|
}
|
|
|
|
// Check HTTP response status
|
|
if (chatResp && chatResp->getStatusCode() != k200OK) {
|
|
LOG_ERROR << "Chat-service rejected settings sync: " << chatResp->getStatusCode();
|
|
resp["success"] = false;
|
|
resp["error"] = "Chat service rejected the update. Settings not updated.";
|
|
callback(jsonResp(resp));
|
|
return;
|
|
}
|
|
|
|
// Chat-service sync successful, now update PostgreSQL
|
|
LOG_INFO << "Settings synced to chat-service Redis, now updating PostgreSQL";
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE chat_settings SET guest_prefix = $1, "
|
|
"default_retention_hours = $2, guests_allowed_site_wide = $3, "
|
|
"registration_enabled = $4, referral_system_enabled = $5, "
|
|
"updated_at = CURRENT_TIMESTAMP WHERE id = 1"
|
|
<< guestPrefix << defaultRetentionHours
|
|
<< guestsAllowedSiteWide << registrationEnabled << referralSystemEnabled
|
|
>> [callback, guestPrefix, defaultRetentionHours,
|
|
guestsAllowedSiteWide, registrationEnabled, referralSystemEnabled](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Chat settings updated";
|
|
resp["settings"]["guestPrefix"] = guestPrefix;
|
|
resp["settings"]["defaultRetentionHours"] = defaultRetentionHours;
|
|
resp["settings"]["guestsAllowedSiteWide"] = guestsAllowedSiteWide;
|
|
resp["settings"]["registrationEnabled"] = registrationEnabled;
|
|
resp["settings"]["referralSystemEnabled"] = referralSystemEnabled;
|
|
callback(jsonResp(resp));
|
|
|
|
LOG_INFO << "Chat settings updated: guestPrefix=" << guestPrefix
|
|
<< ", guestsAllowed=" << guestsAllowedSiteWide
|
|
<< ", regEnabled=" << registrationEnabled
|
|
<< ", referralEnabled=" << referralSystemEnabled;
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update PostgreSQL after chat-service sync: " << e.base().what();
|
|
// SECURITY FIX #33: Warn about inconsistent state
|
|
Json::Value resp;
|
|
resp["success"] = false;
|
|
resp["error"] = "Chat service updated but database update failed. Please retry or contact support.";
|
|
callback(jsonResp(resp));
|
|
};
|
|
});
|
|
}
|
|
|
|
void AdminController::getRealms(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT r.id, r.name, r.description, r.stream_key, r.is_active, r.is_live, "
|
|
"r.viewer_count, r.viewer_multiplier, r.realm_type, r.created_at, r.original_creator_username, "
|
|
"u.username, u.id as user_id "
|
|
"FROM realms r "
|
|
"JOIN users u ON r.user_id = u.id "
|
|
"ORDER BY r.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value realms(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value realm;
|
|
realm["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
realm["name"] = row["name"].as<std::string>();
|
|
realm["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as<std::string>();
|
|
realm["isActive"] = row["is_active"].as<bool>();
|
|
realm["isLive"] = row["is_live"].as<bool>();
|
|
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
|
|
realm["viewerMultiplier"] = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
|
realm["realmType"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
|
realm["createdAt"] = row["created_at"].as<std::string>();
|
|
realm["username"] = row["username"].as<std::string>();
|
|
realm["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
realm["originalCreatorUsername"] = row["original_creator_username"].isNull() ? "" : row["original_creator_username"].as<std::string>();
|
|
realms.append(realm);
|
|
}
|
|
|
|
resp["realms"] = realms;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get realms", "Failed to get realms");
|
|
}
|
|
|
|
void AdminController::setViewerMultiplier(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &realmId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
if (!jsonPtr) {
|
|
callback(jsonError("Invalid JSON body"));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(realmId);
|
|
int multiplier = (*jsonPtr).get("multiplier", 1).asInt();
|
|
|
|
// Clamp multiplier to reasonable range (1-1000)
|
|
if (multiplier < 1) multiplier = 1;
|
|
if (multiplier > 1000) multiplier = 1000;
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "UPDATE realms SET viewer_multiplier = $1 WHERE id = $2 AND realm_type = 'stream'"
|
|
<< multiplier << id
|
|
>> [callback, multiplier](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["multiplier"] = multiplier;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "set viewer multiplier", "Failed to update viewer multiplier");
|
|
}
|
|
|
|
void AdminController::deleteRealm(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &realmId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(realmId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// First, collect all file paths from videos, audio_files, and ebooks for this realm
|
|
// Then delete files from disk before deleting from database
|
|
|
|
// Get video files
|
|
*dbClient << "SELECT file_path, thumbnail_path, preview_path FROM videos WHERE realm_id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id](const Result& videos) {
|
|
// Collect video file paths
|
|
std::vector<std::string> filesToDelete;
|
|
for (const auto& row : videos) {
|
|
if (!row["file_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["file_path"].as<std::string>());
|
|
}
|
|
if (!row["thumbnail_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["thumbnail_path"].as<std::string>());
|
|
}
|
|
if (!row["preview_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["preview_path"].as<std::string>());
|
|
}
|
|
}
|
|
|
|
// Get audio files
|
|
*dbClient << "SELECT file_path, thumbnail_path FROM audio_files WHERE realm_id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id, filesToDelete](const Result& audio) mutable {
|
|
// Collect audio file paths
|
|
for (const auto& row : audio) {
|
|
if (!row["file_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["file_path"].as<std::string>());
|
|
}
|
|
if (!row["thumbnail_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["thumbnail_path"].as<std::string>());
|
|
}
|
|
}
|
|
|
|
// Get ebook files
|
|
*dbClient << "SELECT file_path, cover_path FROM ebooks WHERE realm_id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id, filesToDelete](const Result& ebooks) mutable {
|
|
// Collect ebook file paths
|
|
for (const auto& row : ebooks) {
|
|
if (!row["file_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["file_path"].as<std::string>());
|
|
}
|
|
if (!row["cover_path"].isNull()) {
|
|
filesToDelete.push_back("/app" + row["cover_path"].as<std::string>());
|
|
}
|
|
}
|
|
|
|
// Delete all collected files from disk
|
|
int filesDeleted = 0;
|
|
for (const auto& filePath : filesToDelete) {
|
|
try {
|
|
if (std::filesystem::exists(filePath)) {
|
|
std::filesystem::remove(filePath);
|
|
filesDeleted++;
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_WARN << "Failed to delete file " << filePath << ": " << e.what();
|
|
}
|
|
}
|
|
|
|
LOG_INFO << "Deleted " << filesDeleted << " files for realm " << id;
|
|
|
|
// Now delete the realm (CASCADE will handle DB records)
|
|
*dbClient << "DELETE FROM realms WHERE id = $1"
|
|
<< id
|
|
>> [callback, filesDeleted](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Realm deleted successfully";
|
|
resp["filesDeleted"] = filesDeleted;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete realm", "Failed to delete realm");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get ebooks for realm", "Failed to get ebooks for realm");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get audio files for realm", "Failed to get audio files for realm");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get videos for realm", "Failed to get videos for realm");
|
|
}
|
|
|
|
void AdminController::deleteUser(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
// Prevent admin from deleting themselves
|
|
if (targetUserId == user.id) {
|
|
callback(jsonError("Cannot delete your own account"));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// First check if user exists and is not an admin
|
|
*dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, dbClient, targetUserId](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("User not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
if (r[0]["is_admin"].as<bool>()) {
|
|
callback(jsonError("Cannot delete admin users"));
|
|
return;
|
|
}
|
|
|
|
std::string deletedUsername = r[0]["username"].as<std::string>();
|
|
|
|
// Get an admin user ID to transfer content to
|
|
*dbClient << "SELECT id FROM users WHERE is_admin = true LIMIT 1"
|
|
>> [callback, dbClient, targetUserId, deletedUsername](const Result& adminResult) {
|
|
if (adminResult.empty()) {
|
|
callback(jsonError("No admin user found to transfer content"));
|
|
return;
|
|
}
|
|
|
|
int64_t adminId = adminResult[0]["id"].as<int64_t>();
|
|
|
|
// Transfer realms to admin, preserving original creator username
|
|
*dbClient << "UPDATE realms SET user_id = $1, original_creator_username = $2 WHERE user_id = $3"
|
|
<< adminId << deletedUsername << targetUserId
|
|
>> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) {
|
|
// Transfer videos to admin
|
|
*dbClient << "UPDATE videos SET user_id = $1 WHERE user_id = $2"
|
|
<< adminId << targetUserId
|
|
>> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) {
|
|
// Transfer audio_files to admin
|
|
*dbClient << "UPDATE audio_files SET user_id = $1 WHERE user_id = $2"
|
|
<< adminId << targetUserId
|
|
>> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) {
|
|
// Transfer ebooks to admin
|
|
*dbClient << "UPDATE ebooks SET user_id = $1 WHERE user_id = $2"
|
|
<< adminId << targetUserId
|
|
>> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) {
|
|
// Transfer forums to admin
|
|
*dbClient << "UPDATE forums SET user_id = $1 WHERE user_id = $2"
|
|
<< adminId << targetUserId
|
|
>> [callback, dbClient, targetUserId, deletedUsername](const Result&) {
|
|
// Now safe to delete user (all content transferred)
|
|
*dbClient << "DELETE FROM users WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, deletedUsername](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User '" + deletedUsername + "' deleted. All content transferred to admin.";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete user", "Failed to delete user");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "transfer forums", "Failed to transfer forums");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "transfer ebooks", "Failed to transfer ebooks");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "transfer audio files", "Failed to transfer audio files");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "transfer videos", "Failed to transfer videos");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "transfer realms", "Failed to transfer realms");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get admin user", "Failed to get admin user");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get user", "Failed to get user");
|
|
}
|
|
|
|
void AdminController::disableUser(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
// Prevent admin from disabling themselves
|
|
if (targetUserId == user.id) {
|
|
callback(jsonError("Cannot disable your own account"));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Check if user exists and is not an admin
|
|
*dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, dbClient, targetUserId](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("User not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
bool isAdmin = r[0]["is_admin"].as<bool>();
|
|
std::string username = r[0]["username"].as<std::string>();
|
|
|
|
if (isAdmin) {
|
|
callback(jsonError("Cannot disable admin users"));
|
|
return;
|
|
}
|
|
|
|
// Disable the user
|
|
*dbClient << "UPDATE users SET is_disabled = true WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, username](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User '" + username + "' has been disabled";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "disable user", "Failed to disable user");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get user", "Failed to get user");
|
|
}
|
|
|
|
void AdminController::enableUser(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Check if user exists
|
|
*dbClient << "SELECT id, username FROM users WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, dbClient, targetUserId](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("User not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string username = r[0]["username"].as<std::string>();
|
|
|
|
// Enable the user
|
|
*dbClient << "UPDATE users SET is_disabled = false WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, username](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "User '" + username + "' has been enabled";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "enable user", "Failed to enable user");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get user", "Failed to get user");
|
|
}
|
|
|
|
void AdminController::uberbanUser(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t targetUserId = std::stoll(userId);
|
|
|
|
// Prevent admin from uberbanning themselves
|
|
if (targetUserId == user.id) {
|
|
callback(jsonError("Cannot uberban yourself", k400BadRequest));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Check if user exists and is not an admin
|
|
*dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, dbClient, targetUserId](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("User not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
bool isAdmin = r[0]["is_admin"].as<bool>();
|
|
if (isAdmin) {
|
|
callback(jsonError("Cannot uberban an admin user", k400BadRequest));
|
|
return;
|
|
}
|
|
|
|
std::string username = r[0]["username"].as<std::string>();
|
|
|
|
// Try to get fingerprint from chat-service if user is connected
|
|
auto httpClient = HttpClient::newHttpClient("http://chat-service:8081");
|
|
auto chatReq = HttpRequest::newHttpRequest();
|
|
chatReq->setPath("/api/internal/user/" + std::to_string(targetUserId) + "/uberban");
|
|
chatReq->setMethod(drogon::Post);
|
|
|
|
httpClient->sendRequest(chatReq, [callback, dbClient, targetUserId, username](ReqResult result, const HttpResponsePtr& resp) {
|
|
// Option C: All uberbans are deferred - fingerprint captured on reconnect
|
|
// Check if user was connected and disconnected by chat-service
|
|
bool userWasDisconnected = false;
|
|
if (result == ReqResult::Ok && resp && resp->getStatusCode() == k200OK) {
|
|
auto json = resp->getJsonObject();
|
|
if (json && (*json)["disconnected"].asBool()) {
|
|
userWasDisconnected = true;
|
|
}
|
|
}
|
|
|
|
// Set pending uberban in database and Redis
|
|
// If user was disconnected, chat-service already set Redis key (no TTL)
|
|
// We set it here with TTL as backup / consistency
|
|
*dbClient << "UPDATE users SET pending_uberban = true, pending_uberban_at = NOW() WHERE id = $1"
|
|
<< targetUserId
|
|
>> [callback, username, targetUserId, userWasDisconnected](const Result&) {
|
|
// Set Redis key for fast lookup by chat-service
|
|
// TTL: 30 days (in case user never comes back)
|
|
services::RedisHelper::instance().setexAsync(
|
|
"pending_uberban:" + std::to_string(targetUserId),
|
|
"1",
|
|
30 * 24 * 60 * 60, // 30 days
|
|
[](bool) {} // Fire and forget
|
|
);
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
if (userWasDisconnected) {
|
|
resp["message"] = "User '" + username + "' has been kicked. Fingerprint will be captured on reconnect.";
|
|
} else {
|
|
resp["message"] = "User '" + username + "' marked for uberban (will apply on next activity)";
|
|
}
|
|
resp["immediate"] = false;
|
|
resp["disconnected"] = userWasDisconnected;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "set pending uberban", "Failed to set pending uberban");
|
|
});
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get user", "Failed to get user");
|
|
}
|
|
|
|
void AdminController::incrementReferrals(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &userId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t targetId;
|
|
try {
|
|
targetId = std::stoll(userId);
|
|
} catch (...) {
|
|
callback(jsonError("Invalid user ID", k400BadRequest));
|
|
return;
|
|
}
|
|
|
|
// Generate a random 12-character code
|
|
static const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
std::string code;
|
|
code.reserve(12);
|
|
std::random_device rd;
|
|
std::mt19937 gen(rd());
|
|
std::uniform_int_distribution<> dist(0, sizeof(charset) - 2);
|
|
for (int i = 0; i < 12; ++i) {
|
|
code += charset[dist(gen)];
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
// Insert a new referral code for the user (this increments their count)
|
|
*dbClient << "INSERT INTO referral_codes (owner_id, code) VALUES ($1, $2)"
|
|
<< targetId << code
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Referral code added";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "increment referrals", "Failed to add referral code");
|
|
}
|
|
|
|
void AdminController::getVideos(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, "
|
|
"v.duration_seconds, v.file_size_bytes, v.view_count, v.is_public, v.status, v.created_at, "
|
|
"u.id as user_id, u.username "
|
|
"FROM videos v "
|
|
"LEFT JOIN users u ON v.user_id = u.id "
|
|
"WHERE v.status != 'deleted' "
|
|
"ORDER BY v.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value videos(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value video;
|
|
video["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
video["title"] = row["title"].as<std::string>();
|
|
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
video["filePath"] = row["file_path"].as<std::string>();
|
|
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
video["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
video["viewCount"] = row["view_count"].as<int>();
|
|
video["isPublic"] = row["is_public"].as<bool>();
|
|
video["status"] = row["status"].as<std::string>();
|
|
video["createdAt"] = row["created_at"].as<std::string>();
|
|
video["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
|
|
video["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
|
|
videos.append(video);
|
|
}
|
|
|
|
resp["videos"] = videos;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get videos", "Failed to get videos");
|
|
}
|
|
|
|
void AdminController::deleteVideo(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &videoId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(videoId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Get file paths first
|
|
*dbClient << "SELECT file_path, thumbnail_path FROM videos WHERE id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Video not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
|
|
|
|
// Soft delete by setting status to 'deleted'
|
|
*dbClient << "UPDATE videos SET status = 'deleted' WHERE id = $1"
|
|
<< id
|
|
>> [callback, filePath, thumbnailPath](const Result&) {
|
|
// Delete files from disk
|
|
try {
|
|
std::string fullVideoPath = "/app" + filePath;
|
|
if (std::filesystem::exists(fullVideoPath)) {
|
|
std::filesystem::remove(fullVideoPath);
|
|
}
|
|
if (!thumbnailPath.empty()) {
|
|
std::string fullThumbPath = "/app" + thumbnailPath;
|
|
if (std::filesystem::exists(fullThumbPath)) {
|
|
std::filesystem::remove(fullThumbPath);
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_WARN << "Failed to delete video files: " << e.what();
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Video deleted successfully";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete video", "Failed to delete video");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get video", "Failed to get video");
|
|
}
|
|
|
|
void AdminController::getAudios(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
|
"a.duration_seconds, a.file_size_bytes, a.play_count, a.is_public, a.status, a.created_at, "
|
|
"u.id as user_id, u.username, r.name as realm_name "
|
|
"FROM audio_files a "
|
|
"LEFT JOIN users u ON a.user_id = u.id "
|
|
"LEFT JOIN realms r ON a.realm_id = r.id "
|
|
"WHERE a.status != 'deleted' "
|
|
"ORDER BY a.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value audios(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value audio;
|
|
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
audio["title"] = row["title"].as<std::string>();
|
|
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
audio["filePath"] = row["file_path"].as<std::string>();
|
|
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
audio["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
audio["playCount"] = row["play_count"].as<int>();
|
|
audio["isPublic"] = row["is_public"].as<bool>();
|
|
audio["status"] = row["status"].as<std::string>();
|
|
audio["createdAt"] = row["created_at"].as<std::string>();
|
|
audio["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
|
|
audio["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
|
|
audio["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as<std::string>();
|
|
audios.append(audio);
|
|
}
|
|
|
|
resp["audios"] = audios;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get audios", "Failed to get audios");
|
|
}
|
|
|
|
void AdminController::deleteAudio(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &audioId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(audioId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Get file paths first
|
|
*dbClient << "SELECT file_path, thumbnail_path FROM audio_files WHERE id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Audio not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
|
|
|
|
// Soft delete by setting status to 'deleted'
|
|
*dbClient << "UPDATE audio_files SET status = 'deleted' WHERE id = $1"
|
|
<< id
|
|
>> [callback, filePath, thumbnailPath](const Result&) {
|
|
// Delete files from disk
|
|
try {
|
|
std::string fullAudioPath = "/app" + filePath;
|
|
if (std::filesystem::exists(fullAudioPath)) {
|
|
std::filesystem::remove(fullAudioPath);
|
|
}
|
|
if (!thumbnailPath.empty()) {
|
|
std::string fullThumbPath = "/app" + thumbnailPath;
|
|
if (std::filesystem::exists(fullThumbPath)) {
|
|
std::filesystem::remove(fullThumbPath);
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_WARN << "Failed to delete audio files: " << e.what();
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Audio deleted successfully";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete audio", "Failed to delete audio");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get audio", "Failed to get audio");
|
|
}
|
|
|
|
void AdminController::getEbooks(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
|
"e.file_size_bytes, e.chapter_count, e.read_count, e.is_public, e.status, e.created_at, "
|
|
"u.id as user_id, u.username, r.name as realm_name "
|
|
"FROM ebooks e "
|
|
"LEFT JOIN users u ON e.user_id = u.id "
|
|
"LEFT JOIN realms r ON e.realm_id = r.id "
|
|
"WHERE e.status != 'deleted' "
|
|
"ORDER BY e.created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
Json::Value ebooks(Json::arrayValue);
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value ebook;
|
|
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
ebook["title"] = row["title"].as<std::string>();
|
|
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
ebook["readCount"] = row["read_count"].as<int>();
|
|
ebook["isPublic"] = row["is_public"].as<bool>();
|
|
ebook["status"] = row["status"].as<std::string>();
|
|
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
ebook["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
|
|
ebook["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
|
|
ebook["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as<std::string>();
|
|
ebooks.append(ebook);
|
|
}
|
|
|
|
resp["ebooks"] = ebooks;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get ebooks", "Failed to get ebooks");
|
|
}
|
|
|
|
void AdminController::deleteEbook(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &ebookId) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
int64_t id = std::stoll(ebookId);
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Get file paths first
|
|
*dbClient << "SELECT file_path, cover_path FROM ebooks WHERE id = $1"
|
|
<< id
|
|
>> [callback, dbClient, id](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Ebook not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
std::string coverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as<std::string>();
|
|
|
|
// Soft delete by setting status to 'deleted'
|
|
*dbClient << "UPDATE ebooks SET status = 'deleted' WHERE id = $1"
|
|
<< id
|
|
>> [callback, filePath, coverPath](const Result&) {
|
|
// Delete files from disk
|
|
try {
|
|
std::string fullEbookPath = "/app" + filePath;
|
|
if (std::filesystem::exists(fullEbookPath)) {
|
|
std::filesystem::remove(fullEbookPath);
|
|
}
|
|
if (!coverPath.empty()) {
|
|
std::string fullCoverPath = "/app" + coverPath;
|
|
if (std::filesystem::exists(fullCoverPath)) {
|
|
std::filesystem::remove(fullCoverPath);
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_WARN << "Failed to delete ebook files: " << e.what();
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Ebook deleted successfully";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete ebook", "Failed to delete ebook");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get ebook", "Failed to get ebook");
|
|
}
|
|
|
|
void AdminController::getSiteSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (!user.isAdmin) {
|
|
callback(jsonError("Admin access required", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT setting_key, setting_value FROM site_settings"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
for (const auto& row : r) {
|
|
std::string key = row["setting_key"].as<std::string>();
|
|
std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as<std::string>();
|
|
resp["settings"][key] = value;
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get site settings", "Failed to get site settings");
|
|
}
|
|
|
|
void AdminController::updateSiteSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (!user.isAdmin) {
|
|
callback(jsonError("Admin access required", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto json = req->getJsonObject();
|
|
if (!json) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Update site_title if provided (use UPSERT in case row doesn't exist)
|
|
if (json->isMember("site_title")) {
|
|
std::string title = (*json)["site_title"].asString();
|
|
if (title.length() > 100) {
|
|
title = title.substr(0, 100);
|
|
}
|
|
// SECURITY FIX #27: Sanitize site title to prevent XSS attacks
|
|
title = htmlEscape(title);
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('site_title', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< title
|
|
>> [](const Result&) {
|
|
LOG_INFO << "Site title updated successfully";
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update site_title: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
// Update logo_display_mode if provided (use UPSERT in case row doesn't exist)
|
|
if (json->isMember("logo_display_mode")) {
|
|
std::string mode = (*json)["logo_display_mode"].asString();
|
|
if (mode != "text" && mode != "image" && mode != "both") {
|
|
callback(jsonError("Invalid logo_display_mode. Must be 'text', 'image', or 'both'"));
|
|
return;
|
|
}
|
|
// SECURITY FIX #29: Add proper error handling for async DB operations
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('logo_display_mode', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< mode
|
|
>> [](const Result&) {
|
|
LOG_INFO << "Logo display mode updated successfully";
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update logo_display_mode: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
// Update announcement_enabled if provided
|
|
if (json->isMember("announcement_enabled")) {
|
|
bool enabled = (*json)["announcement_enabled"].asBool();
|
|
std::string value = enabled ? "true" : "false";
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_enabled', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< value
|
|
>> [](const Result&) {
|
|
LOG_INFO << "Announcement enabled setting updated successfully";
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update announcement_enabled: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
// Update announcement_text if provided
|
|
if (json->isMember("announcement_text")) {
|
|
std::string text = (*json)["announcement_text"].asString();
|
|
// Limit to 500 characters
|
|
if (text.length() > 500) {
|
|
text = text.substr(0, 500);
|
|
}
|
|
// Sanitize to prevent XSS
|
|
text = htmlEscape(text);
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_text', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< text
|
|
>> [](const Result&) {
|
|
LOG_INFO << "Announcement text updated successfully";
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update announcement_text: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
// Update censored_words if provided (comma-separated list)
|
|
if (json->isMember("censored_words")) {
|
|
// Rate limit: 10 updates per minute per admin
|
|
if (!censoredWordsRateLimiter.isAllowed(user.id)) {
|
|
callback(jsonError("Rate limit exceeded. Maximum 10 censored words updates per minute.", k429TooManyRequests));
|
|
return;
|
|
}
|
|
|
|
std::string words = (*json)["censored_words"].asString();
|
|
// Limit to 10KB to prevent abuse
|
|
if (words.length() > 10240) {
|
|
callback(jsonError("Censored words list exceeds maximum size of 10KB"));
|
|
return;
|
|
}
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('censored_words', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< words
|
|
>> [](const Result&) {
|
|
// Reload censored words in the CensorService
|
|
CensorService::getInstance().loadCensoredWords();
|
|
// Notify chat service to refresh its cache
|
|
notifyChatServiceCensoredWordsUpdate();
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update censored_words: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Site settings updated";
|
|
callback(jsonResp(resp));
|
|
}
|
|
|
|
void AdminController::uploadSiteLogo(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (!user.isAdmin) {
|
|
callback(jsonError("Admin access required", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser parser;
|
|
parser.parse(req);
|
|
|
|
if (parser.getFiles().empty()) {
|
|
callback(jsonError("No file uploaded"));
|
|
return;
|
|
}
|
|
|
|
const auto& file = parser.getFiles()[0];
|
|
|
|
// Validate file size (max 2MB for logo)
|
|
const size_t maxSize = 2 * 1024 * 1024;
|
|
if (file.fileLength() > maxSize) {
|
|
callback(jsonError("File too large (max 2MB)"));
|
|
return;
|
|
}
|
|
|
|
// Validate image magic bytes
|
|
auto validation = validateImageMagicBytes(file.fileData(), file.fileLength(), true);
|
|
if (!validation.valid) {
|
|
callback(jsonError("Invalid image file. Only JPEG, PNG, GIF, WebP, and SVG are allowed."));
|
|
return;
|
|
}
|
|
|
|
// Create logo directory if it doesn't exist
|
|
const std::string logoDir = "/app/uploads/logo";
|
|
try {
|
|
std::filesystem::create_directories(logoDir);
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to create logo directory: " << e.what();
|
|
callback(jsonError("Failed to save logo"));
|
|
return;
|
|
}
|
|
|
|
// Delete old logo if exists
|
|
try {
|
|
for (const auto& entry : std::filesystem::directory_iterator(logoDir)) {
|
|
std::filesystem::remove(entry.path());
|
|
}
|
|
} catch (...) {
|
|
// Ignore errors when deleting old logo
|
|
}
|
|
|
|
// Save new logo with timestamp to bust cache
|
|
std::string filename = "site_logo_" + std::to_string(std::time(nullptr)) + validation.extension;
|
|
std::string fullPath = logoDir + "/" + filename;
|
|
std::string webPath = "/uploads/logo/" + filename;
|
|
|
|
try {
|
|
std::ofstream ofs(fullPath, std::ios::binary);
|
|
if (!ofs) {
|
|
callback(jsonError("Failed to save logo file"));
|
|
return;
|
|
}
|
|
ofs.write(file.fileData(), file.fileLength());
|
|
ofs.close();
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to write logo file: " << e.what();
|
|
callback(jsonError("Failed to save logo"));
|
|
return;
|
|
}
|
|
|
|
// Update database with logo path (use UPSERT in case row doesn't exist)
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('logo_path', $1) "
|
|
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
|
|
<< webPath
|
|
>> [callback, webPath](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["logoPath"] = webPath;
|
|
resp["message"] = "Logo uploaded successfully";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "update logo path", "Failed to save logo");
|
|
}
|
|
|
|
void AdminController::getPublicSiteSettings(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// Public endpoint - no auth required
|
|
// SECURITY FIX #30: Only expose safe public settings, filter sensitive ones (e.g., hide censored_words)
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT setting_key, setting_value FROM site_settings"
|
|
>> [callback](const Result& r) {
|
|
// Whitelist of publicly-safe settings
|
|
static const std::unordered_set<std::string> publicKeys = {
|
|
"site_title", "logo_path", "logo_display_mode",
|
|
"announcement_enabled", "announcement_text"
|
|
};
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
for (const auto& row : r) {
|
|
std::string key = row["setting_key"].as<std::string>();
|
|
// Only include whitelisted public keys
|
|
if (publicKeys.find(key) != publicKeys.end()) {
|
|
std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as<std::string>();
|
|
resp["settings"][key] = value;
|
|
}
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get site settings: " << e.base().what();
|
|
// Return defaults on error
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["settings"]["site_title"] = "Stream";
|
|
resp["settings"]["logo_path"] = "";
|
|
resp["settings"]["logo_display_mode"] = "text";
|
|
callback(jsonResp(resp));
|
|
};
|
|
}
|
|
|
|
void AdminController::getCensoredWords(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// Internal endpoint for chat service - no auth required (Docker internal network)
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT setting_value FROM site_settings WHERE setting_key = 'censored_words'"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
if (!r.empty() && !r[0]["setting_value"].isNull()) {
|
|
resp["censored_words"] = r[0]["setting_value"].as<std::string>();
|
|
} else {
|
|
resp["censored_words"] = "";
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get censored words: " << e.base().what();
|
|
callback(jsonError("Failed to retrieve censored words", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
void AdminController::uploadDefaultAvatars(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
auto user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
MultiPartParser parser;
|
|
if (parser.parse(req) != 0) {
|
|
callback(jsonError("Invalid multipart form data"));
|
|
return;
|
|
}
|
|
|
|
auto files = parser.getFiles();
|
|
if (files.empty()) {
|
|
callback(jsonError("No files uploaded"));
|
|
return;
|
|
}
|
|
|
|
// Create avatars directory if it doesn't exist
|
|
std::string avatarsDir = "/app/uploads/avatars";
|
|
std::filesystem::create_directories(avatarsDir);
|
|
|
|
Json::Value uploaded(Json::arrayValue);
|
|
int successCount = 0;
|
|
auto dbClient = app().getDbClient();
|
|
|
|
for (auto& file : files) {
|
|
const char* fileData = file.fileData();
|
|
size_t fileLen = file.fileLength();
|
|
|
|
// Check file size limit (500KB max for default avatars)
|
|
if (fileLen > 500 * 1024) {
|
|
LOG_WARN << "Rejected oversized file: " << file.getFileName() << " (" << fileLen << " bytes)";
|
|
continue;
|
|
}
|
|
|
|
// Validate magic bytes (SVG disabled for security - prevents XSS)
|
|
auto validation = validateImageMagicBytes(fileData, fileLen, false);
|
|
if (!validation.valid) {
|
|
LOG_WARN << "Rejected file with invalid magic bytes: " << file.getFileName();
|
|
continue;
|
|
}
|
|
|
|
// Generate unique filename
|
|
std::string timestamp = std::to_string(std::time(nullptr));
|
|
std::string randomStr = std::to_string(rand() % 100000);
|
|
std::string filename = "avatar_" + timestamp + "_" + randomStr + validation.extension;
|
|
std::string fullPath = avatarsDir + "/" + filename;
|
|
|
|
// Write file
|
|
std::ofstream outFile(fullPath, std::ios::binary);
|
|
if (!outFile) {
|
|
LOG_ERROR << "Failed to create file: " << fullPath;
|
|
continue;
|
|
}
|
|
outFile.write(fileData, fileLen);
|
|
outFile.close();
|
|
|
|
std::string dbPath = "/uploads/avatars/" + filename;
|
|
|
|
// Insert into database synchronously (blocking for simplicity in loop)
|
|
try {
|
|
auto result = dbClient->execSqlSync(
|
|
"INSERT INTO default_avatars (file_path, is_active) VALUES ($1, true) RETURNING id",
|
|
dbPath
|
|
);
|
|
if (!result.empty()) {
|
|
Json::Value avatarInfo;
|
|
avatarInfo["id"] = result[0]["id"].as<int64_t>();
|
|
avatarInfo["filePath"] = dbPath;
|
|
uploaded.append(avatarInfo);
|
|
successCount++;
|
|
}
|
|
} catch (const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to insert default avatar: " << e.base().what();
|
|
// Clean up file on db error
|
|
std::filesystem::remove(fullPath);
|
|
}
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = successCount > 0;
|
|
resp["uploaded"] = uploaded;
|
|
resp["count"] = successCount;
|
|
callback(jsonResp(resp));
|
|
}
|
|
|
|
void AdminController::getDefaultAvatars(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
auto user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT id, file_path, is_active, created_at FROM default_avatars ORDER BY created_at DESC"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["avatars"] = Json::arrayValue;
|
|
|
|
for (const auto& row : r) {
|
|
Json::Value avatar;
|
|
avatar["id"] = row["id"].as<int64_t>();
|
|
avatar["filePath"] = row["file_path"].as<std::string>();
|
|
avatar["isActive"] = row["is_active"].as<bool>();
|
|
avatar["createdAt"] = row["created_at"].as<std::string>();
|
|
resp["avatars"].append(avatar);
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "get default avatars", "Failed to load default avatars");
|
|
}
|
|
|
|
void AdminController::deleteDefaultAvatar(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
const std::string &avatarId) {
|
|
auto user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
return;
|
|
}
|
|
|
|
int64_t id;
|
|
try {
|
|
id = std::stoll(avatarId);
|
|
} catch (...) {
|
|
callback(jsonError("Invalid avatar ID"));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
// First get the file path
|
|
*dbClient << "SELECT file_path FROM default_avatars WHERE id = $1"
|
|
<< id
|
|
>> [callback, id, dbClient](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("Avatar not found", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
|
|
|
// Delete from database
|
|
*dbClient << "DELETE FROM default_avatars WHERE id = $1"
|
|
<< id
|
|
>> [callback, filePath](const Result&) {
|
|
// Delete file from filesystem
|
|
std::string fullPath = "/app" + filePath;
|
|
std::filesystem::remove(fullPath);
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Avatar deleted";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> DB_ERROR_MSG(callback, "delete default avatar", "Failed to delete avatar");
|
|
}
|
|
>> DB_ERROR_MSG(callback, "find default avatar", "Failed to find avatar");
|
|
}
|
|
|
|
void AdminController::getRandomDefaultAvatar(const HttpRequestPtr &,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// Public endpoint - returns a random active default avatar for guests
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT file_path FROM default_avatars WHERE is_active = true ORDER BY RANDOM() LIMIT 1"
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
if (!r.empty()) {
|
|
resp["avatarUrl"] = r[0]["file_path"].as<std::string>();
|
|
} else {
|
|
resp["avatarUrl"] = Json::nullValue;
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get random default avatar: " << e.base().what();
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["avatarUrl"] = Json::nullValue;
|
|
callback(jsonResp(resp));
|
|
};
|
|
}
|
|
|
|
void AdminController::trackStickerUsage(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// Internal endpoint for chat-service - no auth required (Docker internal network)
|
|
auto jsonPtr = req->getJsonObject();
|
|
if (!jsonPtr || !(*jsonPtr).isMember("stickers")) {
|
|
callback(jsonError("Invalid request: missing stickers array"));
|
|
return;
|
|
}
|
|
|
|
const auto& stickers = (*jsonPtr)["stickers"];
|
|
if (!stickers.isArray() || stickers.empty()) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["updated"] = 0;
|
|
callback(jsonResp(resp));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
int updateCount = 0;
|
|
static constexpr int MAX_STICKERS_PER_REQUEST = 50;
|
|
|
|
// Increment usage_count for each sticker name (limited to prevent abuse)
|
|
for (const auto& stickerName : stickers) {
|
|
if (updateCount >= MAX_STICKERS_PER_REQUEST) break;
|
|
if (!stickerName.isString()) continue;
|
|
std::string name = stickerName.asString();
|
|
updateCount++;
|
|
|
|
*dbClient << "UPDATE stickers SET usage_count = COALESCE(usage_count, 0) + 1 "
|
|
"WHERE LOWER(name) = LOWER($1) AND is_active = true"
|
|
<< name
|
|
>> [name](const Result&) {
|
|
LOG_DEBUG << "Incremented usage count for sticker: " << name;
|
|
}
|
|
>> [name](const DrogonDbException& e) {
|
|
LOG_WARN << "Failed to increment sticker usage for " << name
|
|
<< ": " << e.base().what();
|
|
};
|
|
}
|
|
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["updated"] = updateCount;
|
|
callback(jsonResp(resp));
|
|
}
|
|
|
|
void AdminController::getStickerStats(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
// Public endpoint - returns top stickers by usage count
|
|
std::string limitParam = req->getParameter("limit");
|
|
int limit = 50;
|
|
if (!limitParam.empty()) {
|
|
try {
|
|
limit = std::min(100, std::max(1, std::stoi(limitParam)));
|
|
} catch (...) {
|
|
limit = 50;
|
|
}
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
// Build query with limit embedded (already validated to be 1-100)
|
|
std::string query = "SELECT id, name, file_path, COALESCE(usage_count, 0) as usage_count "
|
|
"FROM stickers WHERE is_active = true AND COALESCE(usage_count, 0) > 0 "
|
|
"ORDER BY usage_count DESC LIMIT " + std::to_string(limit);
|
|
*dbClient << query
|
|
>> [callback](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["stickers"] = Json::arrayValue;
|
|
|
|
int64_t totalUsage = 0;
|
|
for (const auto& row : r) {
|
|
Json::Value sticker;
|
|
sticker["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
sticker["name"] = row["name"].as<std::string>();
|
|
sticker["filePath"] = row["file_path"].as<std::string>();
|
|
int64_t count = row["usage_count"].as<int64_t>();
|
|
sticker["usageCount"] = static_cast<Json::Int64>(count);
|
|
totalUsage += count;
|
|
resp["stickers"].append(sticker);
|
|
}
|
|
|
|
resp["totalUsage"] = static_cast<Json::Int64>(totalUsage);
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get sticker stats: " << e.base().what();
|
|
callback(jsonError("Failed to retrieve sticker statistics", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
void AdminController::downloadAllStickers(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Get all active stickers with their names and file paths
|
|
*dbClient << "SELECT id, name, file_path FROM stickers WHERE is_active = true ORDER BY name ASC"
|
|
>> [callback](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("No stickers to download", k404NotFound));
|
|
return;
|
|
}
|
|
|
|
// Generate unique temp filename for ZIP
|
|
std::string tempZipPath = "/tmp/stickers_" + drogon::utils::getUuid() + ".zip";
|
|
|
|
int zipError = 0;
|
|
zip_t* archive = zip_open(tempZipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &zipError);
|
|
|
|
if (!archive) {
|
|
LOG_ERROR << "Failed to create ZIP archive: error code " << zipError;
|
|
callback(jsonError("Failed to create archive"));
|
|
return;
|
|
}
|
|
|
|
int addedCount = 0;
|
|
std::unordered_set<std::string> usedNames; // Track names to handle duplicates
|
|
|
|
for (const auto& row : r) {
|
|
std::string stickerName = row["name"].as<std::string>();
|
|
std::string filePath = row["file_path"].as<std::string>();
|
|
|
|
// Convert web path to filesystem path
|
|
// file_path is like "/uploads/stickers/uuid.ext"
|
|
std::string fullPath = "." + filePath; // becomes "./uploads/stickers/uuid.ext"
|
|
|
|
// Check if file exists
|
|
if (!std::filesystem::exists(fullPath)) {
|
|
LOG_WARN << "Sticker file not found: " << fullPath;
|
|
continue;
|
|
}
|
|
|
|
// Get file extension from original file
|
|
std::string ext = "";
|
|
size_t dotPos = filePath.rfind('.');
|
|
if (dotPos != std::string::npos) {
|
|
ext = filePath.substr(dotPos); // includes the dot
|
|
}
|
|
|
|
// Create sanitized filename: name + extension
|
|
// Sanitize name to be filesystem-safe
|
|
std::string safeName = stickerName;
|
|
for (char& c : safeName) {
|
|
if (c == '/' || c == '\\' || c == ':' || c == '*' ||
|
|
c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
|
|
c = '_';
|
|
}
|
|
}
|
|
|
|
std::string zipFilename = safeName + ext;
|
|
|
|
// Handle duplicate names by appending a number
|
|
if (usedNames.count(zipFilename)) {
|
|
int counter = 2;
|
|
while (usedNames.count(safeName + "_" + std::to_string(counter) + ext)) {
|
|
counter++;
|
|
}
|
|
zipFilename = safeName + "_" + std::to_string(counter) + ext;
|
|
}
|
|
usedNames.insert(zipFilename);
|
|
|
|
// Add file to archive
|
|
zip_source_t* source = zip_source_file(archive, fullPath.c_str(), 0, -1);
|
|
if (!source) {
|
|
LOG_WARN << "Failed to create source for: " << fullPath;
|
|
continue;
|
|
}
|
|
|
|
if (zip_file_add(archive, zipFilename.c_str(), source, ZIP_FL_OVERWRITE) < 0) {
|
|
LOG_WARN << "Failed to add file to ZIP: " << zipFilename;
|
|
zip_source_free(source);
|
|
continue;
|
|
}
|
|
|
|
addedCount++;
|
|
}
|
|
|
|
// Close and finalize the archive
|
|
if (zip_close(archive) < 0) {
|
|
LOG_ERROR << "Failed to close ZIP archive: " << zip_strerror(archive);
|
|
std::filesystem::remove(tempZipPath);
|
|
callback(jsonError("Failed to finalize archive"));
|
|
return;
|
|
}
|
|
|
|
if (addedCount == 0) {
|
|
std::filesystem::remove(tempZipPath);
|
|
callback(jsonError("No sticker files could be added to archive"));
|
|
return;
|
|
}
|
|
|
|
// Generate timestamp for filename
|
|
auto now = std::time(nullptr);
|
|
char timestamp[20];
|
|
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", std::localtime(&now));
|
|
|
|
// Read the file into memory
|
|
std::ifstream file(tempZipPath, std::ios::binary);
|
|
if (!file) {
|
|
std::filesystem::remove(tempZipPath);
|
|
callback(jsonError("Failed to read archive"));
|
|
return;
|
|
}
|
|
|
|
std::vector<char> buffer((std::istreambuf_iterator<char>(file)),
|
|
std::istreambuf_iterator<char>());
|
|
file.close();
|
|
|
|
// Delete temp file now that we have it in memory
|
|
std::filesystem::remove(tempZipPath);
|
|
|
|
// Create response with binary data
|
|
auto resp = HttpResponse::newHttpResponse();
|
|
resp->setStatusCode(k200OK);
|
|
resp->setContentTypeCode(CT_CUSTOM);
|
|
resp->addHeader("Content-Type", "application/zip");
|
|
resp->addHeader("Content-Disposition",
|
|
"attachment; filename=\"stickers_" + std::string(timestamp) + ".zip\"");
|
|
resp->setBody(std::string(buffer.begin(), buffer.end()));
|
|
|
|
LOG_INFO << "Stickers ZIP download: " << addedCount << " stickers";
|
|
callback(resp);
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get stickers for download: " << e.base().what();
|
|
callback(jsonError("Failed to get stickers", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
// SSL Certificate Management
|
|
|
|
void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
*dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, "
|
|
"last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at "
|
|
"FROM ssl_settings WHERE id = 1"
|
|
>> [callback, dbClient](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
|
|
std::string dbStatus = "none";
|
|
std::string domain = "";
|
|
|
|
if (r.empty()) {
|
|
// Return defaults if no settings exist
|
|
resp["domain"] = "";
|
|
resp["acmeEmail"] = "";
|
|
resp["certificateStatus"] = "none";
|
|
resp["certificateExpiry"] = Json::nullValue;
|
|
resp["lastRenewalAttempt"] = Json::nullValue;
|
|
resp["lastRenewalError"] = Json::nullValue;
|
|
resp["autoRenewalEnabled"] = true;
|
|
} else {
|
|
const auto& row = r[0];
|
|
domain = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
|
dbStatus = row["certificate_status"].as<std::string>();
|
|
|
|
resp["domain"] = domain;
|
|
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
|
resp["certificateStatus"] = dbStatus;
|
|
|
|
if (!row["certificate_expiry"].isNull()) {
|
|
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
|
|
} else {
|
|
resp["certificateExpiry"] = Json::nullValue;
|
|
}
|
|
|
|
if (!row["last_renewal_attempt"].isNull()) {
|
|
resp["lastRenewalAttempt"] = row["last_renewal_attempt"].as<std::string>();
|
|
} else {
|
|
resp["lastRenewalAttempt"] = Json::nullValue;
|
|
}
|
|
|
|
if (!row["last_renewal_error"].isNull()) {
|
|
resp["lastRenewalError"] = row["last_renewal_error"].as<std::string>();
|
|
} else {
|
|
resp["lastRenewalError"] = Json::nullValue;
|
|
}
|
|
|
|
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
|
|
}
|
|
|
|
// Check for existing certificates on disk if DB shows "none"
|
|
// This handles the case where certbot ran during cloud-init
|
|
if (dbStatus == "none") {
|
|
try {
|
|
// Check /etc/letsencrypt/live directory for any certificates
|
|
std::string letsencryptDir = "/etc/letsencrypt/live";
|
|
if (std::filesystem::exists(letsencryptDir) && std::filesystem::is_directory(letsencryptDir)) {
|
|
for (const auto& entry : std::filesystem::directory_iterator(letsencryptDir)) {
|
|
if (entry.is_directory()) {
|
|
std::string certPath = entry.path().string() + "/fullchain.pem";
|
|
if (std::filesystem::exists(certPath)) {
|
|
std::string detectedDomain = entry.path().filename().string();
|
|
LOG_INFO << "Detected existing SSL certificate for: " << detectedDomain;
|
|
|
|
// Update response to show active certificate
|
|
resp["certificateStatus"] = "active";
|
|
if (resp["domain"].asString().empty()) {
|
|
resp["domain"] = detectedDomain;
|
|
}
|
|
|
|
// Update database to reflect the existing certificate
|
|
*dbClient << "INSERT INTO ssl_settings (id, domain, certificate_status, updated_at) "
|
|
"VALUES (1, $1, 'active', CURRENT_TIMESTAMP) "
|
|
"ON CONFLICT (id) DO UPDATE SET "
|
|
"certificate_status = 'active', "
|
|
"domain = CASE WHEN ssl_settings.domain = '' THEN $1 ELSE ssl_settings.domain END, "
|
|
"updated_at = CURRENT_TIMESTAMP"
|
|
<< detectedDomain
|
|
>> [](const Result&) {
|
|
LOG_INFO << "Updated ssl_settings with detected certificate";
|
|
}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update ssl_settings: " << e.base().what();
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
LOG_DEBUG << "Error checking for certificates: " << e.what();
|
|
}
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get SSL settings: " << e.base().what();
|
|
callback(jsonError("Failed to get SSL settings", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
void AdminController::updateSSLSettings(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto jsonPtr = req->getJsonObject();
|
|
if (!jsonPtr) {
|
|
callback(jsonError("Invalid JSON"));
|
|
return;
|
|
}
|
|
|
|
std::string domain = (*jsonPtr).get("domain", "").asString();
|
|
std::string acmeEmail = (*jsonPtr).get("acmeEmail", "").asString();
|
|
bool autoRenewalEnabled = (*jsonPtr).get("autoRenewalEnabled", true).asBool();
|
|
|
|
// Basic domain validation
|
|
if (!domain.empty()) {
|
|
// Check for valid domain format (simple check)
|
|
if (domain.find(' ') != std::string::npos || domain.find('/') != std::string::npos) {
|
|
callback(jsonError("Invalid domain format"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Basic email validation
|
|
if (!acmeEmail.empty()) {
|
|
if (acmeEmail.find('@') == std::string::npos) {
|
|
callback(jsonError("Invalid email format"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Upsert SSL settings
|
|
*dbClient << "INSERT INTO ssl_settings (id, domain, acme_email, auto_renewal_enabled, updated_at) "
|
|
"VALUES (1, $1, $2, $3, CURRENT_TIMESTAMP) "
|
|
"ON CONFLICT (id) DO UPDATE SET "
|
|
"domain = EXCLUDED.domain, "
|
|
"acme_email = EXCLUDED.acme_email, "
|
|
"auto_renewal_enabled = EXCLUDED.auto_renewal_enabled, "
|
|
"updated_at = CURRENT_TIMESTAMP"
|
|
<< domain << acmeEmail << autoRenewalEnabled
|
|
>> [callback, domain, acmeEmail, autoRenewalEnabled](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["domain"] = domain;
|
|
resp["acmeEmail"] = acmeEmail;
|
|
resp["autoRenewalEnabled"] = autoRenewalEnabled;
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update SSL settings: " << e.base().what();
|
|
callback(jsonError("Failed to update SSL settings", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
void AdminController::requestCertificate(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// First get the current settings
|
|
*dbClient << "SELECT domain, acme_email, certificate_status FROM ssl_settings WHERE id = 1"
|
|
>> [callback, dbClient](const Result& r) {
|
|
if (r.empty()) {
|
|
callback(jsonError("SSL settings not configured. Please set domain and email first."));
|
|
return;
|
|
}
|
|
|
|
const auto& row = r[0];
|
|
std::string domain = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
|
std::string email = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
|
std::string status = row["certificate_status"].as<std::string>();
|
|
|
|
if (domain.empty()) {
|
|
callback(jsonError("Domain not configured"));
|
|
return;
|
|
}
|
|
|
|
if (email.empty()) {
|
|
callback(jsonError("ACME email not configured"));
|
|
return;
|
|
}
|
|
|
|
if (status == "pending") {
|
|
callback(jsonError("Certificate request already in progress"));
|
|
return;
|
|
}
|
|
|
|
// Write trigger files for certbot
|
|
try {
|
|
std::filesystem::create_directories("/etc/letsencrypt");
|
|
|
|
std::ofstream domainFile("/etc/letsencrypt/domain");
|
|
domainFile << domain;
|
|
domainFile.close();
|
|
|
|
std::ofstream emailFile("/etc/letsencrypt/email");
|
|
emailFile << email;
|
|
emailFile.close();
|
|
|
|
// Create trigger file
|
|
std::ofstream triggerFile("/etc/letsencrypt/request_cert");
|
|
triggerFile << "1";
|
|
triggerFile.close();
|
|
|
|
LOG_INFO << "Certificate request triggered for domain: " << domain;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR << "Failed to write certbot trigger files: " << e.what();
|
|
callback(jsonError("Failed to initiate certificate request"));
|
|
return;
|
|
}
|
|
|
|
// Update status to pending
|
|
*dbClient << "UPDATE ssl_settings SET certificate_status = 'pending', "
|
|
"last_renewal_attempt = CURRENT_TIMESTAMP, "
|
|
"last_renewal_error = NULL, "
|
|
"updated_at = CURRENT_TIMESTAMP WHERE id = 1"
|
|
>> [callback, domain](const Result&) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["message"] = "Certificate request initiated for " + domain;
|
|
resp["status"] = "pending";
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update SSL status: " << e.base().what();
|
|
callback(jsonError("Certificate request initiated but status update failed"));
|
|
};
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get SSL settings: " << e.base().what();
|
|
callback(jsonError("Failed to get SSL settings", k500InternalServerError));
|
|
};
|
|
}
|
|
|
|
void AdminController::getSSLStatus(const HttpRequestPtr &req,
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
UserInfo user = getUserFromRequest(req);
|
|
if (user.id == 0 || !user.isAdmin) {
|
|
callback(jsonError("Unauthorized", k403Forbidden));
|
|
return;
|
|
}
|
|
|
|
// Check status file from certbot container
|
|
std::string certStatus = "unknown";
|
|
try {
|
|
std::ifstream statusFile("/etc/letsencrypt/status");
|
|
if (statusFile) {
|
|
std::getline(statusFile, certStatus);
|
|
statusFile.close();
|
|
}
|
|
} catch (...) {
|
|
// Ignore file read errors
|
|
}
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
// Update database status if certbot has completed
|
|
if (certStatus == "active" || certStatus == "error") {
|
|
std::string newStatus = certStatus;
|
|
std::string errorMsg = "";
|
|
|
|
if (certStatus == "error") {
|
|
try {
|
|
std::ifstream errorFile("/etc/letsencrypt/error");
|
|
if (errorFile) {
|
|
std::getline(errorFile, errorMsg);
|
|
errorFile.close();
|
|
}
|
|
} catch (...) {}
|
|
}
|
|
|
|
// Check certificate expiry if active
|
|
std::string expiry = "";
|
|
if (certStatus == "active") {
|
|
// Try to read certificate expiry from OpenSSL
|
|
// For now, we'll set a placeholder - actual expiry can be read from cert
|
|
// In production, use: openssl x509 -enddate -noout -in /etc/letsencrypt/live/domain/cert.pem
|
|
}
|
|
|
|
*dbClient << "UPDATE ssl_settings SET certificate_status = $1, "
|
|
"last_renewal_error = CASE WHEN $2 = '' THEN NULL ELSE $2 END, "
|
|
"updated_at = CURRENT_TIMESTAMP WHERE id = 1"
|
|
<< newStatus << errorMsg
|
|
>> [](const Result&) {}
|
|
>> [](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to update SSL status in DB: " << e.base().what();
|
|
};
|
|
}
|
|
|
|
// Return current status from database
|
|
*dbClient << "SELECT domain, certificate_status, certificate_expiry, "
|
|
"last_renewal_attempt, last_renewal_error FROM ssl_settings WHERE id = 1"
|
|
>> [callback, certStatus](const Result& r) {
|
|
Json::Value resp;
|
|
resp["success"] = true;
|
|
resp["certbotStatus"] = certStatus;
|
|
|
|
if (r.empty()) {
|
|
resp["configured"] = false;
|
|
} else {
|
|
resp["configured"] = true;
|
|
const auto& row = r[0];
|
|
resp["domain"] = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
|
resp["status"] = row["certificate_status"].as<std::string>();
|
|
|
|
if (!row["certificate_expiry"].isNull()) {
|
|
resp["expiry"] = row["certificate_expiry"].as<std::string>();
|
|
}
|
|
|
|
if (!row["last_renewal_attempt"].isNull()) {
|
|
resp["lastAttempt"] = row["last_renewal_attempt"].as<std::string>();
|
|
}
|
|
|
|
if (!row["last_renewal_error"].isNull()) {
|
|
resp["lastError"] = row["last_renewal_error"].as<std::string>();
|
|
}
|
|
}
|
|
|
|
callback(jsonResp(resp));
|
|
}
|
|
>> [callback](const DrogonDbException& e) {
|
|
LOG_ERROR << "Failed to get SSL status: " << e.base().what();
|
|
callback(jsonError("Failed to get SSL status", k500InternalServerError));
|
|
};
|
|
} |