beeta/backend/src/controllers/EbookController.cpp

896 lines
43 KiB
C++
Raw Normal View History

2026-01-05 22:54:27 -05:00
#include "EbookController.h"
#include "../services/DatabaseService.h"
#include "../common/HttpHelpers.h"
#include "../common/AuthHelpers.h"
#include "../common/FileUtils.h"
#include "../common/FileValidation.h"
#include <drogon/utils/Utilities.h>
#include <drogon/Cookie.h>
#include <random>
#include <sstream>
#include <iomanip>
#include <fstream>
#include <filesystem>
#include <regex>
#include <cerrno>
// 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<void(const HttpResponsePtr &)> &&callback) {
2026-01-09 16:38:24 -05:00
auto pagination = parsePagination(req, 20, 50);
2026-01-05 22:54:27 -05:00
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"
2026-01-09 16:38:24 -05:00
<< static_cast<int64_t>(pagination.limit) << static_cast<int64_t>(pagination.offset)
2026-01-05 22:54:27 -05:00
>> [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["createdAt"] = row["created_at"].as<std::string>();
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
ebook["username"] = row["username"].as<std::string>();
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
ebook["realmName"] = row["realm_name"].as<std::string>();
ebooks.append(ebook);
}
resp["ebooks"] = ebooks;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get ebooks");
}
void EbookController::getLatestEbooks(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&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<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["createdAt"] = row["created_at"].as<std::string>();
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
ebook["username"] = row["username"].as<std::string>();
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
ebook["realmName"] = row["realm_name"].as<std::string>();
ebooks.append(ebook);
}
resp["ebooks"] = ebooks;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get latest ebooks");
}
void EbookController::getEbook(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&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<bool>()) {
callback(jsonError("Ebook not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
auto& ebook = resp["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["createdAt"] = row["created_at"].as<std::string>();
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
ebook["username"] = row["username"].as<std::string>();
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
ebook["realmName"] = row["realm_name"].as<std::string>();
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get ebook");
}
void EbookController::getUserEbooks(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&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<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["createdAt"] = row["created_at"].as<std::string>();
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
ebook["realmName"] = row["realm_name"].as<std::string>();
ebooks.append(ebook);
}
resp["ebooks"] = ebooks;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user ebooks");
}
void EbookController::getRealmEbooks(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&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<Json::Int64>(realmResult[0]["id"].as<int64_t>());
realm["name"] = realmResult[0]["name"].as<std::string>();
realm["description"] = realmResult[0]["description"].isNull() ? "" : realmResult[0]["description"].as<std::string>();
realm["titleColor"] = realmResult[0]["title_color"].isNull() ? "#ffffff" : realmResult[0]["title_color"].as<std::string>();
realm["createdAt"] = realmResult[0]["created_at"].as<std::string>();
realm["userId"] = static_cast<Json::Int64>(realmResult[0]["user_id"].as<int64_t>());
realm["username"] = realmResult[0]["username"].as<std::string>();
realm["avatarUrl"] = realmResult[0]["avatar_url"].isNull() ? "" : realmResult[0]["avatar_url"].as<std::string>();
// Ebooks
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["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<int>();
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "increment read count");
}
void EbookController::getMyEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT 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<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["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
ebook["realmName"] = row["realm_name"].as<std::string>();
ebooks.append(ebook);
}
resp["ebooks"] = ebooks;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user ebooks");
}
void EbookController::uploadEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
try {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
MultiPartParser parser;
parser.parse(req);
// Get realm ID from form data - required
std::string realmIdStr = parser.getParameter<std::string>("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<std::string>("title"), 255);
if (title.empty()) {
title = "Untitled Ebook";
}
// Get optional description - sanitize input
std::string description = sanitizeUserInput(parser.getParameter<std::string>("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<bool>()) {
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<std::string>();
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<int64_t>(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<int64_t>();
Json::Value resp;
resp["success"] = true;
resp["ebook"]["id"] = static_cast<Json::Int64>(ebookId);
resp["ebook"]["realmId"] = static_cast<Json::Int64>(realmId);
resp["ebook"]["title"] = title;
resp["ebook"]["filePath"] = filePath;
resp["ebook"]["fileSizeBytes"] = static_cast<Json::Int64>(fileSize);
resp["ebook"]["status"] = "ready";
resp["ebook"]["createdAt"] = r2[0]["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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>();
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 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<void(const HttpResponsePtr &)> &&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<std::string>();
// 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<void(const HttpResponsePtr &)> &&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>();
std::string filePath = r[0]["file_path"].as<std::string>();
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");
}