diff --git a/.gitignore b/.gitignore index 27aef11..f98ceca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ .DS_Store obj/*.o + +.github/copilot-instructions.md + +memory-bank +old-files \ No newline at end of file diff --git a/API.md b/API.md index 28aba96..19962fc 100644 --- a/API.md +++ b/API.md @@ -115,6 +115,47 @@ Authorization: Bearer } ``` +### Publisher Management + + +#### Disconnect Publisher + +Disconnect an active publishing operation by publisher or player ID. + +``` +DELETE /api/disconnect/{publisher_id} +Authorization: Bearer +``` + +**Required Permissions:** `admin` or `write` + +**Parameters:** +- `publisher_id` (path) - Publisher ID **or** player ID associated with the stream + +**Response:** +- `200 OK` - Publisher disconnected successfully + ```json + { + "status": "success", + "message": "Publisher disconnected successfully" + } + ``` +- `401 Unauthorized` - Invalid or missing API key +- `403 Forbidden` - Insufficient permissions +- `404 Not Found` - Publisher not found or not currently streaming + ```json + { + "status": "error", + "message": "Publisher not found or not currently streaming" + } + ``` + +**Example:** +```bash +curl -X DELETE -H "Authorization: Bearer YOUR_API_KEY" \ + http://hostname:8080/api/disconnect/live_stream +``` + ### Statistics #### Get Publisher Statistics @@ -209,6 +250,7 @@ The API implements rate limiting to prevent abuse. Each endpoint type has its ow - GET /api/stream-ids - POST /api/stream-ids - DELETE /api/stream-ids/{player_id} + - DELETE /api/disconnect/{player_id} - **Statistics** (`stats`): 300 requests per minute per IP (configurable via `rate_limit_stats`) - GET /stats/{player_id} - **Configuration** (`config`): 20 requests per minute per IP (configurable via `rate_limit_config`) @@ -319,6 +361,10 @@ curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \ # Get statistics curl -H "Authorization: Bearer YOUR_API_KEY" \ http://hostname:8080/stats/live_stream + +# Disconnect publisher +curl -X DELETE -H "Authorization: Bearer YOUR_API_KEY" \ + http://hostname:8080/api/disconnect/live_stream ``` ### Python @@ -342,6 +388,9 @@ data = { "description": "Main studio feed" } response = requests.post(f"{BASE_URL}/api/stream-ids", json=data, headers=headers) + +# Disconnect publisher +response = requests.delete(f"{BASE_URL}/api/disconnect/live_stream", headers=headers) ``` ### JavaScript/Fetch @@ -374,6 +423,16 @@ fetch(`${BASE_URL}/api/stream-ids`, { }) .then(response => response.json()) .then(data => console.log(data)); + +// Disconnect publisher +fetch(`${BASE_URL}/api/disconnect/live_stream`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${API_KEY}` + } +}) +.then(response => response.json()) +.then(data => console.log(data)); ``` ## Security Considerations diff --git a/Dockerfile b/Dockerfile index 779b9f5..d3a4a2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV LD_LIBRARY_PATH=/lib:/usr/lib:/usr/local/lib64 # Install runtime dependencies RUN apk update \ - && apk add --no-cache openssl libstdc++ supervisor coreutils procps net-tools sqlite sqlite-dev \ + && apk add --no-cache openssl libstdc++ supervisor coreutils procps net-tools sqlite sqlite-dev spdlog \ && rm -rf /var/cache/apk/* # Copy binaries from the builder stage diff --git a/slscore/SLSApiServer.cpp b/slscore/SLSApiServer.cpp index b93c7dc..f7e7607 100644 --- a/slscore/SLSApiServer.cpp +++ b/slscore/SLSApiServer.cpp @@ -176,6 +176,11 @@ void CSLSApiServer::setupEndpoints() { m_server.Post("/api/keys", [this](const httplib::Request& req, httplib::Response& res) { handleApiKeys(req, res); }); + + // Publisher disconnect endpoint + m_server.Delete(R"(/api/disconnect/(.+))", [this](const httplib::Request& req, httplib::Response& res) { + handleDisconnectPublisher(req, res); + }); } void CSLSApiServer::handleHealth(const httplib::Request& req, httplib::Response& res) { @@ -440,4 +445,66 @@ void CSLSApiServer::handleApiKeys(const httplib::Request& req, httplib::Response error["message"] = "Failed to create API key"; res.set_content(error.dump(), "application/json"); } +} + +void CSLSApiServer::handleDisconnectPublisher(const httplib::Request& req, httplib::Response& res) { + setCorsHeaders(res); + + // Rate limiting + if (!checkRateLimit(req.remote_addr, "api")) { + res.status = 429; + json error; + error["status"] = "error"; + error["message"] = "Rate limit exceeded"; + res.set_content(error.dump(), "application/json"); + return; + } + + // Authentication with admin or write permissions check + std::string permissions; + if (!authenticateRequest(req, res, permissions)) { + return; + } + + if (permissions != "admin" && permissions != "write") { + res.status = 403; + json error; + error["status"] = "error"; + error["message"] = "Admin or write permissions required"; + res.set_content(error.dump(), "application/json"); + CSLSDatabase::getInstance().logAccess(req.get_header_value("Authorization").substr(7), + req.path, req.method, req.remote_addr, 403); + return; + } + + std::string publisher_id = req.matches[1]; + + if (!m_sls_manager) { + res.status = 500; + json error; + error["status"] = "error"; + error["message"] = "SLS manager not available"; + res.set_content(error.dump(), "application/json"); + return; + } + + // Attempt to disconnect the publisher + if (m_sls_manager->disconnect_publisher(publisher_id)) { + json response; + response["status"] = "success"; + response["message"] = "Publisher disconnected successfully"; + res.set_content(response.dump(), "application/json"); + + CSLSDatabase::getInstance().logAccess(req.get_header_value("Authorization").substr(7), + req.path, req.method, req.remote_addr, 200); + } else { + res.status = 404; + json error; + error["status"] = "error"; + error["message"] = "Publisher not found or not currently streaming"; + res.set_content(error.dump(), "application/json"); + + CSLSDatabase::getInstance().logAccess(req.get_header_value("Authorization").substr(7), + req.path, req.method, req.remote_addr, 404); + } } \ No newline at end of file diff --git a/slscore/SLSApiServer.hpp b/slscore/SLSApiServer.hpp index 0f0cd3c..ccce345 100644 --- a/slscore/SLSApiServer.hpp +++ b/slscore/SLSApiServer.hpp @@ -86,6 +86,7 @@ class CSLSApiServer { void handleStats(const httplib::Request& req, httplib::Response& res); void handleConfig(const httplib::Request& req, httplib::Response& res); void handleApiKeys(const httplib::Request& req, httplib::Response& res); + void handleDisconnectPublisher(const httplib::Request& req, httplib::Response& res); }; #endif // _SLS_API_SERVER_HPP_ \ No newline at end of file diff --git a/slscore/SLSManager.cpp b/slscore/SLSManager.cpp index 72a38df..081b5bb 100644 --- a/slscore/SLSManager.cpp +++ b/slscore/SLSManager.cpp @@ -61,8 +61,8 @@ CSLSManager::CSLSManager() m_map_data = NULL; m_map_publisher = NULL; m_map_puller = NULL; - m_map_pusher = NULL; + m_map_pusher = NULL; } CSLSManager::~CSLSManager() @@ -196,6 +196,11 @@ int CSLSManager::start() } char* CSLSManager::find_publisher_by_player_key(char *player_key) { + if (player_key == NULL || strlen(player_key) == 0) { + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::find_publisher_by_player_key, empty player key provided", this); + return NULL; + } + // First check stream ID database std::string publisher_id = CSLSDatabase::getInstance().getPublisherFromPlayer(player_key); if (!publisher_id.empty()) { @@ -205,10 +210,10 @@ char* CSLSManager::find_publisher_by_player_key(char *player_key) { return mapped_publisher; } - + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::find_publisher_by_player_key, player key '%s' not found in database", this, player_key); - + // If not found in database, check if it's a direct publisher key CSLSRole* role = nullptr; for (int i = 0; i < m_server_count; i++) { @@ -217,13 +222,13 @@ char* CSLSManager::find_publisher_by_player_key(char *player_key) { break; } } - + if (role != NULL) { sls_log(SLS_LOG_INFO, "[%p]CSLSManager::find_publisher_by_player_key, player key '%s' is a publisher key", this, player_key); return player_key; } - + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::find_publisher_by_player_key, no publisher found for player key '%s'", this, player_key); return NULL; @@ -288,6 +293,45 @@ json CSLSManager::generate_json_for_publisher(std::string playerKey, int clear, return ret; } +bool CSLSManager::disconnect_publisher(const std::string& key) { + if (key.empty()) { + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::disconnect_publisher, empty key provided", this); + return false; + } + + char* mapped_publisher = find_publisher_by_player_key(const_cast(key.c_str())); + if (mapped_publisher == NULL) { + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::disconnect_publisher, unable to resolve publisher for key: %s", this, key.c_str()); + return false; + } + + std::string publisher_key(mapped_publisher); + + // Search for the publisher in all server instances using resolved publisher key + CSLSRole *role = nullptr; + for (int i = 0; i < m_server_count; i++) { + CSLSMapPublisher *publisher_map = &m_map_publisher[i]; + role = publisher_map->get_publisher(publisher_key.c_str()); + if (role != nullptr) { + break; + } + } + if (role == nullptr) { + sls_log(SLS_LOG_WARNING, "[%p]CSLSManager::disconnect_publisher, publisher not found for key: %s (resolved publisher: %s)", + this, key.c_str(), publisher_key.c_str()); + return false; + } + + // Disconnect the publisher + sls_log(SLS_LOG_INFO, "[%p]CSLSManager::disconnect_publisher, disconnecting publisher: %s (requested key: %s)", + this, publisher_key.c_str(), key.c_str()); + // Call on_close to notify any HTTP callbacks + role->on_close(); + // Mark the role as invalid to trigger cleanup in the next cycle + role->invalid_srt(); + return true; +} + json CSLSManager::create_legacy_json_stats_for_publisher(CSLSRole *role, int clear) { json ret = json::object(); SRT_TRACEBSTATS stats; diff --git a/slscore/SLSManager.hpp b/slscore/SLSManager.hpp index d720084..3faa5f1 100644 --- a/slscore/SLSManager.hpp +++ b/slscore/SLSManager.hpp @@ -97,10 +97,10 @@ public : bool is_single_thread(); int check_invalid(); - json generate_json_for_publisher(std::string publisherName, int clear, bool legacy = false); + json generate_json_for_publisher(std::string playerKey, int clear, bool legacy = false); json create_legacy_json_stats_for_publisher(CSLSRole *role, int clear); json create_json_stats_for_publisher(CSLSRole *role, int clear); - char* find_publisher_by_player_key(char *player_key); + bool disconnect_publisher(const std::string& key); void get_stat_info(std::string &info); static int stat_client_callback(void *p, HTTP_CALLBACK_TYPE type, void *v, void* context); @@ -118,6 +118,8 @@ public : CSLSRoleList * m_list_role; CSLSGroup * m_single_group; + + char* find_publisher_by_player_key(char *player_key); }; diff --git a/slscore/conf.cpp b/slscore/conf.cpp index 980d180..d88e041 100644 --- a/slscore/conf.cpp +++ b/slscore/conf.cpp @@ -166,12 +166,25 @@ vector sls_conf_string_split(const string& str, const string& delim) string& trim(string &s) { - if (s.empty()) - { + if (s.empty()) { + return s; + } + + const char *whitespace = " \t\r\n"; + + size_t start = s.find_first_not_of(whitespace); + if (start == string::npos) { + s.clear(); return s; } - s.erase(0,s.find_first_not_of(" ")); - s.erase(s.find_last_not_of(" ") + 1); + if (start > 0) { + s.erase(0, start); + } + + size_t end = s.find_last_not_of(whitespace); + if (end != string::npos && end + 1 < s.size()) { + s.erase(end + 1); + } return s; } @@ -306,7 +319,7 @@ int sls_conf_parse_block(ifstream& ifs, int& line, sls_conf_base_t * b, bool& ch } } else if (line_end_flag == "}" ) { if (str_line != line_end_flag) { - sls_log(SLS_LOG_ERROR, "line:%d=‘%s’, end indicator ‘}’ with more info.", str_line.c_str(), line); + sls_log(SLS_LOG_ERROR, "line:%d=‘%s’, end indicator ‘}’ with more info.", line, str_line.c_str()); ret = SLS_ERROR; break; } @@ -316,7 +329,7 @@ int sls_conf_parse_block(ifstream& ifs, int& line, sls_conf_base_t * b, bool& ch break; } else { - sls_log(SLS_LOG_ERROR, "line:%d='%s', invalid end flag, except ';', '{', '}',", str_line.c_str(), line); + sls_log(SLS_LOG_ERROR, "line:%d='%s', invalid end flag, except ';', '{', '}',", line, str_line.c_str()); ret = SLS_ERROR; break; }