909 lines
43 KiB
C++
909 lines
43 KiB
C++
|
|
#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) {
|
||
|
|
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<int64_t>(limit) << static_cast<int64_t>(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<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");
|
||
|
|
}
|