Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,67 @@ public:
ADD_METHOD_TO(AdminController::disconnectStream, "/api/admin/streams/{1}/disconnect", Post);
ADD_METHOD_TO(AdminController::promoteToStreamer, "/api/admin/users/{1}/promote", Post);
ADD_METHOD_TO(AdminController::demoteFromStreamer, "/api/admin/users/{1}/demote", Post);
ADD_METHOD_TO(AdminController::promoteToRestreamer, "/api/admin/users/{1}/promote-restreamer", Post);
ADD_METHOD_TO(AdminController::demoteFromRestreamer, "/api/admin/users/{1}/demote-restreamer", Post);
ADD_METHOD_TO(AdminController::promoteToBot, "/api/admin/users/{1}/promote-bot", Post);
ADD_METHOD_TO(AdminController::demoteFromBot, "/api/admin/users/{1}/demote-bot", Post);
ADD_METHOD_TO(AdminController::getAllBotApiKeys, "/api/admin/bot-keys", Get);
ADD_METHOD_TO(AdminController::deleteBotApiKey, "/api/admin/bot-keys/{1}", Delete);
ADD_METHOD_TO(AdminController::uploadStickers, "/api/admin/stickers/upload", Post);
ADD_METHOD_TO(AdminController::getStickers, "/api/admin/stickers", Get);
ADD_METHOD_TO(AdminController::deleteSticker, "/api/admin/stickers/{1}", Delete);
ADD_METHOD_TO(AdminController::renameSticker, "/api/admin/stickers/{1}/rename", Put);
ADD_METHOD_TO(AdminController::promoteToStickerCreator, "/api/admin/users/{1}/promote-sticker-creator", Post);
ADD_METHOD_TO(AdminController::demoteFromStickerCreator, "/api/admin/users/{1}/demote-sticker-creator", Post);
ADD_METHOD_TO(AdminController::promoteToUploader, "/api/admin/users/{1}/promote-uploader", Post);
ADD_METHOD_TO(AdminController::demoteFromUploader, "/api/admin/users/{1}/demote-uploader", Post);
ADD_METHOD_TO(AdminController::promoteToTexter, "/api/admin/users/{1}/promote-texter", Post);
ADD_METHOD_TO(AdminController::demoteFromTexter, "/api/admin/users/{1}/demote-texter", Post);
ADD_METHOD_TO(AdminController::promoteToWatchCreator, "/api/admin/users/{1}/promote-watch-creator", Post);
ADD_METHOD_TO(AdminController::demoteFromWatchCreator, "/api/admin/users/{1}/demote-watch-creator", Post);
ADD_METHOD_TO(AdminController::promoteToModerator, "/api/admin/users/{1}/promote-moderator", Post);
ADD_METHOD_TO(AdminController::demoteFromModerator, "/api/admin/users/{1}/demote-moderator", Post);
ADD_METHOD_TO(AdminController::getStickerSubmissions, "/api/admin/sticker-submissions", Get);
ADD_METHOD_TO(AdminController::approveStickerSubmission, "/api/admin/sticker-submissions/{1}/approve", Post);
ADD_METHOD_TO(AdminController::denyStickerSubmission, "/api/admin/sticker-submissions/{1}/deny", Post);
ADD_METHOD_TO(AdminController::uploadHonkSound, "/api/admin/honks/upload", Post);
ADD_METHOD_TO(AdminController::getHonkSounds, "/api/admin/honks", Get);
ADD_METHOD_TO(AdminController::deleteHonkSound, "/api/admin/honks/{1}", Delete);
ADD_METHOD_TO(AdminController::setActiveHonkSound, "/api/admin/honks/{1}/activate", Post);
ADD_METHOD_TO(AdminController::getActiveHonkSound, "/api/honk/active", Get);
ADD_METHOD_TO(AdminController::getChatSettings, "/api/admin/settings/chat", Get);
ADD_METHOD_TO(AdminController::updateChatSettings, "/api/admin/settings/chat", Put);
ADD_METHOD_TO(AdminController::getRealms, "/api/admin/realms", Get);
ADD_METHOD_TO(AdminController::deleteRealm, "/api/admin/realms/{1}", Delete);
ADD_METHOD_TO(AdminController::setViewerMultiplier, "/api/admin/realms/{1}/viewer-multiplier", Post);
ADD_METHOD_TO(AdminController::deleteUser, "/api/admin/users/{1}", Delete);
ADD_METHOD_TO(AdminController::disableUser, "/api/admin/users/{1}/disable", Post);
ADD_METHOD_TO(AdminController::enableUser, "/api/admin/users/{1}/enable", Post);
ADD_METHOD_TO(AdminController::uberbanUser, "/api/admin/users/{1}/uberban", Post);
ADD_METHOD_TO(AdminController::incrementReferrals, "/api/admin/users/{1}/increment-referrals", Post);
ADD_METHOD_TO(AdminController::getVideos, "/api/admin/videos", Get);
ADD_METHOD_TO(AdminController::deleteVideo, "/api/admin/videos/{1}", Delete);
ADD_METHOD_TO(AdminController::getAudios, "/api/admin/audios", Get);
ADD_METHOD_TO(AdminController::deleteAudio, "/api/admin/audios/{1}", Delete);
ADD_METHOD_TO(AdminController::getEbooks, "/api/admin/ebooks", Get);
ADD_METHOD_TO(AdminController::deleteEbook, "/api/admin/ebooks/{1}", Delete);
ADD_METHOD_TO(AdminController::getSiteSettings, "/api/admin/settings/site", Get);
ADD_METHOD_TO(AdminController::updateSiteSettings, "/api/admin/settings/site", Put);
ADD_METHOD_TO(AdminController::uploadSiteLogo, "/api/admin/settings/site/logo", Post);
ADD_METHOD_TO(AdminController::getPublicSiteSettings, "/api/settings/site", Get);
ADD_METHOD_TO(AdminController::getCensoredWords, "/api/internal/censored-words", Get);
ADD_METHOD_TO(AdminController::uploadDefaultAvatars, "/api/admin/default-avatars/upload", Post);
ADD_METHOD_TO(AdminController::getDefaultAvatars, "/api/admin/default-avatars", Get);
ADD_METHOD_TO(AdminController::deleteDefaultAvatar, "/api/admin/default-avatars/{1}", Delete);
ADD_METHOD_TO(AdminController::getRandomDefaultAvatar, "/api/default-avatar/random", Get);
ADD_METHOD_TO(AdminController::trackStickerUsage, "/api/internal/stickers/track-usage", Post);
ADD_METHOD_TO(AdminController::getStickerStats, "/api/stats/stickers", Get);
ADD_METHOD_TO(AdminController::downloadAllStickers, "/api/admin/stickers/download-all", Get);
// SSL Certificate Management
ADD_METHOD_TO(AdminController::getSSLSettings, "/api/admin/settings/ssl", Get);
ADD_METHOD_TO(AdminController::updateSSLSettings, "/api/admin/settings/ssl", Put);
ADD_METHOD_TO(AdminController::requestCertificate, "/api/admin/ssl/request", Post);
ADD_METHOD_TO(AdminController::getSSLStatus, "/api/admin/ssl/status", Get);
METHOD_LIST_END
void getUsers(const HttpRequestPtr &req,
@ -32,6 +93,216 @@ public:
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
void promoteToRestreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromRestreamer(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void promoteToBot(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromBot(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void getAllBotApiKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &keyId);
void uploadStickers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getStickers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteSticker(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &stickerId);
void renameSticker(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &stickerId);
void promoteToStickerCreator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromStickerCreator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void promoteToUploader(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromUploader(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void promoteToTexter(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromTexter(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void promoteToWatchCreator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromWatchCreator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void promoteToModerator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void demoteFromModerator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void getStickerSubmissions(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void approveStickerSubmission(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &submissionId);
void denyStickerSubmission(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &submissionId);
void uploadHonkSound(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getHonkSounds(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteHonkSound(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &honkId);
void setActiveHonkSound(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &honkId);
void getActiveHonkSound(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getChatSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateChatSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteRealm(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void setViewerMultiplier(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void deleteUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void disableUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void enableUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void uberbanUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void incrementReferrals(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void getVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId);
void getAudios(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void getEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
void getSiteSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateSiteSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadSiteLogo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getPublicSiteSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getCensoredWords(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadDefaultAvatars(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getDefaultAvatars(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteDefaultAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &avatarId);
void getRandomDefaultAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void trackStickerUsage(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getStickerStats(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void downloadAllStickers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// SSL Certificate Management
void getSSLSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateSSLSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void requestCertificate(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getSSLStatus(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class AudioController : public HttpController<AudioController> {
public:
METHOD_LIST_BEGIN
// Public endpoints
ADD_METHOD_TO(AudioController::getAllAudio, "/api/audio", Get);
ADD_METHOD_TO(AudioController::getLatestAudio, "/api/audio/latest", Get);
ADD_METHOD_TO(AudioController::getAudio, "/api/audio/{1}", Get);
ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get);
ADD_METHOD_TO(AudioController::getRealmAudioByName, "/api/audio/realm/name/{1}", Get);
ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post);
// Authenticated endpoints
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
ADD_METHOD_TO(AudioController::uploadAudio, "/api/user/audio", Post);
ADD_METHOD_TO(AudioController::updateAudio, "/api/audio/{1}", Put);
ADD_METHOD_TO(AudioController::deleteAudio, "/api/audio/{1}", Delete);
ADD_METHOD_TO(AudioController::uploadThumbnail, "/api/audio/{1}/thumbnail", Post);
ADD_METHOD_TO(AudioController::deleteThumbnail, "/api/audio/{1}/thumbnail", Delete);
METHOD_LIST_END
// Public audio listing
void getAllAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getLatestAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void getRealmAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void getRealmAudioByName(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmName);
void incrementPlayCount(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
// Authenticated audio management
void getMyAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void deleteAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void uploadThumbnail(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void deleteThumbnail(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
};

View file

@ -0,0 +1,908 @@
#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");
}

View file

@ -0,0 +1,72 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class EbookController : public HttpController<EbookController> {
public:
METHOD_LIST_BEGIN
// Public endpoints
ADD_METHOD_TO(EbookController::getAllEbooks, "/api/ebooks", Get);
ADD_METHOD_TO(EbookController::getLatestEbooks, "/api/ebooks/latest", Get);
ADD_METHOD_TO(EbookController::getEbook, "/api/ebooks/{1}", Get);
ADD_METHOD_TO(EbookController::getUserEbooks, "/api/ebooks/user/{1}", Get);
ADD_METHOD_TO(EbookController::getRealmEbooks, "/api/ebooks/realm/{1}", Get);
ADD_METHOD_TO(EbookController::incrementReadCount, "/api/ebooks/{1}/read", Post);
// Authenticated endpoints
ADD_METHOD_TO(EbookController::getMyEbooks, "/api/user/ebooks", Get);
ADD_METHOD_TO(EbookController::uploadEbook, "/api/user/ebooks", Post);
ADD_METHOD_TO(EbookController::updateEbook, "/api/ebooks/{1}", Put);
ADD_METHOD_TO(EbookController::deleteEbook, "/api/ebooks/{1}", Delete);
ADD_METHOD_TO(EbookController::uploadCover, "/api/ebooks/{1}/cover", Post);
ADD_METHOD_TO(EbookController::downloadEbook, "/api/ebooks/{1}/download", Get);
METHOD_LIST_END
// Public ebook listing
void getAllEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getLatestEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
void getUserEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
void getRealmEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void incrementReadCount(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
// Authenticated ebook management
void getMyEbooks(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
void deleteEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
void uploadCover(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
void downloadEbook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &ebookId);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,122 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class ForumController : public HttpController<ForumController> {
public:
METHOD_LIST_BEGIN
// Forum CRUD
ADD_METHOD_TO(ForumController::getForums, "/api/forums", Get);
ADD_METHOD_TO(ForumController::getForum, "/api/forums/{1}", Get);
ADD_METHOD_TO(ForumController::createForum, "/api/forums", Post);
ADD_METHOD_TO(ForumController::updateForum, "/api/forums/{1}", Put);
ADD_METHOD_TO(ForumController::deleteForum, "/api/forums/{1}", Delete);
ADD_METHOD_TO(ForumController::uploadBanner, "/api/forums/{1}/banner", Post);
ADD_METHOD_TO(ForumController::deleteBanner, "/api/forums/{1}/banner", Delete);
ADD_METHOD_TO(ForumController::updateBannerPosition, "/api/forums/{1}/banner/position", Put);
ADD_METHOD_TO(ForumController::updateTitleColor, "/api/forums/{1}/title-color", Put);
// Thread CRUD
ADD_METHOD_TO(ForumController::getThreads, "/api/forums/{1}/threads", Get);
ADD_METHOD_TO(ForumController::getThread, "/api/forums/{1}/threads/{2}", Get);
ADD_METHOD_TO(ForumController::createThread, "/api/forums/{1}/threads", Post);
ADD_METHOD_TO(ForumController::updateThread, "/api/forums/{1}/threads/{2}", Put);
ADD_METHOD_TO(ForumController::deleteThread, "/api/forums/{1}/threads/{2}", Delete);
ADD_METHOD_TO(ForumController::pinThread, "/api/forums/{1}/threads/{2}/pin", Post);
ADD_METHOD_TO(ForumController::lockThread, "/api/forums/{1}/threads/{2}/lock", Post);
// Post CRUD
ADD_METHOD_TO(ForumController::getPosts, "/api/forums/{1}/threads/{2}/posts", Get);
ADD_METHOD_TO(ForumController::createPost, "/api/forums/{1}/threads/{2}/posts", Post);
ADD_METHOD_TO(ForumController::updatePost, "/api/forums/{1}/threads/{2}/posts/{3}", Put);
ADD_METHOD_TO(ForumController::deletePost, "/api/forums/{1}/threads/{2}/posts/{3}", Delete);
// Moderation
ADD_METHOD_TO(ForumController::getBannedUsers, "/api/forums/{1}/bans", Get);
ADD_METHOD_TO(ForumController::banUser, "/api/forums/{1}/bans", Post);
ADD_METHOD_TO(ForumController::unbanUser, "/api/forums/{1}/bans/{2}", Delete);
METHOD_LIST_END
// Forum methods
void getForums(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getForum(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void createForum(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateForum(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void deleteForum(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void uploadBanner(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void deleteBanner(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void updateBannerPosition(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void updateTitleColor(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
// Thread methods
void getThreads(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void getThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void createThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void updateThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void deleteThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void pinThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void lockThread(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
// Post methods
void getPosts(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void createPost(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId);
void updatePost(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId,
const std::string &postId);
void deletePost(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &threadId,
const std::string &postId);
// Moderation methods
void getBannedUsers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void banUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId);
void unbanUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &forumId, const std::string &banId);
private:
bool isForumModerator(int64_t userId, int64_t forumOwnerId, bool isAdmin);
bool isUserBanned(int64_t forumId, int64_t userId);
};

File diff suppressed because it is too large Load diff

View file

@ -15,10 +15,18 @@ public:
ADD_METHOD_TO(RealmController::regenerateRealmKey, "/api/realms/{1}/regenerate-key", Post);
ADD_METHOD_TO(RealmController::getRealmByName, "/api/realms/by-name/{1}", Get);
ADD_METHOD_TO(RealmController::getLiveRealms, "/api/realms/live", Get);
ADD_METHOD_TO(RealmController::getAllRealms, "/api/realms/all", Get);
ADD_METHOD_TO(RealmController::validateRealmKey, "/api/realms/validate/{1}", Get);
ADD_METHOD_TO(RealmController::issueRealmViewerToken, "/api/realms/{1}/viewer-token", Get);
ADD_METHOD_TO(RealmController::getRealmStreamKey, "/api/realms/{1}/stream-key", Get);
ADD_METHOD_TO(RealmController::getRealmStats, "/api/realms/{1}/stats", Get);
ADD_METHOD_TO(RealmController::getPublicUserRealms, "/api/realms/user/{1}", Get);
ADD_METHOD_TO(RealmController::uploadOfflineImage, "/api/realms/{1}/offline-image", Post);
ADD_METHOD_TO(RealmController::deleteOfflineImage, "/api/realms/{1}/offline-image", Delete);
ADD_METHOD_TO(RealmController::getRealmModerators, "/api/realms/{1}/moderators", Get);
ADD_METHOD_TO(RealmController::addRealmModerator, "/api/realms/{1}/moderators", Post);
ADD_METHOD_TO(RealmController::removeRealmModerator, "/api/realms/{1}/moderators/{2}", Delete);
ADD_METHOD_TO(RealmController::updateTitleColor, "/api/realms/{1}/title-color", Put);
METHOD_LIST_END
void getUserRealms(const HttpRequestPtr &req,
@ -49,7 +57,10 @@ public:
void getLiveRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getAllRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void validateRealmKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key);
@ -66,6 +77,32 @@ public:
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
void getPublicUserRealms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
void uploadOfflineImage(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void deleteOfflineImage(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void getRealmModerators(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void addRealmModerator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void removeRealmModerator(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &moderatorId);
void updateTitleColor(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
};

View file

@ -0,0 +1,413 @@
#include "RestreamController.h"
#include "../services/RestreamService.h"
#include "../common/HttpHelpers.h"
#include "../common/AuthHelpers.h"
using namespace drogon::orm;
void RestreamController::verifyRestreamPermission(const HttpRequestPtr &req, int64_t realmId,
std::function<void(bool authorized, const UserInfo& user)> callback) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(false, user);
return;
}
// Admin can always manage restreams
if (user.isAdmin) {
callback(true, user);
return;
}
// Must have restreamer role
if (!user.isRestreamer) {
callback(false, user);
return;
}
// Check if user owns the realm
auto dbClient = app().getDbClient();
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
<< realmId
>> [callback, user](const Result& r) {
if (r.empty()) {
callback(false, user);
return;
}
int64_t ownerId = r[0]["user_id"].as<int64_t>();
callback(ownerId == user.id, user);
}
>> [callback, user](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(false, user);
};
}
void RestreamController::getDestinations(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
int64_t id = std::stoll(realmId);
verifyRestreamPermission(req, id, [callback, id](bool authorized, const UserInfo& user) {
if (!authorized) {
callback(jsonError("Unauthorized - requires restreamer role and realm ownership", k403Forbidden));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT id, name, rtmp_url, stream_key, enabled, is_connected, last_error, "
"last_connected_at, created_at FROM restream_destinations WHERE realm_id = $1 "
"ORDER BY created_at ASC"
<< id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value destinations(Json::arrayValue);
for (const auto& row : r) {
Json::Value dest;
dest["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
dest["name"] = row["name"].as<std::string>();
dest["rtmpUrl"] = row["rtmp_url"].as<std::string>();
dest["streamKey"] = row["stream_key"].as<std::string>();
dest["enabled"] = row["enabled"].as<bool>();
dest["isConnected"] = row["is_connected"].as<bool>();
dest["lastError"] = row["last_error"].isNull() ? "" : row["last_error"].as<std::string>();
dest["lastConnectedAt"] = row["last_connected_at"].isNull() ? "" : row["last_connected_at"].as<std::string>();
dest["createdAt"] = row["created_at"].as<std::string>();
destinations.append(dest);
}
resp["destinations"] = destinations;
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "get restream destinations", "Failed to get restream destinations");
});
}
void RestreamController::addDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
int64_t id = std::stoll(realmId);
verifyRestreamPermission(req, id, [this, callback, id, req](bool authorized, const UserInfo& user) {
if (!authorized) {
callback(jsonError("Unauthorized - requires restreamer role and realm ownership", k403Forbidden));
return;
}
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
callback(jsonError("Invalid JSON body"));
return;
}
const auto& json = *jsonPtr;
std::string name = json.get("name", "").asString();
std::string rtmpUrl = json.get("rtmpUrl", "").asString();
std::string streamKey = json.get("streamKey", "").asString();
bool enabled = json.get("enabled", true).asBool();
// Validate inputs
if (name.empty() || name.length() > 100) {
callback(jsonError("Name is required and must be less than 100 characters"));
return;
}
if (rtmpUrl.empty() || rtmpUrl.length() > 500) {
callback(jsonError("RTMP URL is required and must be less than 500 characters"));
return;
}
// Validate RTMP URL format
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
return;
}
if (streamKey.empty() || streamKey.length() > 500) {
callback(jsonError("Stream key is required and must be less than 500 characters"));
return;
}
auto dbClient = app().getDbClient();
// Check current count (max 2 destinations per realm)
*dbClient << "SELECT COUNT(*) as count FROM restream_destinations WHERE realm_id = $1"
<< id
>> [dbClient, callback, id, name, rtmpUrl, streamKey, enabled](const Result& r) {
int64_t count = r[0]["count"].as<int64_t>();
if (count >= 2) {
callback(jsonError("Maximum of 2 restream destinations per realm"));
return;
}
// Insert new destination
*dbClient << "INSERT INTO restream_destinations (realm_id, name, rtmp_url, stream_key, enabled) "
"VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at"
<< id << name << rtmpUrl << streamKey << enabled
>> [callback, name, rtmpUrl, streamKey, enabled](const Result& r) {
if (r.empty()) {
callback(jsonError("Failed to create destination"));
return;
}
Json::Value resp;
resp["success"] = true;
resp["message"] = "Restream destination created";
resp["destination"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
resp["destination"]["name"] = name;
resp["destination"]["rtmpUrl"] = rtmpUrl;
resp["destination"]["streamKey"] = streamKey;
resp["destination"]["enabled"] = enabled;
resp["destination"]["isConnected"] = false;
resp["destination"]["createdAt"] = r[0]["created_at"].as<std::string>();
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "create restream destination", "Failed to create destination");
}
>> DB_ERROR(callback, "check destination count");
});
}
void RestreamController::updateDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId) {
int64_t rid = std::stoll(realmId);
int64_t did = std::stoll(destinationId);
verifyRestreamPermission(req, rid, [callback, rid, did, req](bool authorized, const UserInfo& user) {
if (!authorized) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
callback(jsonError("Invalid JSON body"));
return;
}
const auto& json = *jsonPtr;
auto dbClient = app().getDbClient();
// Validate fields if provided
if (json.isMember("name")) {
std::string name = json["name"].asString();
if (name.empty() || name.length() > 100) {
callback(jsonError("Name must be 1-100 characters"));
return;
}
}
if (json.isMember("rtmpUrl")) {
std::string rtmpUrl = json["rtmpUrl"].asString();
if (rtmpUrl.empty() || rtmpUrl.length() > 500) {
callback(jsonError("RTMP URL must be 1-500 characters"));
return;
}
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
return;
}
}
if (json.isMember("streamKey")) {
std::string streamKey = json["streamKey"].asString();
if (streamKey.empty() || streamKey.length() > 500) {
callback(jsonError("Stream key must be 1-500 characters"));
return;
}
}
bool hasAnyField = json.isMember("name") || json.isMember("rtmpUrl") ||
json.isMember("streamKey") || json.isMember("enabled");
if (!hasAnyField) {
callback(jsonError("No fields to update"));
return;
}
// Only update enabled (most common case)
if (json.isMember("enabled") && !json.isMember("name") &&
!json.isMember("rtmpUrl") && !json.isMember("streamKey")) {
bool newEnabled = json["enabled"].asBool();
// If disabling, stop the push first
if (!newEnabled) {
// Get the realm's stream key to stop the push
*dbClient << "SELECT stream_key FROM realms WHERE id = $1"
<< rid
>> [dbClient, callback, did, rid, newEnabled](const Result& r) {
if (!r.empty()) {
std::string streamKey = r[0]["stream_key"].as<std::string>();
// Stop the push
RestreamService::getInstance().stopPush(streamKey, did, [](bool) {});
}
// Update the database
*dbClient << "UPDATE restream_destinations SET enabled = $1 WHERE id = $2 AND realm_id = $3"
<< newEnabled << did << rid
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Destination updated";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
}
>> DB_ERROR(callback, "get realm");
} else {
// Just enable it
*dbClient << "UPDATE restream_destinations SET enabled = $1 WHERE id = $2 AND realm_id = $3"
<< newEnabled << did << rid
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Destination updated";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
}
}
// Update name only
else if (json.isMember("name") && !json.isMember("enabled") &&
!json.isMember("rtmpUrl") && !json.isMember("streamKey")) {
*dbClient << "UPDATE restream_destinations SET name = $1 WHERE id = $2 AND realm_id = $3"
<< json["name"].asString() << did << rid
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Destination updated";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
}
// Full update with all fields
else {
std::string name = json.get("name", "").asString();
std::string rtmpUrl = json.get("rtmpUrl", "").asString();
std::string streamKey = json.get("streamKey", "").asString();
bool enabled = json.get("enabled", true).asBool();
*dbClient << "UPDATE restream_destinations SET "
"name = $1, rtmp_url = $2, stream_key = $3, enabled = $4 "
"WHERE id = $5 AND realm_id = $6"
<< name << rtmpUrl << streamKey << enabled << did << rid
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Destination updated";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
}
});
}
void RestreamController::deleteDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId) {
int64_t rid = std::stoll(realmId);
int64_t did = std::stoll(destinationId);
verifyRestreamPermission(req, rid, [callback, rid, did](bool authorized, const UserInfo& user) {
if (!authorized) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
auto dbClient = app().getDbClient();
// First get the realm's stream key to stop any active push
*dbClient << "SELECT r.stream_key FROM realms r "
"JOIN restream_destinations rd ON rd.realm_id = r.id "
"WHERE rd.id = $1 AND rd.realm_id = $2"
<< did << rid
>> [dbClient, callback, did, rid](const Result& r) {
if (!r.empty()) {
std::string streamKey = r[0]["stream_key"].as<std::string>();
// Stop the push if active
RestreamService::getInstance().stopPush(streamKey, did, [](bool) {});
}
// Delete the destination
*dbClient << "DELETE FROM restream_destinations WHERE id = $1 AND realm_id = $2"
<< did << rid
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Destination deleted";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "delete restream destination", "Failed to delete destination");
}
>> DB_ERROR(callback, "get stream key for delete");
});
}
void RestreamController::testDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId) {
int64_t rid = std::stoll(realmId);
int64_t did = std::stoll(destinationId);
verifyRestreamPermission(req, rid, [callback, rid, did](bool authorized, const UserInfo& user) {
if (!authorized) {
callback(jsonError("Unauthorized", k403Forbidden));
return;
}
auto dbClient = app().getDbClient();
// Get the destination and realm info
*dbClient << "SELECT rd.id, rd.name, rd.rtmp_url, rd.stream_key, rd.enabled, "
"r.stream_key as realm_stream_key, r.is_live "
"FROM restream_destinations rd "
"JOIN realms r ON rd.realm_id = r.id "
"WHERE rd.id = $1 AND rd.realm_id = $2"
<< did << rid
>> [callback, did](const Result& r) {
if (r.empty()) {
callback(jsonError("Destination not found", k404NotFound));
return;
}
bool isLive = r[0]["is_live"].as<bool>();
if (!isLive) {
callback(jsonError("Stream must be live to test restream connection"));
return;
}
RestreamDestination dest;
dest.id = r[0]["id"].as<int64_t>();
dest.name = r[0]["name"].as<std::string>();
dest.rtmpUrl = r[0]["rtmp_url"].as<std::string>();
dest.streamKey = r[0]["stream_key"].as<std::string>();
dest.enabled = r[0]["enabled"].as<bool>();
std::string realmStreamKey = r[0]["realm_stream_key"].as<std::string>();
// Try to start the push
RestreamService::getInstance().startPush(realmStreamKey, dest,
[callback, dest](bool success, const std::string& error) {
Json::Value resp;
resp["success"] = success;
if (success) {
resp["message"] = "Restream connection successful";
resp["isConnected"] = true;
} else {
resp["message"] = "Restream connection failed";
resp["error"] = error;
resp["isConnected"] = false;
}
callback(jsonResp(resp, success ? k200OK : k400BadRequest));
});
}
>> DB_ERROR(callback, "test restream destination");
});
}

View file

@ -0,0 +1,44 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class RestreamController : public HttpController<RestreamController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(RestreamController::getDestinations, "/api/realms/{1}/restream", Get);
ADD_METHOD_TO(RestreamController::addDestination, "/api/realms/{1}/restream", Post);
ADD_METHOD_TO(RestreamController::updateDestination, "/api/realms/{1}/restream/{2}", Put);
ADD_METHOD_TO(RestreamController::deleteDestination, "/api/realms/{1}/restream/{2}", Delete);
ADD_METHOD_TO(RestreamController::testDestination, "/api/realms/{1}/restream/{2}/test", Post);
METHOD_LIST_END
void getDestinations(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void addDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void updateDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId);
void deleteDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId);
void testDestination(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &destinationId);
private:
// Verify user has restream permission for a realm (owner + restreamer role)
void verifyRestreamPermission(const HttpRequestPtr &req, int64_t realmId,
std::function<void(bool authorized, const UserInfo& user)> callback);
};

View file

@ -4,6 +4,9 @@
#include "../services/RedisHelper.h"
#include "../services/OmeClient.h"
#include "../services/AuthService.h"
#include "../services/RestreamService.h"
#include "../common/HttpHelpers.h"
#include "../common/AuthHelpers.h"
#include <drogon/utils/Utilities.h>
#include <drogon/Cookie.h>
#include <random>
@ -15,24 +18,10 @@ using namespace drogon::orm;
// Helper functions at the top
namespace {
// JSON response helper - saves 6-8 lines per endpoint
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
auto r = HttpResponse::newHttpJsonResponse(j);
r->setStatusCode(c);
return r;
}
HttpResponsePtr jsonOk(const Json::Value& data) {
return jsonResp(data);
}
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
Json::Value j;
j["success"] = false;
j["error"] = error;
return jsonResp(j, code);
}
// Quick JSON builder for common patterns
Json::Value json(std::initializer_list<std::pair<const char*, Json::Value>> items) {
Json::Value j;
@ -41,19 +30,6 @@ namespace {
}
return j;
}
UserInfo getUserFromRequest(const HttpRequestPtr &req) {
UserInfo user;
std::string auth = req->getHeader("Authorization");
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
return user;
}
std::string token = auth.substr(7);
AuthService::getInstance().validateToken(token, user);
return user;
}
}
// Static member definitions
@ -122,10 +98,7 @@ void StreamController::disconnectStream(const HttpRequestPtr &req,
}
});
}
>> [callback](const DrogonDbException& e) {
LOG_ERROR << "Database error: " << e.base().what();
callback(jsonError("Database error"));
};
>> DB_ERROR(callback, "disconnect stream");
}
void StreamController::getStreamStats(const HttpRequestPtr &,
@ -252,7 +225,8 @@ void StreamController::heartbeat(const HttpRequestPtr &req,
return;
}
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 30,
// Refresh token TTL to 5 minutes on heartbeat
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 300,
[callback](bool success) {
if (!success) {
callback(jsonResp({}, k500InternalServerError));
@ -285,29 +259,29 @@ void StreamWebSocketController::handleNewMessage(const WebSocketConnectionPtr&,
void StreamWebSocketController::handleNewConnection(const HttpRequestPtr &req,
const WebSocketConnectionPtr& wsConnPtr) {
LOG_INFO << "New WebSocket connection established";
// Allow anonymous connections for receiving public broadcasts (stream_live/stream_offline)
// These are used by the home page to get instant updates
std::lock_guard<std::mutex> lock(connectionsMutex_);
connections_.insert(wsConnPtr);
auto token = req->getCookie("viewer_token");
if (token.empty()) {
LOG_WARN << "WebSocket connection without viewer token";
wsConnPtr->shutdown();
return;
}
RedisHelper::getKeyAsync("viewer_token:" + token,
[wsConnPtr, token](const std::string& streamKey) {
if (streamKey.empty()) {
LOG_WARN << "Invalid viewer token";
wsConnPtr->shutdown();
return;
if (!token.empty()) {
// If viewer token is provided, validate and track it
RedisHelper::getKeyAsync("viewer_token:" + token,
[wsConnPtr, token](const std::string& streamKey) {
if (!streamKey.empty()) {
std::lock_guard<std::mutex> lock(connectionsMutex_);
tokenConnections_[token].insert(wsConnPtr);
LOG_INFO << "WebSocket authenticated for stream: " << streamKey;
} else {
LOG_DEBUG << "WebSocket with invalid/expired viewer token - treating as anonymous";
}
}
std::lock_guard<std::mutex> lock(connectionsMutex_);
tokenConnections_[token].insert(wsConnPtr);
connections_.insert(wsConnPtr);
LOG_INFO << "WebSocket authenticated for stream: " << streamKey;
}
);
);
} else {
LOG_DEBUG << "Anonymous WebSocket connection (no viewer token)";
}
}
void StreamWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) {
@ -360,11 +334,248 @@ void StreamWebSocketController::broadcastKeyUpdate(const std::string& userId, co
void StreamWebSocketController::broadcastStatsUpdate(const Json::Value& stats) {
std::string jsonStr = Json::FastWriter().write(stats);
std::lock_guard<std::mutex> lock(connectionsMutex_);
for (const auto& conn : connections_) {
if (conn->connected()) {
conn->send(jsonStr);
}
}
}
// OvenMediaEngine Webhook Handlers
void StreamController::handleOmeWebhook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
LOG_WARN << "OME webhook received with invalid JSON";
callback(jsonError("Invalid JSON", k400BadRequest));
return;
}
const auto& payload = *jsonPtr;
std::string eventType = payload.get("eventType", "").asString();
LOG_INFO << "OME Webhook received: " << eventType;
LOG_DEBUG << "OME Webhook payload: " << payload.toStyledString();
// Extract stream information
std::string streamName;
if (payload.isMember("stream") && payload["stream"].isMember("name")) {
streamName = payload["stream"]["name"].asString();
} else if (payload.isMember("streamName")) {
streamName = payload["streamName"].asString();
}
if (streamName.empty()) {
LOG_WARN << "OME webhook missing stream name";
callback(jsonOk(json({{"success", true}, {"message", "Acknowledged"}})));
return;
}
auto dbClient = app().getDbClient();
if (eventType == "streamCreated" || eventType == "stream.created" || eventType == "publish") {
// Stream started - mark realm as live immediately
LOG_INFO << "Stream started via webhook: " << streamName;
*dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, "
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
<< streamName
>> [streamName](const Result& r) {
LOG_INFO << "Realm marked as live via webhook: " << streamName;
// Broadcast to WebSocket clients
Json::Value msg;
msg["type"] = "stream_live";
msg["stream_key"] = streamName;
msg["is_live"] = true;
StreamWebSocketController::broadcastStatsUpdate(msg);
// Trigger immediate stats fetch
StatsService::getInstance().updateStreamStats(streamName);
// Pre-warm thumbnail cache so it's ready when users see the stream
// This makes an async request to generate the thumbnail in the background
auto client = HttpClient::newHttpClient("http://localhost:8088");
auto req = HttpRequest::newHttpRequest();
req->setPath("/thumb/" + streamName + ".webp");
req->setMethod(drogon::Get);
client->sendRequest(req, [streamName](ReqResult result, const HttpResponsePtr& response) {
if (result == ReqResult::Ok && response && response->statusCode() == k200OK) {
LOG_INFO << "Thumbnail pre-warmed for stream: " << streamName;
} else {
LOG_DEBUG << "Thumbnail pre-warm pending for: " << streamName << " (stream may still be initializing)";
}
}, 10.0); // 10 second timeout for thumbnail generation
// Start restream destinations if realm has any
if (!r.empty()) {
int64_t realmId = r[0]["id"].as<int64_t>();
RestreamService::getInstance().startAllDestinations(streamName, realmId);
}
}
>> [streamName](const DrogonDbException& e) {
LOG_ERROR << "Failed to mark realm live via webhook: " << e.base().what();
};
}
else if (eventType == "streamDeleted" || eventType == "stream.deleted" || eventType == "unpublish") {
// Stream ended - mark realm as offline immediately
LOG_INFO << "Stream ended via webhook: " << streamName;
*dbClient << "UPDATE realms SET is_live = false, viewer_count = 0, "
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
<< streamName
>> [streamName](const Result& r) {
LOG_INFO << "Realm marked as offline via webhook: " << streamName;
// Broadcast to WebSocket clients
Json::Value msg;
msg["type"] = "stream_offline";
msg["stream_key"] = streamName;
msg["is_live"] = false;
StreamWebSocketController::broadcastStatsUpdate(msg);
// Stop all restream destinations
if (!r.empty()) {
int64_t realmId = r[0]["id"].as<int64_t>();
RestreamService::getInstance().stopAllDestinations(streamName, realmId);
}
}
>> [streamName](const DrogonDbException& e) {
LOG_ERROR << "Failed to mark realm offline via webhook: " << e.base().what();
};
}
else if (eventType == "sessionCreated" || eventType == "viewer.connected") {
// Viewer connected
LOG_INFO << "Viewer connected to stream: " << streamName;
StatsService::getInstance().updateStreamStats(streamName);
}
else if (eventType == "sessionDeleted" || eventType == "viewer.disconnected") {
// Viewer disconnected
LOG_INFO << "Viewer disconnected from stream: " << streamName;
StatsService::getInstance().updateStreamStats(streamName);
}
// Always respond with success to acknowledge the webhook
callback(jsonOk(json({{"success", true}, {"message", "Webhook processed"}})));
}
void StreamController::handleOmeAdmission(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
// Admission webhook - validates if a stream is allowed to publish/play
// OME sends: { "client": {...}, "request": { "direction", "protocol", "status", "url", ... } }
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
LOG_WARN << "OME admission webhook received with invalid JSON";
callback(jsonError("Invalid JSON", k400BadRequest));
return;
}
const auto& payload = *jsonPtr;
LOG_INFO << "OME Admission webhook: " << payload.toStyledString();
// Check if this is a "closing" status - just acknowledge it
if (payload.isMember("request") && payload["request"].isMember("status")) {
std::string status = payload["request"]["status"].asString();
if (status == "closing") {
LOG_INFO << "OME admission closing notification";
Json::Value response;
callback(jsonOk(response)); // Empty response for closing
return;
}
}
// Extract stream key from URL: rtmp://host:port/app/STREAM_KEY or similar
std::string streamKey;
if (payload.isMember("request") && payload["request"].isMember("url")) {
std::string url = payload["request"]["url"].asString();
// URL format: scheme://host[:port]/app/stream_key[/file][?query]
// Find the stream key after /app/
size_t appPos = url.find("/app/");
if (appPos != std::string::npos) {
std::string afterApp = url.substr(appPos + 5); // Skip "/app/"
// Remove any trailing path or query string
size_t endPos = afterApp.find_first_of("/?");
if (endPos != std::string::npos) {
streamKey = afterApp.substr(0, endPos);
} else {
streamKey = afterApp;
}
}
LOG_INFO << "Extracted stream key from URL: " << streamKey << " (URL: " << url << ")";
}
if (streamKey.empty()) {
LOG_WARN << "OME admission webhook: could not extract stream key, allowing by default";
Json::Value response;
response["allowed"] = true;
callback(jsonOk(response));
return;
}
// Check direction - only validate "incoming" (publish) requests
std::string direction;
if (payload.isMember("request") && payload["request"].isMember("direction")) {
direction = payload["request"]["direction"].asString();
}
if (direction == "outgoing") {
// Playback request - allow all for now (could add viewer auth later)
LOG_INFO << "Allowing outgoing (playback) request for: " << streamKey;
Json::Value response;
response["allowed"] = true;
callback(jsonOk(response));
return;
}
// Validate stream key against database for incoming (publish) requests
auto dbClient = app().getDbClient();
*dbClient << "SELECT id FROM realms WHERE stream_key = $1 AND is_active = true"
<< streamKey
>> [callback, streamKey](const Result& r) {
Json::Value response;
if (!r.empty()) {
LOG_INFO << "Stream key validated for admission: " << streamKey;
response["allowed"] = true;
// Mark stream as live immediately when publishing is approved
int64_t realmId = r[0]["id"].as<int64_t>();
auto db = app().getDbClient();
*db << "UPDATE realms SET is_live = true, viewer_count = 0, "
"updated_at = CURRENT_TIMESTAMP WHERE id = $1"
<< realmId
>> [streamKey, realmId](const Result&) {
LOG_INFO << "Realm marked live on admission: " << streamKey;
// Broadcast to WebSocket clients
Json::Value msg;
msg["type"] = "stream_live";
msg["stream_key"] = streamKey;
msg["is_live"] = true;
StreamWebSocketController::broadcastStatsUpdate(msg);
// Trigger stats fetch
StatsService::getInstance().updateStreamStats(streamKey);
// Start restream destinations
RestreamService::getInstance().startAllDestinations(streamKey, realmId);
}
>> [streamKey](const DrogonDbException& e) {
LOG_ERROR << "Failed to mark realm live on admission: " << e.base().what();
};
} else {
LOG_WARN << "Invalid stream key rejected: " << streamKey;
response["allowed"] = false;
response["reason"] = "Invalid or inactive stream key";
}
callback(jsonOk(response));
}
>> [callback, streamKey](const DrogonDbException& e) {
LOG_ERROR << "Database error during admission check: " << e.base().what();
// Allow on DB error to prevent blocking legitimate streams
Json::Value response;
response["allowed"] = true;
callback(jsonOk(response));
};
}

View file

@ -18,33 +18,43 @@ public:
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post);
// OvenMediaEngine webhook endpoints
ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post);
ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post);
METHOD_LIST_END
void health(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void validateStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &key);
void disconnectStream(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamId);
void getStreamStats(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
void getActiveStreams(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void issueViewerToken(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
void heartbeat(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &streamKey);
// OvenMediaEngine webhook handlers
void handleOmeWebhook(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void handleOmeAdmission(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
};
class StreamWebSocketController : public WebSocketController<StreamWebSocketController> {

File diff suppressed because it is too large Load diff

View file

@ -13,16 +13,40 @@ public:
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
ADD_METHOD_TO(UserController::getToken, "/api/user/token", Get);
ADD_METHOD_TO(UserController::updateProfile, "/api/user/profile", Put);
ADD_METHOD_TO(UserController::updatePassword, "/api/user/password", Put);
ADD_METHOD_TO(UserController::togglePgpOnly, "/api/user/pgp-only", Put);
ADD_METHOD_TO(UserController::addPgpKey, "/api/user/pgp-key", Post);
ADD_METHOD_TO(UserController::getPgpKeys, "/api/user/pgp-keys", Get);
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
ADD_METHOD_TO(UserController::uploadBanner, "/api/user/banner", Post);
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
ADD_METHOD_TO(UserController::updateColor, "/api/user/color", Put);
ADD_METHOD_TO(UserController::getAvailableColors, "/api/colors/available", Get);
ADD_METHOD_TO(UserController::getBotApiKeys, "/api/user/bot-keys", Get);
ADD_METHOD_TO(UserController::createBotApiKey, "/api/user/bot-keys", Post);
ADD_METHOD_TO(UserController::deleteBotApiKey, "/api/user/bot-keys/{1}", Delete);
ADD_METHOD_TO(UserController::validateBotApiKey, "/api/internal/validate-bot-key", Post);
ADD_METHOD_TO(UserController::processPendingUberban, "/api/internal/user/{1}/process-pending-uberban", Post);
ADD_METHOD_TO(UserController::submitSticker, "/api/stickers/submit", Post);
ADD_METHOD_TO(UserController::getMySubmissions, "/api/stickers/my-submissions", Get);
ADD_METHOD_TO(UserController::uploadGraffiti, "/api/user/graffiti", Post);
ADD_METHOD_TO(UserController::deleteGraffiti, "/api/user/graffiti", Delete);
// Übercoin endpoints
ADD_METHOD_TO(UserController::sendUbercoin, "/api/ubercoin/send", Post);
ADD_METHOD_TO(UserController::previewUbercoin, "/api/ubercoin/preview", Post);
ADD_METHOD_TO(UserController::getTreasury, "/api/ubercoin/treasury", Get);
// Treasury cron endpoints (admin-only, called by scheduled tasks)
ADD_METHOD_TO(UserController::treasuryApplyGrowth, "/api/ubercoin/cron/growth", Post);
ADD_METHOD_TO(UserController::treasuryDistribute, "/api/ubercoin/cron/distribute", Post);
// Referral code endpoints
ADD_METHOD_TO(UserController::getReferralCodes, "/api/user/referral-codes", Get);
ADD_METHOD_TO(UserController::purchaseReferralCode, "/api/user/referral-codes/purchase", Post);
ADD_METHOD_TO(UserController::validateReferralCode, "/api/auth/validate-referral", Post);
ADD_METHOD_TO(UserController::registerWithReferral, "/api/auth/register-referral", Post);
ADD_METHOD_TO(UserController::getReferralSettings, "/api/settings/referral", Get);
METHOD_LIST_END
void register_(const HttpRequestPtr &req,
@ -42,7 +66,10 @@ public:
void getCurrentUser(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getToken(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
@ -60,7 +87,10 @@ public:
void uploadAvatar(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadBanner(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getProfile(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
@ -75,6 +105,76 @@ public:
void getAvailableColors(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getBotApiKeys(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void createBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &keyId);
void validateBotApiKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void processPendingUberban(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &userId);
void submitSticker(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getMySubmissions(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadGraffiti(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void deleteGraffiti(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Übercoin methods
void sendUbercoin(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void previewUbercoin(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getTreasury(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Treasury cron methods (admin-only)
void treasuryApplyGrowth(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void treasuryDistribute(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Referral code methods
void getReferralCodes(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void purchaseReferralCode(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void validateReferralCode(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void registerWithReferral(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getReferralSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
private:
UserInfo getUserFromRequest(const HttpRequestPtr &req);
// Übercoin helper: Calculate burn rate based on account age
// Formula: max(1, 99 * e^(-account_age_days / 180))
double calculateBurnRate(int accountAgeDays);
// Referral code helper: Generate random alphanumeric code
std::string generateReferralCode(int length = 12);
// Übercoin helper: Calculate account age in days from created_at timestamp
int calculateAccountAgeDays(const std::string& createdAt);
};

View file

@ -0,0 +1,926 @@
#include "VideoController.h"
#include "../services/DatabaseService.h"
#include "../services/RedisHelper.h" // SECURITY FIX #13: Redis for view rate limiting
#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 <cstdlib>
#include <array>
#include <thread>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
using namespace drogon::orm;
namespace {
// Video metadata extracted from a single ffprobe call
struct VideoMetadata {
int duration = 0;
int width = 0;
int height = 0;
int bitrate = 0;
std::string videoCodec;
std::string audioCodec;
};
// Get all video metadata with a single ffprobe call (5x faster than separate calls)
VideoMetadata getVideoMetadata(const std::string& videoPath) {
VideoMetadata meta;
if (!isPathSafe(videoPath, "/app/uploads")) {
LOG_ERROR << "Unsafe video path rejected: " << videoPath;
return meta;
}
std::vector<std::string> args = {
"/usr/bin/ffprobe", "-v", "error",
"-show_format", "-show_streams",
"-of", "json",
videoPath
};
std::string output = execCommandSafe(args);
if (output.empty()) {
return meta;
}
try {
Json::Value root;
Json::CharReaderBuilder builder;
std::string errors;
std::istringstream stream(output);
if (!Json::parseFromStream(builder, stream, &root, &errors)) {
LOG_ERROR << "Failed to parse ffprobe JSON: " << errors;
return meta;
}
// Extract format info (duration, bitrate)
if (root.isMember("format")) {
const auto& format = root["format"];
if (format.isMember("duration")) {
meta.duration = static_cast<int>(std::stof(format["duration"].asString()));
}
if (format.isMember("bit_rate")) {
try {
meta.bitrate = std::stoi(format["bit_rate"].asString());
} catch (...) {}
}
}
// Extract stream info (video/audio codecs, dimensions)
if (root.isMember("streams") && root["streams"].isArray()) {
for (const auto& stream : root["streams"]) {
std::string codecType = stream.get("codec_type", "").asString();
if (codecType == "video" && meta.videoCodec.empty()) {
meta.videoCodec = stream.get("codec_name", "").asString();
meta.width = stream.get("width", 0).asInt();
meta.height = stream.get("height", 0).asInt();
} else if (codecType == "audio" && meta.audioCodec.empty()) {
meta.audioCodec = stream.get("codec_name", "").asString();
}
}
}
} catch (const std::exception& e) {
LOG_ERROR << "Exception parsing video metadata: " << e.what();
}
return meta;
}
// Video JSON detail levels for API responses
enum class VideoJsonLevel {
Minimal, // 9 fields - for realm video lists
Basic, // 11 fields - for user video lists (+ realmId, realmName)
Standard, // 14 fields - for public lists (+ userId, username, avatarUrl)
Extended // 20 fields - for single video detail (+ technical metadata)
};
// Build video JSON object from database row (reduces code duplication)
Json::Value buildVideoJson(const drogon::orm::Row& row, VideoJsonLevel level) {
Json::Value video;
// Core fields (all levels)
video["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
video["title"] = row["title"].as<std::string>();
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
video["filePath"] = row["file_path"].as<std::string>();
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
video["previewPath"] = row["preview_path"].isNull() ? "" : row["preview_path"].as<std::string>();
video["durationSeconds"] = row["duration_seconds"].as<int>();
video["viewCount"] = row["view_count"].as<int>();
video["createdAt"] = row["created_at"].as<std::string>();
if (level == VideoJsonLevel::Minimal) return video;
// Basic+ fields (realm info)
video["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
video["realmName"] = row["realm_name"].as<std::string>();
if (level == VideoJsonLevel::Basic) return video;
// Standard+ fields (user info)
video["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
video["username"] = row["username"].as<std::string>();
video["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
if (level == VideoJsonLevel::Standard) return video;
// Extended fields (technical metadata)
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
video["width"] = row["width"].isNull() ? 0 : row["width"].as<int>();
video["height"] = row["height"].isNull() ? 0 : row["height"].as<int>();
video["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
video["videoCodec"] = row["video_codec"].isNull() ? "" : row["video_codec"].as<std::string>();
video["audioCodec"] = row["audio_codec"].isNull() ? "" : row["audio_codec"].as<std::string>();
return video;
}
// Generate static WebP thumbnail (safe version - no shell)
bool generateThumbnail(const std::string& videoPath, const std::string& thumbnailPath, int seekSeconds = 2) {
if (!isPathSafe(videoPath, "/app/uploads") || !isPathSafe(thumbnailPath, "/app/uploads")) {
LOG_ERROR << "Unsafe path rejected for thumbnail generation";
return false;
}
pid_t pid = fork();
if (pid == -1) return false;
if (pid == 0) {
// Child process - redirect stderr to /dev/null
int devnull = open("/dev/null", O_WRONLY);
dup2(devnull, STDERR_FILENO);
dup2(devnull, STDOUT_FILENO);
close(devnull);
std::string seekStr = std::to_string(seekSeconds);
execl("/usr/bin/ffmpeg", "ffmpeg",
"-y", "-ss", seekStr.c_str(),
"-i", videoPath.c_str(),
"-vframes", "1",
"-vf", "scale=320:-1",
"-c:v", "libwebp",
"-quality", "80",
thumbnailPath.c_str(),
nullptr);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0 && std::filesystem::exists(thumbnailPath);
}
// Generate animated WebP preview (safe version - no shell)
bool generateAnimatedPreview(const std::string& videoPath, const std::string& previewPath, int seekSeconds = 2, int duration = 3) {
if (!isPathSafe(videoPath, "/app/uploads") || !isPathSafe(previewPath, "/app/uploads")) {
LOG_ERROR << "Unsafe path rejected for preview generation";
return false;
}
pid_t pid = fork();
if (pid == -1) return false;
if (pid == 0) {
// Child process
int devnull = open("/dev/null", O_WRONLY);
dup2(devnull, STDERR_FILENO);
dup2(devnull, STDOUT_FILENO);
close(devnull);
std::string seekStr = std::to_string(seekSeconds);
std::string durationStr = std::to_string(duration);
execl("/usr/bin/ffmpeg", "ffmpeg",
"-y", "-ss", seekStr.c_str(),
"-t", durationStr.c_str(),
"-i", videoPath.c_str(),
"-vf", "scale=320:-1,fps=10",
"-loop", "0",
"-c:v", "libwebp",
"-quality", "60",
previewPath.c_str(),
nullptr);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0 && std::filesystem::exists(previewPath);
}
// Process video thumbnails asynchronously
void processVideoThumbnails(int64_t videoId, const std::string& videoFullPath, const std::string& uploadsDir) {
// Run thumbnail generation in a separate thread
std::thread([videoId, videoFullPath, uploadsDir]() {
try {
// Get all video metadata with a single ffprobe call (5x faster)
VideoMetadata meta = getVideoMetadata(videoFullPath);
// Calculate seek position (10% into video, min 1s, max 30s)
int seekPos = std::max(1, std::min(30, meta.duration / 10));
// Generate filenames
std::string baseName = std::filesystem::path(videoFullPath).stem().string();
std::string thumbnailFilename = baseName + "_thumb.webp";
std::string previewFilename = baseName + "_preview.webp";
std::string thumbnailFullPath = uploadsDir + "/" + thumbnailFilename;
std::string previewFullPath = uploadsDir + "/" + previewFilename;
// Generate thumbnails
bool thumbOk = generateThumbnail(videoFullPath, thumbnailFullPath, seekPos);
bool previewOk = generateAnimatedPreview(videoFullPath, previewFullPath, seekPos, 3);
// Update database
std::string thumbnailPath = thumbOk ? "/uploads/videos/" + thumbnailFilename : "";
std::string previewPath = previewOk ? "/uploads/videos/" + previewFilename : "";
auto dbClient = app().getDbClient();
*dbClient << "UPDATE videos SET thumbnail_path = $1, preview_path = $2, "
"duration_seconds = $3, width = $4, height = $5, "
"bitrate = $6, video_codec = $7, audio_codec = $8 "
"WHERE id = $9"
<< thumbnailPath << previewPath << meta.duration << meta.width << meta.height
<< meta.bitrate << meta.videoCodec << meta.audioCodec << videoId
>> [videoId](const Result&) {
LOG_INFO << "Video " << videoId << " thumbnails and metadata generated successfully";
}
>> [videoId](const DrogonDbException& e) {
LOG_ERROR << "Failed to update video " << videoId << " metadata: " << e.base().what();
};
} catch (const std::exception& e) {
LOG_ERROR << "Exception processing thumbnails for video " << videoId << ": " << e.what();
}
}).detach();
}
}
void VideoController::getAllVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
// Get pagination parameters
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 v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
"FROM videos v "
"JOIN users u ON v.user_id = u.id "
"JOIN realms r ON v.realm_id = r.id "
"WHERE v.is_public = true AND v.status = 'ready' "
"ORDER BY v.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 videos(Json::arrayValue);
for (const auto& row : r) {
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
}
resp["videos"] = videos;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get videos");
}
void VideoController::getLatestVideos(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
"FROM videos v "
"JOIN users u ON v.user_id = u.id "
"JOIN realms r ON v.realm_id = r.id "
"WHERE v.is_public = true AND v.status = 'ready' "
"ORDER BY v.created_at DESC "
"LIMIT 5"
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value videos(Json::arrayValue);
for (const auto& row : r) {
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
}
resp["videos"] = videos;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get latest videos");
}
void VideoController::getVideo(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId) {
int64_t id;
try {
id = std::stoll(videoId);
} catch (...) {
callback(jsonError("Invalid video ID", k400BadRequest));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.file_size_bytes, v.width, v.height, "
"v.bitrate, v.video_codec, v.audio_codec, "
"v.view_count, v.is_public, v.status, v.created_at, v.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
"FROM videos v "
"JOIN users u ON v.user_id = u.id "
"JOIN realms r ON v.realm_id = r.id "
"WHERE v.id = $1 AND v.status = 'ready'"
<< id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Video not found", k404NotFound));
return;
}
const auto& row = r[0];
// Check if video is public
if (!row["is_public"].as<bool>()) {
callback(jsonError("Video not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
resp["video"] = buildVideoJson(row, VideoJsonLevel::Extended);
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get video");
}
void VideoController::getUserVideos(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
"FROM videos v "
"JOIN users u ON v.user_id = u.id "
"JOIN realms r ON v.realm_id = r.id "
"WHERE u.username = $1 AND v.is_public = true AND v.status = 'ready' "
"ORDER BY v.created_at DESC"
<< username
>> [callback, username](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["username"] = username;
Json::Value videos(Json::arrayValue);
for (const auto& row : r) {
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
}
resp["videos"] = videos;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user videos");
}
void VideoController::getRealmVideos(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 = 'video'"
<< id
>> [callback, dbClient, id](const Result& realmResult) {
if (realmResult.empty()) {
callback(jsonError("Video realm not found", k404NotFound));
return;
}
// Get videos for this realm
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.view_count, v.created_at "
"FROM videos v "
"WHERE v.realm_id = $1 AND v.is_public = true AND v.status = 'ready' "
"ORDER BY v.created_at DESC"
<< 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>();
// Videos (Minimal level - no realm/user info since it's implied)
Json::Value videos(Json::arrayValue);
for (const auto& row : r) {
videos.append(buildVideoJson(row, VideoJsonLevel::Minimal));
}
resp["videos"] = videos;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get realm videos");
}
>> DB_ERROR(callback, "get realm");
}
void VideoController::incrementViewCount(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId) {
int64_t id;
try {
id = std::stoll(videoId);
} catch (...) {
callback(jsonError("Invalid video ID", k400BadRequest));
return;
}
// SECURITY FIX #13: Rate limit view count increments per IP per video
// Prevents artificial view count inflation
std::string clientIp = req->getPeerAddr().toIp();
std::string rateKey = "view_limit:" + std::to_string(id) + ":" + clientIp;
// Check if this IP has already viewed this video recently (5 minute window)
RedisHelper::getKeyAsync(rateKey, [callback, id, rateKey, clientIp](const std::string& exists) {
if (!exists.empty()) {
// Already counted recently - return success but don't increment
Json::Value resp;
resp["success"] = true;
resp["message"] = "View already counted";
callback(jsonResp(resp));
return;
}
// Set rate limit key first (TTL 300 seconds = 5 minutes)
RedisHelper::storeKeyAsync(rateKey, "1", 300, [callback, id](bool stored) {
if (!stored) {
LOG_WARN << "Failed to set view rate limit key, allowing view anyway";
}
// Increment view count in database
auto dbClient = app().getDbClient();
*dbClient << "UPDATE videos SET view_count = view_count + 1 "
"WHERE id = $1 AND is_public = true AND status = 'ready' "
"RETURNING view_count"
<< id
>> [callback](const Result& r) {
if (r.empty()) {
callback(jsonError("Video not found", k404NotFound));
return;
}
Json::Value resp;
resp["success"] = true;
resp["viewCount"] = r[0]["view_count"].as<int>();
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "increment view count");
});
});
}
void VideoController::getMyVideos(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 v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
"v.duration_seconds, v.file_size_bytes, v.view_count, v.is_public, v.status, v.created_at, "
"v.realm_id, r.name as realm_name "
"FROM videos v "
"JOIN realms r ON v.realm_id = r.id "
"WHERE v.user_id = $1 AND v.status != 'deleted' "
"ORDER BY v.created_at DESC"
<< user.id
>> [callback](const Result& r) {
Json::Value resp;
resp["success"] = true;
Json::Value videos(Json::arrayValue);
for (const auto& row : r) {
Json::Value video;
video["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
video["title"] = row["title"].as<std::string>();
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
video["filePath"] = row["file_path"].as<std::string>();
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
video["previewPath"] = row["preview_path"].isNull() ? "" : row["preview_path"].as<std::string>();
video["durationSeconds"] = row["duration_seconds"].as<int>();
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
video["viewCount"] = row["view_count"].as<int>();
video["isPublic"] = row["is_public"].as<bool>();
video["status"] = row["status"].as<std::string>();
video["createdAt"] = row["created_at"].as<std::string>();
video["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
video["realmName"] = row["realm_name"].as<std::string>();
videos.append(video);
}
resp["videos"] = videos;
callback(jsonResp(resp));
}
>> DB_ERROR(callback, "get user videos");
}
void VideoController::uploadVideo(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 all data from parser before async call
if (parser.getFiles().empty()) {
callback(jsonError("No file uploaded"));
return;
}
const auto& file = parser.getFiles()[0];
// Get title from form data
std::string title = parser.getParameter<std::string>("title");
if (title.empty()) {
title = "Untitled Video";
}
if (title.length() > 255) {
title = title.substr(0, 255);
}
// Get optional description
std::string description = parser.getParameter<std::string>("description");
if (description.length() > 5000) {
description = description.substr(0, 5000);
}
// Validate file size (500MB max)
const size_t maxSize = 500 * 1024 * 1024;
size_t fileSize = file.fileLength();
if (fileSize > maxSize) {
callback(jsonError("File too large (max 500MB)"));
return;
}
if (fileSize == 0) {
callback(jsonError("Empty file uploaded"));
return;
}
// Validate video magic bytes
auto validation = validateVideoMagicBytes(file.fileData(), fileSize);
if (!validation.valid) {
LOG_WARN << "Video upload rejected: invalid video magic bytes";
callback(jsonError("Invalid video file. Only MP4, WebM, and MOV are allowed."));
return;
}
std::string fileExt = validation.extension.substr(1);
// Write file to disk IMMEDIATELY (before async DB calls) to avoid holding 500MB in memory
const std::string uploadDir = "/app/uploads/videos";
if (!ensureDirectoryExists(uploadDir)) {
callback(jsonError("Failed to create upload directory"));
return;
}
// Generate unique filename
std::string filename = generateRandomFilename(fileExt);
std::string fullPath = uploadDir + "/" + filename;
// Ensure file doesn't exist
while (std::filesystem::exists(fullPath)) {
filename = generateRandomFilename(fileExt);
fullPath = uploadDir + "/" + filename;
}
// Write directly from Drogon buffer (no memory copy)
try {
std::ofstream ofs(fullPath, std::ios::binary);
if (!ofs) {
LOG_ERROR << "Failed to create file: " << fullPath;
callback(jsonError("Failed to save file"));
return;
}
ofs.write(file.fileData(), fileSize);
ofs.close();
if (!std::filesystem::exists(fullPath)) {
LOG_ERROR << "File was not created: " << fullPath;
callback(jsonError("Failed to save file"));
return;
}
} catch (const std::exception& e) {
LOG_ERROR << "Exception saving video file: " << e.what();
callback(jsonError("Failed to save file"));
return;
}
std::string filePath = "/uploads/videos/" + filename;
// 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, fullPath, filePath, fileSize, uploadDir](const Result& r) {
if (r.empty() || !r[0]["is_uploader"].as<bool>()) {
std::filesystem::remove(fullPath); // Clean up file on permission failure
callback(jsonError("You don't have permission to upload videos", k403Forbidden));
return;
}
// Check if realm exists and belongs to user
if (r[0]["realm_id"].isNull()) {
std::filesystem::remove(fullPath); // Clean up file
callback(jsonError("Video realm not found or doesn't belong to you", k404NotFound));
return;
}
// Check if it's a video realm
std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
if (realmType != "video") {
std::filesystem::remove(fullPath); // Clean up file
callback(jsonError("Can only upload videos to video realms", k400BadRequest));
return;
}
// Insert video record - status is 'ready' for now (no processing)
*dbClient << "INSERT INTO videos (user_id, realm_id, title, description, file_path, "
"file_size_bytes, status, is_public, duration_seconds) "
"VALUES ($1, $2, $3, $4, $5, $6, 'ready', true, 0) RETURNING id, created_at"
<< user.id << realmId << title << description << filePath
<< static_cast<int64_t>(fileSize)
>> [callback, title, filePath, fileSize, realmId, fullPath, uploadDir](const Result& r2) {
if (r2.empty()) {
std::filesystem::remove(fullPath); // Clean up file
callback(jsonError("Failed to save video record"));
return;
}
int64_t videoId = r2[0]["id"].as<int64_t>();
// Start async thumbnail generation
processVideoThumbnails(videoId, fullPath, uploadDir);
Json::Value resp;
resp["success"] = true;
resp["video"]["id"] = static_cast<Json::Int64>(videoId);
resp["video"]["realmId"] = static_cast<Json::Int64>(realmId);
resp["video"]["title"] = title;
resp["video"]["filePath"] = filePath;
resp["video"]["fileSizeBytes"] = static_cast<Json::Int64>(fileSize);
resp["video"]["status"] = "ready";
resp["video"]["createdAt"] = r2[0]["created_at"].as<std::string>();
callback(jsonResp(resp));
}
>> [callback, fullPath](const DrogonDbException& e) {
LOG_ERROR << "Failed to insert video: " << e.base().what();
// Clean up file on DB error
std::filesystem::remove(fullPath);
callback(jsonError("Failed to save video"));
};
}
>> [callback, fullPath](const DrogonDbException& e) {
LOG_ERROR << "Failed to check uploader status: " << e.base().what();
std::filesystem::remove(fullPath); // Clean up file on DB error
callback(jsonError("Database error"));
};
} catch (const std::exception& e) {
LOG_ERROR << "Exception in uploadVideo: " << e.what();
callback(jsonError("Internal server error"));
}
}
void VideoController::updateVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t id;
try {
id = std::stoll(videoId);
} catch (...) {
callback(jsonError("Invalid video ID", k400BadRequest));
return;
}
auto json = req->getJsonObject();
if (!json) {
callback(jsonError("Invalid JSON"));
return;
}
auto dbClient = app().getDbClient();
// Verify ownership
*dbClient << "SELECT id FROM videos 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("Video not found or access denied", k404NotFound));
return;
}
std::string title, description;
if (json->isMember("title")) {
title = (*json)["title"].asString();
if (title.length() > 255) title = title.substr(0, 255);
}
if (json->isMember("description")) {
description = (*json)["description"].asString();
if (description.length() > 5000) description = description.substr(0, 5000);
}
if (json->isMember("title") && json->isMember("description")) {
*dbClient << "UPDATE videos SET title = $1, description = $2 WHERE id = $3"
<< title << description << id
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Video updated successfully";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
} else if (json->isMember("title")) {
*dbClient << "UPDATE videos SET title = $1 WHERE id = $2"
<< title << id
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Video updated successfully";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
} else if (json->isMember("description")) {
*dbClient << "UPDATE videos SET description = $1 WHERE id = $2"
<< description << id
>> [callback](const Result&) {
Json::Value resp;
resp["success"] = true;
resp["message"] = "Video updated successfully";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
} else {
Json::Value resp;
resp["success"] = true;
resp["message"] = "No changes to apply";
callback(jsonResp(resp));
}
}
>> DB_ERROR(callback, "verify video ownership");
}
void VideoController::deleteVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId) {
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Unauthorized", k401Unauthorized));
return;
}
int64_t id;
try {
id = std::stoll(videoId);
} catch (...) {
callback(jsonError("Invalid video ID", k400BadRequest));
return;
}
auto dbClient = app().getDbClient();
// Get file paths and verify ownership
*dbClient << "SELECT file_path, thumbnail_path, preview_path FROM videos "
"WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
<< id << user.id
>> [callback, dbClient, id](const Result& r) {
if (r.empty()) {
callback(jsonError("Video not found or access denied", k404NotFound));
return;
}
std::string filePath = r[0]["file_path"].as<std::string>();
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
std::string previewPath = r[0]["preview_path"].isNull() ? "" : r[0]["preview_path"].as<std::string>();
// Soft delete by setting status to 'deleted'
*dbClient << "UPDATE videos SET status = 'deleted' WHERE id = $1"
<< id
>> [callback, filePath, thumbnailPath, previewPath](const Result&) {
// Delete files from disk
try {
std::string fullVideoPath = "/app" + filePath;
if (std::filesystem::exists(fullVideoPath)) {
std::filesystem::remove(fullVideoPath);
}
if (!thumbnailPath.empty()) {
std::string fullThumbPath = "/app" + thumbnailPath;
if (std::filesystem::exists(fullThumbPath)) {
std::filesystem::remove(fullThumbPath);
}
}
if (!previewPath.empty()) {
std::string fullPreviewPath = "/app" + previewPath;
if (std::filesystem::exists(fullPreviewPath)) {
std::filesystem::remove(fullPreviewPath);
}
}
} catch (const std::exception& e) {
LOG_WARN << "Failed to delete video files: " << e.what();
}
Json::Value resp;
resp["success"] = true;
resp["message"] = "Video deleted successfully";
callback(jsonResp(resp));
}
>> DB_ERROR_MSG(callback, "delete video", "Failed to delete video");
}
>> DB_ERROR(callback, "get video for deletion");
}

View file

@ -0,0 +1,62 @@
#pragma once
#include <drogon/HttpController.h>
#include "../services/AuthService.h"
using namespace drogon;
class VideoController : public HttpController<VideoController> {
public:
METHOD_LIST_BEGIN
// Public endpoints
ADD_METHOD_TO(VideoController::getAllVideos, "/api/videos", Get);
ADD_METHOD_TO(VideoController::getLatestVideos, "/api/videos/latest", Get);
ADD_METHOD_TO(VideoController::getVideo, "/api/videos/{1}", Get);
ADD_METHOD_TO(VideoController::getUserVideos, "/api/videos/user/{1}", Get);
ADD_METHOD_TO(VideoController::getRealmVideos, "/api/videos/realm/{1}", Get);
ADD_METHOD_TO(VideoController::incrementViewCount, "/api/videos/{1}/view", Post);
// Authenticated endpoints
ADD_METHOD_TO(VideoController::getMyVideos, "/api/user/videos", Get);
ADD_METHOD_TO(VideoController::uploadVideo, "/api/user/videos", Post);
ADD_METHOD_TO(VideoController::updateVideo, "/api/videos/{1}", Put);
ADD_METHOD_TO(VideoController::deleteVideo, "/api/videos/{1}", Delete);
METHOD_LIST_END
// Public video listing
void getAllVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getLatestVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId);
void getUserVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username);
void getRealmVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void incrementViewCount(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId);
// Authenticated video management
void getMyVideos(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void uploadVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void updateVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId);
void deleteVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &videoId);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,118 @@
#pragma once
#include <drogon/HttpController.h>
#include <drogon/orm/DbClient.h>
#include "../services/AuthService.h"
using namespace drogon;
using namespace drogon::orm;
class WatchController : public HttpController<WatchController> {
public:
METHOD_LIST_BEGIN
// List watch rooms
ADD_METHOD_TO(WatchController::getWatchRooms, "/api/watch/rooms", Get);
// Playlist management
ADD_METHOD_TO(WatchController::getPlaylist, "/api/watch/{1}/playlist", Get);
ADD_METHOD_TO(WatchController::addToPlaylist, "/api/watch/{1}/playlist", Post);
ADD_METHOD_TO(WatchController::removeFromPlaylist, "/api/watch/{1}/playlist/{2}", Delete);
ADD_METHOD_TO(WatchController::reorderPlaylist, "/api/watch/{1}/playlist/reorder", Put);
ADD_METHOD_TO(WatchController::toggleLock, "/api/watch/{1}/playlist/{2}/lock", Put);
// Playback control
ADD_METHOD_TO(WatchController::getRoomState, "/api/watch/{1}/state", Get);
ADD_METHOD_TO(WatchController::playVideo, "/api/watch/{1}/play", Post);
ADD_METHOD_TO(WatchController::pauseVideo, "/api/watch/{1}/pause", Post);
ADD_METHOD_TO(WatchController::seekVideo, "/api/watch/{1}/seek", Post);
ADD_METHOD_TO(WatchController::skipVideo, "/api/watch/{1}/skip", Post);
ADD_METHOD_TO(WatchController::nextVideo, "/api/watch/{1}/next", Post);
// Settings
ADD_METHOD_TO(WatchController::updateSettings, "/api/watch/{1}/settings", Put);
// Duration update (called by chat-service when player reports duration)
ADD_METHOD_TO(WatchController::updateDuration, "/api/watch/{1}/duration", Post);
METHOD_LIST_END
// List watch rooms
void getWatchRooms(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Playlist management
void getPlaylist(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void addToPlaylist(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void removeFromPlaylist(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &itemId);
void reorderPlaylist(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void toggleLock(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &itemId);
// Playback control
void getRoomState(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void playVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void pauseVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void seekVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void skipVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
void nextVideo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
// Settings
void updateSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
// Duration update (called by chat-service when player reports duration)
void updateDuration(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId);
private:
bool canControlPlaylist(const UserInfo& user, int64_t realmId, int64_t ownerId,
const std::string& mode, const std::string& whitelist);
bool canControlPlayback(const UserInfo& user, int64_t ownerId);
std::string extractYouTubeVideoId(const std::string& url);
// Helper to add video to playlist (reduces callback nesting)
void addVideoToPlaylist(
std::function<void(const HttpResponsePtr &)> callback,
const DbClientPtr& dbClient,
int64_t realmId,
const UserInfo& user,
const std::string& videoId,
const std::string& title,
int durationSeconds,
const std::string& thumbnailUrl,
const std::string& username,
const std::string& fingerprint,
int64_t ownerId);
};