fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 8m29s

This commit is contained in:
doomtube 2026-01-10 19:25:42 -05:00
parent 9876641ff6
commit 9e985d05f1
11 changed files with 1011 additions and 70 deletions

View file

@ -22,6 +22,147 @@
using namespace drogon::orm;
namespace {
// Generate waveform data using ffmpeg (safe version)
// Returns path to waveform JSON file, or empty string on failure
std::string generateWaveform(const std::string& audioPath, int64_t audioId) {
if (!isPathSafe(audioPath, "/app/uploads")) {
LOG_ERROR << "Unsafe audio path rejected for waveform: " << audioPath;
return "";
}
// Ensure waveforms directory exists
const std::string waveformDir = "/app/uploads/audio/waveforms";
if (!ensureDirectoryExists(waveformDir)) {
LOG_ERROR << "Failed to create waveforms directory";
return "";
}
std::string waveformPath = waveformDir + "/" + std::to_string(audioId) + ".json";
// Use ffmpeg to extract raw audio samples (mono, downsampled to 8000Hz)
// Then we'll compute peaks from the samples
std::vector<std::string> args = {
"/usr/bin/ffmpeg", "-i", audioPath,
"-ac", "1", // mono
"-ar", "8000", // 8kHz sample rate
"-f", "f32le", // 32-bit float, little endian
"-acodec", "pcm_f32le",
"-v", "error",
"-y", // overwrite
"pipe:1" // output to stdout
};
// Execute ffmpeg and capture raw samples
int pipefd[2];
if (pipe(pipefd) == -1) {
LOG_ERROR << "Failed to create pipe for waveform generation";
return "";
}
pid_t pid = fork();
if (pid == -1) {
LOG_ERROR << "Failed to fork for waveform generation";
close(pipefd[0]);
close(pipefd[1]);
return "";
}
if (pid == 0) {
// Child process
close(pipefd[0]); // Close read end
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
// Redirect stderr to /dev/null
int devNull = open("/dev/null", O_WRONLY);
if (devNull >= 0) {
dup2(devNull, STDERR_FILENO);
close(devNull);
}
std::vector<char*> argv;
for (auto& arg : args) {
argv.push_back(const_cast<char*>(arg.c_str()));
}
argv.push_back(nullptr);
execv("/usr/bin/ffmpeg", argv.data());
_exit(1);
}
// Parent process
close(pipefd[1]); // Close write end
// Read raw samples (limit to reasonable amount - ~30 seconds of audio at 8kHz = 240KB)
const size_t maxSamples = 8000 * 30; // 30 seconds worth
std::vector<float> samples;
samples.reserve(maxSamples);
float sample;
ssize_t bytesRead;
while (samples.size() < maxSamples &&
(bytesRead = read(pipefd[0], &sample, sizeof(float))) == sizeof(float)) {
samples.push_back(std::abs(sample)); // Store absolute value
}
close(pipefd[0]);
// Wait for child process
int status;
waitpid(pid, &status, 0);
if (samples.empty()) {
LOG_WARN << "No samples extracted for waveform generation";
return "";
}
// Compute 200 peaks from samples
const int numPeaks = 200;
std::vector<float> peaks(numPeaks, 0.0f);
size_t samplesPerPeak = samples.size() / numPeaks;
if (samplesPerPeak == 0) samplesPerPeak = 1;
for (int i = 0; i < numPeaks; i++) {
size_t start = i * samplesPerPeak;
size_t end = std::min(start + samplesPerPeak, samples.size());
float maxVal = 0.0f;
for (size_t j = start; j < end; j++) {
if (samples[j] > maxVal) maxVal = samples[j];
}
peaks[i] = maxVal;
}
// Normalize peaks to 0-1 range
float maxPeak = *std::max_element(peaks.begin(), peaks.end());
if (maxPeak > 0.0f) {
for (auto& p : peaks) {
p /= maxPeak;
}
}
// Write JSON file
try {
std::ofstream ofs(waveformPath);
if (!ofs) {
LOG_ERROR << "Failed to create waveform file: " << waveformPath;
return "";
}
ofs << "{\"peaks\":[";
for (size_t i = 0; i < peaks.size(); i++) {
if (i > 0) ofs << ",";
ofs << std::fixed << std::setprecision(3) << peaks[i];
}
ofs << "]}";
ofs.close();
LOG_INFO << "Waveform generated for audio " << audioId << ": " << peaks.size() << " peaks";
return "/uploads/audio/waveforms/" + std::to_string(audioId) + ".json";
} catch (const std::exception& e) {
LOG_ERROR << "Exception writing waveform file: " << e.what();
return "";
}
}
// Get audio duration in seconds using ffprobe (safe version)
int getAudioDuration(const std::string& audioPath) {
if (!isPathSafe(audioPath, "/app/uploads")) {
@ -87,6 +228,7 @@ namespace {
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
audio["filePath"] = row["file_path"].as<std::string>();
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
audio["waveformPath"] = row["waveform_path"].isNull() ? "" : row["waveform_path"].as<std::string>();
audio["durationSeconds"] = row["duration_seconds"].as<int>();
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
@ -120,16 +262,32 @@ namespace {
return;
}
// Generate waveform data
std::string waveformPath = generateWaveform(audioFullPath, audioId);
auto dbClient = app().getDbClient();
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4"
<< duration << bitrate << format << audioId
>> [audioId](const Result&) {
LOG_INFO << "Audio " << audioId << " metadata processed successfully";
}
>> [audioId](const DrogonDbException& e) {
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
markAudioFailed(audioId);
};
if (!waveformPath.empty()) {
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, waveform_path = $4, status = 'ready' WHERE id = $5"
<< duration << bitrate << format << waveformPath << audioId
>> [audioId](const Result&) {
LOG_INFO << "Audio " << audioId << " metadata and waveform processed successfully";
}
>> [audioId](const DrogonDbException& e) {
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
markAudioFailed(audioId);
};
} else {
// Still mark as ready even without waveform
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4"
<< duration << bitrate << format << audioId
>> [audioId](const Result&) {
LOG_INFO << "Audio " << audioId << " metadata processed (no waveform)";
}
>> [audioId](const DrogonDbException& e) {
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
markAudioFailed(audioId);
};
}
} catch (const std::exception& e) {
LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what();
markAudioFailed(audioId);
@ -143,7 +301,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
auto pagination = parsePagination(req, 20, 50);
auto dbClient = app().getDbClient();
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
@ -170,7 +328,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
void AudioController::getLatestAudio(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) {
auto dbClient = app().getDbClient();
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
"u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name "
@ -280,7 +438,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
return;
}
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
"FROM audio_files a "
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
@ -339,7 +497,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
int64_t realmId = realmResult[0]["id"].as<int64_t>();
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
"FROM audio_files a "
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
@ -422,6 +580,168 @@ void AudioController::incrementPlayCount(const HttpRequestPtr &req,
});
}
void AudioController::getWaveform(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId) {
int64_t id;
try {
id = std::stoll(audioId);
} catch (...) {
callback(jsonError("Invalid audio ID", k400BadRequest));
return;
}
auto dbClient = app().getDbClient();
*dbClient << "SELECT waveform_path FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'"
<< id
>> [callback, id](const Result& r) {
if (r.empty()) {
callback(jsonError("Audio not found", k404NotFound));
return;
}
std::string waveformPath = r[0]["waveform_path"].isNull() ? "" : r[0]["waveform_path"].as<std::string>();
if (waveformPath.empty()) {
// Return empty peaks if no waveform available
Json::Value resp;
resp["peaks"] = Json::arrayValue;
callback(jsonResp(resp));
return;
}
std::string fullPath = "/app" + waveformPath;
// Validate path is within allowed directory
if (!isPathSafe(fullPath, "/app/uploads/audio/waveforms")) {
LOG_WARN << "Blocked access to waveform file outside uploads: " << fullPath;
Json::Value resp;
resp["peaks"] = Json::arrayValue;
callback(jsonResp(resp));
return;
}
// Read and return the JSON file
try {
std::ifstream ifs(fullPath);
if (!ifs) {
Json::Value resp;
resp["peaks"] = Json::arrayValue;
callback(jsonResp(resp));
return;
}
std::stringstream buffer;
buffer << ifs.rdbuf();
Json::Value resp;
Json::CharReaderBuilder builder;
std::string errors;
std::istringstream stream(buffer.str());
if (Json::parseFromStream(builder, stream, &resp, &errors)) {
// Add cache headers for waveforms (they don't change)
auto response = jsonResp(resp);
response->addHeader("Cache-Control", "public, max-age=31536000"); // 1 year
callback(response);
} else {
Json::Value errResp;
errResp["peaks"] = Json::arrayValue;
callback(jsonResp(errResp));
}
} catch (const std::exception& e) {
LOG_ERROR << "Error reading waveform file: " << e.what();
Json::Value resp;
resp["peaks"] = Json::arrayValue;
callback(jsonResp(resp));
}
}
>> DB_ERROR(callback, "get waveform");
}
void AudioController::downloadAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId) {
// Require authentication for downloads
UserInfo user = getUserFromRequest(req);
if (user.id == 0) {
callback(jsonError("Please log in to download audio", k401Unauthorized));
return;
}
int64_t id;
try {
id = std::stoll(audioId);
} catch (...) {
callback(jsonError("Invalid audio ID", k400BadRequest));
return;
}
auto dbClient = app().getDbClient();
// Get audio info - only allow download of public, ready audio
*dbClient << "SELECT title, file_path, format FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'"
<< id
>> [callback, id](const Result& r) {
if (r.empty()) {
callback(jsonError("Audio 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 format = r[0]["format"].isNull() ? "mp3" : r[0]["format"].as<std::string>();
std::string fullPath = "/app" + filePath;
// Validate path is within allowed directory
if (!isPathSafe(fullPath, "/app/uploads/audio")) {
LOG_WARN << "Blocked access to file outside uploads: " << fullPath;
callback(jsonError("Audio file not found", k404NotFound));
return;
}
// Check file exists
if (!std::filesystem::exists(fullPath)) {
LOG_ERROR << "Audio file not found: " << fullPath;
callback(jsonError("Audio 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 = "audio";
if (safeTitle.length() > 100) safeTitle = safeTitle.substr(0, 100);
// Determine content type based on format
std::string contentType = "audio/mpeg"; // default for mp3
std::string extension = "mp3";
if (format == "wav") {
contentType = "audio/wav";
extension = "wav";
} else if (format == "flac") {
contentType = "audio/flac";
extension = "flac";
} else if (format == "ogg") {
contentType = "audio/ogg";
extension = "ogg";
} else if (format == "aac" || format == "m4a") {
contentType = "audio/mp4";
extension = "m4a";
}
// Use Drogon's file response for efficient streaming
auto resp = HttpResponse::newFileResponse(fullPath, "", CT_CUSTOM);
resp->addHeader("Content-Type", contentType);
resp->addHeader("Content-Disposition", "attachment; filename=\"" + safeTitle + "." + extension + "\"");
callback(resp);
}
>> DB_ERROR(callback, "download audio");
}
void AudioController::getMyAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req);

View file

@ -14,6 +14,8 @@ public:
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);
ADD_METHOD_TO(AudioController::getWaveform, "/api/audio/{1}/waveform", Get);
ADD_METHOD_TO(AudioController::downloadAudio, "/api/audio/{1}/download", Get);
// Authenticated endpoints
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
@ -47,6 +49,14 @@ public:
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void getWaveform(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void downloadAudio(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);

View file

@ -103,15 +103,15 @@ namespace {
}
}
// Helper to create a new viewer token
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey) {
// Helper to create a new viewer token (uses realm-specific cookies to support multi-stream viewing)
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey, const std::string& realmId) {
auto bytes = drogon::utils::genRandomString(32);
std::string token = drogon::utils::base64Encode(
(const unsigned char*)bytes.data(), bytes.length()
);
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300,
[callback, token](bool stored) {
[callback, token, realmId](bool stored) {
if (!stored) {
auto resp = HttpResponse::newHttpResponse();
resp->setStatusCode(k500InternalServerError);
@ -121,7 +121,8 @@ namespace {
auto resp = HttpResponse::newHttpResponse();
Cookie cookie("viewer_token", token);
// Use realm-specific cookie name to support multiple streams simultaneously
Cookie cookie("viewer_token_" + realmId, token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(false);
@ -393,13 +394,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
const std::string &realmId) {
int64_t id = std::stoll(realmId);
// Check for existing viewer token to avoid creating duplicates on page refresh
auto existingToken = req->getCookie("viewer_token");
// Check for existing viewer token for THIS specific realm (supports multi-stream viewing)
auto existingToken = req->getCookie("viewer_token_" + realmId);
auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
<< id
>> [callback, existingToken](const Result& r) {
>> [callback, existingToken, realmId](const Result& r) {
if (r.empty()) {
callback(jsonResp({}, k404NotFound));
return;
@ -407,18 +408,18 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
std::string streamKey = r[0]["stream_key"].as<std::string>();
// If user has existing token, check if it's still valid for this stream
// If user has existing token for this realm, check if it's still valid
if (!existingToken.empty()) {
RedisHelper::getKeyAsync("viewer_token:" + existingToken,
[callback, existingToken, streamKey](const std::string& storedKey) {
[callback, existingToken, streamKey, realmId](const std::string& storedKey) {
if (storedKey == streamKey) {
// Token is still valid for this stream - just refresh TTL and return it
RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300,
[callback, existingToken](bool stored) {
[callback, existingToken, realmId](bool stored) {
auto resp = HttpResponse::newHttpResponse();
// Refresh cookie
Cookie cookie("viewer_token", existingToken);
// Refresh realm-specific cookie
Cookie cookie("viewer_token_" + realmId, existingToken);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(false);
@ -441,13 +442,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
if (!storedKey.empty()) {
RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){});
}
createNewViewerToken(callback, streamKey);
createNewViewerToken(callback, streamKey, realmId);
}
}
);
} else {
// No existing token, create new one
createNewViewerToken(callback, streamKey);
// No existing token for this realm, create new one
createNewViewerToken(callback, streamKey, realmId);
}
}
>> [callback](const DrogonDbException& e) {
@ -459,15 +460,15 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) {
// Check for viewer token
auto token = req->getCookie("viewer_token");
// Check for realm-specific viewer token (supports multi-stream viewing)
auto token = req->getCookie("viewer_token_" + realmId);
if (token.empty()) {
callback(jsonError("No viewer token", k403Forbidden));
callback(jsonError("No viewer token for this realm", k403Forbidden));
return;
}
int64_t id = std::stoll(realmId);
// First get the stream key for this realm
auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
@ -477,9 +478,9 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
callback(jsonError("Realm not found", k404NotFound));
return;
}
std::string streamKey = r[0]["stream_key"].as<std::string>();
// Verify the token is valid for this stream
RedisHelper::getKeyAsync("viewer_token:" + token,
[callback, streamKey](const std::string& storedStreamKey) {
@ -487,7 +488,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
callback(jsonError("Invalid token for this realm", k403Forbidden));
return;
}
// Token is valid, return the stream key
Json::Value resp;
resp["success"] = true;

View file

@ -2212,44 +2212,50 @@ void UserController::getTreasury(const HttpRequestPtr &,
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0;
estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.0;
// Calculate next Sunday (next distribution) in UTC
// Calculate next Sunday (next distribution) in EST (UTC-5)
// EST offset: -5 hours = -18000 seconds
const int EST_OFFSET_SECONDS = -5 * 3600;
std::time_t now = std::time(nullptr);
std::tm nowUtc;
std::time_t nowEst = now + EST_OFFSET_SECONDS;
std::tm nowEstTm;
#ifdef _WIN32
gmtime_s(&nowUtc, &now);
gmtime_s(&nowEstTm, &nowEst);
#else
gmtime_r(&now, &nowUtc);
gmtime_r(&nowEst, &nowEstTm);
#endif
// Days until Sunday (0 = Sunday)
// If today is Sunday, next distribution is next Sunday (7 days)
int daysUntilSunday = (7 - nowUtc.tm_wday) % 7;
// Days until Sunday (0 = Sunday) based on EST
int daysUntilSunday = (7 - nowEstTm.tm_wday) % 7;
if (daysUntilSunday == 0) daysUntilSunday = 7;
// Calculate next Sunday at midnight UTC
std::tm nextSundayTm = nowUtc;
// Calculate next Sunday at midnight EST (which is 5 AM UTC)
std::tm nextSundayTm = nowEstTm;
nextSundayTm.tm_mday += daysUntilSunday;
nextSundayTm.tm_hour = 0;
nextSundayTm.tm_min = 0;
nextSundayTm.tm_sec = 0;
// Normalize the tm struct (handles month overflow etc)
// Note: timegm is the UTC version of mktime
// Convert to time_t (treating as UTC since we applied offset)
#ifdef _WIN32
std::time_t nextSunday = _mkgmtime(&nextSundayTm);
std::time_t nextSundayEst = _mkgmtime(&nextSundayTm);
#else
std::time_t nextSunday = timegm(&nextSundayTm);
std::time_t nextSundayEst = timegm(&nextSundayTm);
#endif
// Convert back to UTC for the response
std::time_t nextSundayUtc = nextSundayEst - EST_OFFSET_SECONDS;
// Format as ISO 8601 UTC
char nextDistBuffer[32];
std::tm nextSundayUtc;
std::tm nextSundayUtcTm;
#ifdef _WIN32
gmtime_s(&nextSundayUtc, &nextSunday);
gmtime_s(&nextSundayUtcTm, &nextSundayUtc);
#else
gmtime_r(&nextSunday, &nextSundayUtc);
gmtime_r(&nextSundayUtc, &nextSundayUtcTm);
#endif
std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtc);
std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtcTm);
Json::Value resp;
resp["success"] = true;