#include "EbookController.h" #include "../services/DatabaseService.h" #include "../common/HttpHelpers.h" #include "../common/AuthHelpers.h" #include "../common/FileUtils.h" #include "../common/FileValidation.h" #include #include #include #include #include #include #include #include #include // File size limits static constexpr size_t MAX_EBOOK_SIZE = 100 * 1024 * 1024; // 100MB static constexpr size_t MAX_COVER_SIZE = 5 * 1024 * 1024; // 5MB using namespace drogon::orm; void EbookController::getAllEbooks(const HttpRequestPtr &req, std::function &&callback) { int page = 1; int limit = 20; auto pageParam = req->getParameter("page"); auto limitParam = req->getParameter("limit"); if (!pageParam.empty()) { try { page = std::stoi(pageParam); } catch (...) {} } if (!limitParam.empty()) { try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {} } int offset = (page - 1) * limit; 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.created_at, e.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " "FROM ebooks e " "JOIN users u ON e.user_id = u.id " "JOIN realms r ON e.realm_id = r.id " "WHERE e.is_public = true AND e.status = 'ready' " "ORDER BY e.created_at DESC " "LIMIT $1 OFFSET $2" << static_cast(limit) << static_cast(offset) >> [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(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["userId"] = static_cast(row["user_id"].as()); ebook["username"] = row["username"].as(); ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR(callback, "get ebooks"); } void EbookController::getLatestEbooks(const HttpRequestPtr &, std::function &&callback) { 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.created_at, e.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " "FROM ebooks e " "JOIN users u ON e.user_id = u.id " "JOIN realms r ON e.realm_id = r.id " "WHERE e.is_public = true AND e.status = 'ready' " "ORDER BY e.created_at DESC " "LIMIT 5" >> [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(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["userId"] = static_cast(row["user_id"].as()); ebook["username"] = row["username"].as(); ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR(callback, "get latest ebooks"); } void EbookController::getEbook(const HttpRequestPtr &, std::function &&callback, const std::string &ebookId) { int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); 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, e.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " "FROM ebooks e " "JOIN users u ON e.user_id = u.id " "JOIN realms r ON e.realm_id = r.id " "WHERE e.id = $1 AND e.status = 'ready'" << id >> [callback](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found", k404NotFound)); return; } const auto& row = r[0]; if (!row["is_public"].as()) { callback(jsonError("Ebook not found", k404NotFound)); return; } Json::Value resp; resp["success"] = true; auto& ebook = resp["ebook"]; ebook["id"] = static_cast(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["userId"] = static_cast(row["user_id"].as()); ebook["username"] = row["username"].as(); ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); callback(jsonResp(resp)); } >> DB_ERROR(callback, "get ebook"); } void EbookController::getUserEbooks(const HttpRequestPtr &, std::function &&callback, const std::string &username) { 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.created_at, e.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " "FROM ebooks e " "JOIN users u ON e.user_id = u.id " "JOIN realms r ON e.realm_id = r.id " "WHERE u.username = $1 AND e.is_public = true AND e.status = 'ready' " "ORDER BY e.created_at DESC" << username >> [callback, username](const Result& r) { Json::Value resp; resp["success"] = true; resp["username"] = username; Json::Value ebooks(Json::arrayValue); for (const auto& row : r) { Json::Value ebook; ebook["id"] = static_cast(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR(callback, "get user ebooks"); } void EbookController::getRealmEbooks(const HttpRequestPtr &, std::function &&callback, const std::string &realmId) { int64_t id; try { id = std::stoll(realmId); } catch (...) { callback(jsonError("Invalid realm ID", k400BadRequest)); return; } auto dbClient = app().getDbClient(); // First get realm info *dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, " "u.id as user_id, u.username, u.avatar_url " "FROM realms r " "JOIN users u ON r.user_id = u.id " "WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'ebook'" << id >> [callback, dbClient, id](const Result& realmResult) { if (realmResult.empty()) { callback(jsonError("Ebook realm not found", k404NotFound)); return; } // Get ebooks for this realm *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.created_at " "FROM ebooks e " "WHERE e.realm_id = $1 AND e.is_public = true AND e.status = 'ready' " "ORDER BY e.created_at DESC LIMIT 100" << id >> [callback, realmResult](const Result& r) { Json::Value resp; resp["success"] = true; // Realm info auto& realm = resp["realm"]; realm["id"] = static_cast(realmResult[0]["id"].as()); realm["name"] = realmResult[0]["name"].as(); realm["description"] = realmResult[0]["description"].isNull() ? "" : realmResult[0]["description"].as(); realm["titleColor"] = realmResult[0]["title_color"].isNull() ? "#ffffff" : realmResult[0]["title_color"].as(); realm["createdAt"] = realmResult[0]["created_at"].as(); realm["userId"] = static_cast(realmResult[0]["user_id"].as()); realm["username"] = realmResult[0]["username"].as(); realm["avatarUrl"] = realmResult[0]["avatar_url"].isNull() ? "" : realmResult[0]["avatar_url"].as(); // Ebooks Json::Value ebooks(Json::arrayValue); for (const auto& row : r) { Json::Value ebook; ebook["id"] = static_cast(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["createdAt"] = row["created_at"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR(callback, "get realm ebooks"); } >> DB_ERROR(callback, "get realm"); } void EbookController::incrementReadCount(const HttpRequestPtr &, std::function &&callback, const std::string &ebookId) { int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); return; } auto dbClient = app().getDbClient(); *dbClient << "UPDATE ebooks SET read_count = read_count + 1 " "WHERE id = $1 AND is_public = true AND status = 'ready' " "RETURNING read_count" << id >> [callback](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found", k404NotFound)); return; } Json::Value resp; resp["success"] = true; resp["readCount"] = r[0]["read_count"].as(); callback(jsonResp(resp)); } >> DB_ERROR(callback, "increment read count"); } void EbookController::getMyEbooks(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT 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, " "e.realm_id, r.name as realm_name " "FROM ebooks e " "JOIN realms r ON e.realm_id = r.id " "WHERE e.user_id = $1 AND e.status != 'deleted' " "ORDER BY e.created_at DESC" << user.id >> [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(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["isPublic"] = row["is_public"].as(); ebook["status"] = row["status"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR(callback, "get user ebooks"); } void EbookController::uploadEbook(const HttpRequestPtr &req, std::function &&callback) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } MultiPartParser parser; parser.parse(req); // Get realm ID from form data - required std::string realmIdStr = parser.getParameter("realmId"); if (realmIdStr.empty()) { callback(jsonError("Realm ID is required")); return; } int64_t realmId; try { realmId = std::stoll(realmIdStr); } catch (...) { callback(jsonError("Invalid realm ID")); return; } // Extract file if (parser.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } const auto& file = parser.getFiles()[0]; // Get title from form data - sanitize input std::string title = sanitizeUserInput(parser.getParameter("title"), 255); if (title.empty()) { title = "Untitled Ebook"; } // Get optional description - sanitize input std::string description = sanitizeUserInput(parser.getParameter("description"), 5000); // Validate file size size_t fileSize = file.fileLength(); if (fileSize > MAX_EBOOK_SIZE) { callback(jsonError("File too large (max 100MB)")); return; } if (fileSize == 0) { callback(jsonError("Empty file uploaded")); return; } // Validate EPUB magic bytes if (!isValidEpub(file.fileData(), fileSize)) { LOG_WARN << "Ebook upload rejected: invalid EPUB file"; callback(jsonError("Invalid file. Only EPUB format is allowed.")); return; } // Copy file data before async call std::string fileDataStr(file.fileData(), fileSize); // Check if user has uploader role and the realm exists and belongs to them auto dbClient = app().getDbClient(); *dbClient << "SELECT u.is_uploader, r.id as realm_id, r.realm_type " "FROM users u " "LEFT JOIN realms r ON r.user_id = u.id AND r.id = $2 " "WHERE u.id = $1" << user.id << realmId >> [callback, user, dbClient, realmId, title, description, fileDataStr, fileSize](const Result& r) { if (r.empty() || !r[0]["is_uploader"].as()) { callback(jsonError("You don't have permission to upload ebooks", k403Forbidden)); return; } // Check if realm exists and belongs to user if (r[0]["realm_id"].isNull()) { callback(jsonError("Ebook realm not found or doesn't belong to you", k404NotFound)); return; } // Check if it's an ebook realm std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as(); if (realmType != "ebook") { callback(jsonError("Can only upload ebooks to ebook realms", k400BadRequest)); return; } // Ensure uploads directory exists const std::string uploadDir = "/app/uploads/ebooks"; if (!ensureDirectoryExists(uploadDir)) { callback(jsonError("Failed to create upload directory")); return; } // Generate unique filename and create file atomically // This prevents TOCTOU race conditions std::string filename; std::string fullPath; int maxAttempts = 10; for (int attempt = 0; attempt < maxAttempts; ++attempt) { filename = generateRandomFilename("epub"); fullPath = uploadDir + "/" + filename; // Try to create file atomically with exclusive access if (writeFileExclusive(fullPath, fileDataStr.data(), fileSize)) { break; // Success } // If file already exists (EEXIST), try again with new name if (errno == EEXIST) { continue; } // Other error - fail LOG_ERROR << "Failed to create file: " << fullPath << " errno: " << errno; callback(jsonError("Failed to save file")); return; } // Verify file was created if (!std::filesystem::exists(fullPath)) { LOG_ERROR << "File was not created after " << maxAttempts << " attempts"; callback(jsonError("Failed to save file")); return; } try { std::string filePath = "/uploads/ebooks/" + filename; // Insert ebook record - status is 'ready' (no server-side processing needed) *dbClient << "INSERT INTO ebooks (user_id, realm_id, title, description, file_path, " "file_size_bytes, status, is_public) " "VALUES ($1, $2, $3, $4, $5, $6, 'ready', true) RETURNING id, created_at" << user.id << realmId << title << description << filePath << static_cast(fileSize) >> [callback, title, filePath, fileSize, realmId](const Result& r2) { if (r2.empty()) { callback(jsonError("Failed to save ebook record")); return; } int64_t ebookId = r2[0]["id"].as(); Json::Value resp; resp["success"] = true; resp["ebook"]["id"] = static_cast(ebookId); resp["ebook"]["realmId"] = static_cast(realmId); resp["ebook"]["title"] = title; resp["ebook"]["filePath"] = filePath; resp["ebook"]["fileSizeBytes"] = static_cast(fileSize); resp["ebook"]["status"] = "ready"; resp["ebook"]["createdAt"] = r2[0]["created_at"].as(); callback(jsonResp(resp)); } >> [callback, fullPath](const DrogonDbException& e) { LOG_ERROR << "Failed to insert ebook: " << e.base().what(); // Clean up file on DB error std::filesystem::remove(fullPath); callback(jsonError("Failed to save ebook")); }; } catch (const std::exception& e) { LOG_ERROR << "Exception saving ebook file: " << e.what(); callback(jsonError("Failed to save file")); } } >> DB_ERROR(callback, "check uploader status"); } catch (const std::exception& e) { LOG_ERROR << "Exception in uploadEbook: " << e.what(); callback(jsonError("Internal server error")); } } void EbookController::updateEbook(const HttpRequestPtr &req, std::function &&callback, const std::string &ebookId) { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } auto dbClient = app().getDbClient(); // Verify ownership *dbClient << "SELECT id FROM ebooks WHERE id = $1 AND user_id = $2 AND status != 'deleted'" << id << user.id >> [callback, json, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found or access denied", k404NotFound)); return; } std::string title, description; if (json->isMember("title")) { title = sanitizeUserInput((*json)["title"].asString(), 255); } if (json->isMember("description")) { description = sanitizeUserInput((*json)["description"].asString(), 5000); } if (json->isMember("title") && json->isMember("description")) { *dbClient << "UPDATE ebooks SET title = $1, description = $2, updated_at = NOW() WHERE id = $3" << title << description << id >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Ebook updated successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook"); } else if (json->isMember("title")) { *dbClient << "UPDATE ebooks SET title = $1, updated_at = NOW() WHERE id = $2" << title << id >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Ebook updated successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook"); } else if (json->isMember("description")) { *dbClient << "UPDATE ebooks SET description = $1, updated_at = NOW() WHERE id = $2" << description << id >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Ebook updated successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook"); } else { Json::Value resp; resp["success"] = true; resp["message"] = "No changes to apply"; callback(jsonResp(resp)); } } >> DB_ERROR(callback, "verify ebook ownership"); } void EbookController::deleteEbook(const HttpRequestPtr &req, std::function &&callback, const std::string &ebookId) { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); return; } auto dbClient = app().getDbClient(); // Get file path and verify ownership *dbClient << "SELECT file_path, cover_path FROM ebooks " "WHERE id = $1 AND user_id = $2 AND status != 'deleted'" << id << user.id >> [callback, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found or access denied", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); std::string coverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as(); // 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 with path validation try { std::string fullEbookPath = "/app" + filePath; // Validate path is within allowed directory before deletion if (isPathSafe(fullEbookPath, "/app/uploads/ebooks")) { if (std::filesystem::exists(fullEbookPath)) { std::filesystem::remove(fullEbookPath); } } else { LOG_WARN << "Blocked deletion of file outside uploads: " << fullEbookPath; } if (!coverPath.empty()) { std::string fullCoverPath = "/app" + coverPath; // Validate cover path as well if (isPathSafe(fullCoverPath, "/app/uploads/ebooks")) { if (std::filesystem::exists(fullCoverPath)) { std::filesystem::remove(fullCoverPath); } } else { LOG_WARN << "Blocked deletion of cover outside uploads: " << 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(callback, "get ebook for deletion"); } void EbookController::uploadCover(const HttpRequestPtr &req, std::function &&callback, const std::string &ebookId) { try { UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); return; } MultiPartParser parser; parser.parse(req); if (parser.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } const auto& file = parser.getFiles()[0]; size_t fileSize = file.fileLength(); // Validate cover file size if (fileSize > MAX_COVER_SIZE) { callback(jsonError("Cover image too large (max 5MB)")); return; } if (fileSize == 0) { callback(jsonError("Empty file uploaded")); return; } // Validate image type auto validation = validateImageMagicBytes(file.fileData(), fileSize); if (!validation.valid) { callback(jsonError("Invalid image file. Only JPG, PNG, and WebP are allowed.")); return; } std::string fileDataStr(file.fileData(), fileSize); std::string fileExt = validation.extension; auto dbClient = app().getDbClient(); // Verify ownership *dbClient << "SELECT id, cover_path FROM ebooks WHERE id = $1 AND user_id = $2 AND status != 'deleted'" << id << user.id >> [callback, dbClient, id, fileDataStr, fileSize, fileExt](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found or access denied", k404NotFound)); return; } std::string oldCoverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as(); // Ensure covers directory exists const std::string coverDir = "/app/uploads/ebooks/covers"; if (!ensureDirectoryExists(coverDir)) { callback(jsonError("Failed to create upload directory")); return; } // Generate unique filename and create file atomically std::string filename; std::string fullPath; int maxAttempts = 10; for (int attempt = 0; attempt < maxAttempts; ++attempt) { filename = generateRandomFilename(fileExt); fullPath = coverDir + "/" + filename; if (writeFileExclusive(fullPath, fileDataStr.data(), fileSize)) { break; } if (errno == EEXIST) { continue; } callback(jsonError("Failed to save cover")); return; } if (!std::filesystem::exists(fullPath)) { callback(jsonError("Failed to save cover")); return; } std::string coverPath = "/uploads/ebooks/covers/" + filename; // Update database *dbClient << "UPDATE ebooks SET cover_path = $1 WHERE id = $2" << coverPath << id >> [callback, coverPath, oldCoverPath](const Result&) { // Delete old cover if exists (with path validation) if (!oldCoverPath.empty()) { try { std::string oldFullPath = "/app" + oldCoverPath; // Validate path before deletion if (isPathSafe(oldFullPath, "/app/uploads/ebooks")) { if (std::filesystem::exists(oldFullPath)) { std::filesystem::remove(oldFullPath); } } else { LOG_WARN << "Blocked deletion of old cover outside uploads: " << oldFullPath; } } catch (...) {} } Json::Value resp; resp["success"] = true; resp["coverPath"] = coverPath; callback(jsonResp(resp)); } >> [callback, fullPath](const DrogonDbException& e) { LOG_ERROR << "Failed to update cover path: " << e.base().what(); std::filesystem::remove(fullPath); callback(jsonError("Failed to save cover")); }; } >> DB_ERROR(callback, "verify ebook ownership"); } catch (const std::exception& e) { LOG_ERROR << "Exception in uploadCover: " << e.what(); callback(jsonError("Internal server error")); } } void EbookController::downloadEbook(const HttpRequestPtr &req, std::function &&callback, const std::string &ebookId) { // Require authentication for downloads UserInfo user = getUserFromRequest(req); if (user.id == 0) { callback(jsonError("Please log in to download ebooks", k401Unauthorized)); return; } int64_t id; try { id = std::stoll(ebookId); } catch (...) { callback(jsonError("Invalid ebook ID", k400BadRequest)); return; } auto dbClient = app().getDbClient(); // Get ebook info - only allow download of public, ready ebooks *dbClient << "SELECT title, file_path FROM ebooks WHERE id = $1 AND is_public = true AND status = 'ready'" << id >> [callback, id](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found", k404NotFound)); return; } std::string title = r[0]["title"].as(); std::string filePath = r[0]["file_path"].as(); std::string fullPath = "/app" + filePath; // Validate path is within allowed directory if (!isPathSafe(fullPath, "/app/uploads/ebooks")) { LOG_WARN << "Blocked access to file outside uploads: " << fullPath; callback(jsonError("Ebook file not found", k404NotFound)); return; } // Check file exists if (!std::filesystem::exists(fullPath)) { LOG_ERROR << "Ebook file not found: " << fullPath; callback(jsonError("Ebook file not found", k404NotFound)); return; } // Sanitize title for filename (remove special chars) std::string safeTitle; for (char c : title) { if (std::isalnum(c) || c == ' ' || c == '-' || c == '_') { safeTitle += c; } } if (safeTitle.empty()) safeTitle = "ebook"; if (safeTitle.length() > 100) safeTitle = safeTitle.substr(0, 100); // Use Drogon's file response for efficient streaming auto resp = HttpResponse::newFileResponse(fullPath, "", CT_CUSTOM); resp->addHeader("Content-Type", "application/epub+zip"); resp->addHeader("Content-Disposition", "attachment; filename=\"" + safeTitle + ".epub\""); callback(resp); } >> DB_ERROR(callback, "download ebook"); }