Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
};
|
||||
1051
backend/src/controllers/AudioController.cpp
Normal file
1051
backend/src/controllers/AudioController.cpp
Normal file
File diff suppressed because it is too large
Load diff
72
backend/src/controllers/AudioController.h
Normal file
72
backend/src/controllers/AudioController.h
Normal 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);
|
||||
};
|
||||
908
backend/src/controllers/EbookController.cpp
Normal file
908
backend/src/controllers/EbookController.cpp
Normal 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");
|
||||
}
|
||||
72
backend/src/controllers/EbookController.h
Normal file
72
backend/src/controllers/EbookController.h
Normal 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);
|
||||
};
|
||||
1788
backend/src/controllers/ForumController.cpp
Normal file
1788
backend/src/controllers/ForumController.cpp
Normal file
File diff suppressed because it is too large
Load diff
122
backend/src/controllers/ForumController.h
Normal file
122
backend/src/controllers/ForumController.h
Normal 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
|
|
@ -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);
|
||||
};
|
||||
413
backend/src/controllers/RestreamController.cpp
Normal file
413
backend/src/controllers/RestreamController.cpp
Normal 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");
|
||||
});
|
||||
}
|
||||
44
backend/src/controllers/RestreamController.h
Normal file
44
backend/src/controllers/RestreamController.h
Normal 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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
};
|
||||
926
backend/src/controllers/VideoController.cpp
Normal file
926
backend/src/controllers/VideoController.cpp
Normal 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");
|
||||
}
|
||||
62
backend/src/controllers/VideoController.h
Normal file
62
backend/src/controllers/VideoController.h
Normal 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);
|
||||
};
|
||||
1305
backend/src/controllers/WatchController.cpp
Normal file
1305
backend/src/controllers/WatchController.cpp
Normal file
File diff suppressed because it is too large
Load diff
118
backend/src/controllers/WatchController.h
Normal file
118
backend/src/controllers/WatchController.h
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue