/
/
/
1#include "bag_recorder_backend/crow_server.hpp"
2#include <nlohmann/json.hpp>
3#include <fstream>
4#include <filesystem>
5#include <cstdlib>
6#include <sstream>
7#include <chrono>
8#include <random>
9
10namespace bag_recorder_backend
11{
12
13CrowServer::CrowServer(std::shared_ptr<BagRecorderNode> recorder_node, int port)
14: recorder_node_(recorder_node),
15 websocket_manager_(std::make_unique<WebSocketManager>()),
16 server_running_(false),
17 should_stop_(false),
18 port_(port),
19 ssl_enabled_(false),
20 cors_enabled_(true),
21 static_files_path_("./frontend/dist")
22{
23 setup_routes();
24}
25
26CrowServer::~CrowServer()
27{
28 stop();
29}
30
31void CrowServer::start()
32{
33 if (server_running_) {
34 return;
35 }
36
37 server_running_ = true;
38 should_stop_ = false;
39
40 // Start status broadcaster
41 start_status_broadcaster();
42
43 // Configure and start server
44 app_.port(port_);
45
46 if (ssl_enabled_ && !cert_file_.empty() && !key_file_.empty()) {
47 app_.ssl_file(cert_file_, key_file_);
48 }
49
50 app_.multithreaded();
51 app_.run();
52}
53
54void CrowServer::stop()
55{
56 if (!server_running_) {
57 return;
58 }
59
60 should_stop_ = true;
61 server_running_ = false;
62
63 stop_status_broadcaster();
64 app_.stop();
65}
66
67bool CrowServer::is_running() const
68{
69 return server_running_;
70}
71
72void CrowServer::set_port(int port)
73{
74 port_ = port;
75}
76
77void CrowServer::set_ssl_config(const std::string & cert_file, const std::string & key_file)
78{
79 cert_file_ = cert_file;
80 key_file_ = key_file;
81 ssl_enabled_ = true;
82}
83
84void CrowServer::enable_cors(bool enable)
85{
86 cors_enabled_ = enable;
87}
88
89void CrowServer::setup_routes()
90{
91 // API Routes
92 CROW_ROUTE(app_, "/api/status").methods("GET"_method)
93 ([this]() { return handle_get_status(); });
94
95 CROW_ROUTE(app_, "/api/topics").methods("GET"_method)
96 ([this]() { return handle_get_topics(); });
97
98 CROW_ROUTE(app_, "/api/recording/start").methods("POST"_method)
99 ([this](const crow::request & req) { return handle_start_recording(req); });
100
101 CROW_ROUTE(app_, "/api/recording/stop").methods("POST"_method)
102 ([this]() { return handle_stop_recording(); });
103
104 CROW_ROUTE(app_, "/api/config").methods("GET"_method)
105 ([this]() { return handle_get_config(); });
106
107 CROW_ROUTE(app_, "/api/config").methods("POST"_method)
108 ([this](const crow::request & req) { return handle_set_config(req); });
109
110 CROW_ROUTE(app_, "/api/recordings").methods("GET"_method)
111 ([this]() { return handle_get_recordings(); });
112
113 CROW_ROUTE(app_, "/api/recordings/<string>").methods("DELETE"_method)
114 ([this](const std::string & filename) { return handle_delete_recording(filename); });
115
116 CROW_ROUTE(app_, "/api/recordings/<string>/download").methods("GET"_method)
117 ([this](const std::string & filename) { return handle_download_recording(filename); });
118
119 CROW_ROUTE(app_, "/api/recordings/download").methods("POST"_method)
120 ([this](const crow::request & req) { return handle_download_multiple_recordings(req); });
121
122 CROW_ROUTE(app_, "/api/recordings/delete").methods("POST"_method)
123 ([this](const crow::request & req) { return handle_delete_multiple_recordings(req); });
124
125 // WebSocket route
126 CROW_ROUTE(app_, "/ws")
127 .websocket(&app_)
128 .onopen([this](crow::websocket::connection & conn) {
129 handle_websocket_connect(conn);
130 })
131 .onmessage([this](crow::websocket::connection & conn, const std::string & data, bool) {
132 handle_websocket_message(conn, data);
133 })
134 .onclose([this](crow::websocket::connection & conn, const std::string & reason, uint16_t code) {
135 handle_websocket_close(conn, reason);
136 });
137
138 // Static file serving
139 CROW_ROUTE(app_, "/")
140 ([this]() { return serve_static_file("index.html"); });
141
142 CROW_ROUTE(app_, "/<path>")
143 ([this](const std::string & path) { return serve_static_file(path); });
144}
145
146crow::response CrowServer::handle_get_status()
147{
148 auto status = recorder_node_->get_status();
149 auto json_response = recording_status_to_json(status);
150
151 crow::response res(200, json_response.dump());
152 res.set_header("Content-Type", "application/json");
153 return add_cors_headers(std::move(res));
154}
155
156crow::response CrowServer::handle_get_topics()
157{
158 auto topics = recorder_node_->get_available_topics();
159 nlohmann::json json_response;
160 json_response["topics"] = topics;
161
162 crow::response res(200, json_response.dump());
163 res.set_header("Content-Type", "application/json");
164 return add_cors_headers(std::move(res));
165}
166
167crow::response CrowServer::handle_start_recording(const crow::request & req)
168{
169 try {
170 auto json_data = nlohmann::json::parse(req.body);
171 auto config = json_to_recording_config(json_data);
172
173 bool success = recorder_node_->start_recording(config);
174
175 nlohmann::json response;
176 response["success"] = success;
177 response["message"] = success ? "Recording started" : "Failed to start recording";
178
179 crow::response res(success ? 200 : 400, response.dump());
180 res.set_header("Content-Type", "application/json");
181 return add_cors_headers(std::move(res));
182
183 } catch (const std::exception & e) {
184 nlohmann::json response;
185 response["success"] = false;
186 response["message"] = std::string("Error: ") + e.what();
187
188 crow::response res(400, response.dump());
189 res.set_header("Content-Type", "application/json");
190 return add_cors_headers(std::move(res));
191 }
192}
193
194crow::response CrowServer::handle_stop_recording()
195{
196 bool success = recorder_node_->stop_recording();
197
198 nlohmann::json response;
199 response["success"] = success;
200 response["message"] = success ? "Recording stopped" : "Failed to stop recording";
201
202 crow::response res(success ? 200 : 400, response.dump());
203 res.set_header("Content-Type", "application/json");
204 return add_cors_headers(std::move(res));
205}
206
207crow::response CrowServer::handle_get_config()
208{
209 // Return default configuration or current configuration
210 nlohmann::json config;
211 config["output_directory"] = recorder_node_->get_output_directory();
212 config["storage_id"] = "sqlite3";
213 config["serialization_format"] = "cdr";
214 config["max_bagfile_size"] = 0;
215 config["max_bagfile_duration"] = 0.0;
216
217 crow::response res(200, config.dump());
218 res.set_header("Content-Type", "application/json");
219 return add_cors_headers(std::move(res));
220}
221
222crow::response CrowServer::handle_set_config(const crow::request & req)
223{
224 try {
225 auto json_data = nlohmann::json::parse(req.body);
226
227 if (json_data.contains("output_directory")) {
228 recorder_node_->set_output_directory(json_data["output_directory"]);
229 }
230
231 nlohmann::json response;
232 response["success"] = true;
233 response["message"] = "Configuration updated";
234
235 crow::response res(200, response.dump());
236 res.set_header("Content-Type", "application/json");
237 return add_cors_headers(std::move(res));
238
239 } catch (const std::exception & e) {
240 nlohmann::json response;
241 response["success"] = false;
242 response["message"] = std::string("Error: ") + e.what();
243
244 crow::response res(400, response.dump());
245 res.set_header("Content-Type", "application/json");
246 return add_cors_headers(std::move(res));
247 }
248}
249
250crow::response CrowServer::handle_get_recordings()
251{
252 nlohmann::json recordings = nlohmann::json::array();
253
254 // List actual recorded bag files
255 try {
256 std::string output_dir = recorder_node_->get_output_directory();
257
258 if (std::filesystem::exists(output_dir)) {
259 for (const auto & entry : std::filesystem::directory_iterator(output_dir)) {
260 if (entry.is_directory()) {
261 nlohmann::json recording;
262 recording["name"] = entry.path().filename().string();
263 recording["path"] = entry.path().string();
264
265 // Calculate total size of all files in the bag directory
266 uintmax_t total_size = 0;
267 if (std::filesystem::exists(entry) && std::filesystem::is_directory(entry)) {
268 try {
269 for (const auto & file_entry : std::filesystem::recursive_directory_iterator(entry)) {
270 if (file_entry.is_regular_file()) {
271 total_size += std::filesystem::file_size(file_entry);
272 }
273 }
274 } catch (const std::exception & size_error) {
275 // If we can't calculate size, set it to 0
276 total_size = 0;
277 }
278 }
279
280 recording["size"] = total_size;
281 recordings.push_back(recording);
282 }
283 }
284 }
285 } catch (const std::exception & e) {
286 // Log the error - add the error as a recording for debugging
287 nlohmann::json error_recording;
288 error_recording["name"] = "ERROR";
289 error_recording["path"] = e.what();
290 error_recording["size"] = 0;
291 recordings.push_back(error_recording);
292 }
293
294 nlohmann::json response;
295 response["recordings"] = recordings;
296
297 crow::response res(200, response.dump());
298 res.set_header("Content-Type", "application/json");
299 return add_cors_headers(std::move(res));
300}
301
302crow::response CrowServer::handle_delete_recording(const std::string & filename)
303{
304 nlohmann::json response;
305
306 try {
307 std::string output_dir = recorder_node_->get_output_directory();
308 std::filesystem::path recording_path = std::filesystem::path(output_dir) / filename;
309
310 // Basic security check - filename should not contain path traversal
311 if (filename.find("..") != std::string::npos || filename.find("/") != std::string::npos) {
312 response["success"] = false;
313 response["message"] = "Invalid recording name";
314 crow::response res(400, response.dump());
315 res.set_header("Content-Type", "application/json");
316 return add_cors_headers(std::move(res));
317 }
318
319 // Check if the recording directory exists
320 if (!std::filesystem::exists(recording_path) || !std::filesystem::is_directory(recording_path)) {
321 response["success"] = false;
322 response["message"] = "Recording not found";
323 crow::response res(404, response.dump());
324 res.set_header("Content-Type", "application/json");
325 return add_cors_headers(std::move(res));
326 }
327
328 // Don't delete if currently recording
329 if (recorder_node_->get_status().is_recording) {
330 auto current_bag_path = recorder_node_->get_status().current_bag_path;
331 if (!current_bag_path.empty() && current_bag_path.find(filename) != std::string::npos) {
332 response["success"] = false;
333 response["message"] = "Cannot delete recording that is currently in progress";
334 crow::response res(409, response.dump());
335 res.set_header("Content-Type", "application/json");
336 return add_cors_headers(std::move(res));
337 }
338 }
339
340 // Delete the directory and all its contents
341 std::uintmax_t deleted_files = std::filesystem::remove_all(recording_path);
342
343 response["success"] = true;
344 response["message"] = "Recording deleted successfully";
345 response["deleted_files"] = deleted_files;
346
347 crow::response res(200, response.dump());
348 res.set_header("Content-Type", "application/json");
349 return add_cors_headers(std::move(res));
350
351 } catch (const std::exception & e) {
352 response["success"] = false;
353 response["message"] = std::string("Failed to delete recording: ") + e.what();
354
355 crow::response res(500, response.dump());
356 res.set_header("Content-Type", "application/json");
357 return add_cors_headers(std::move(res));
358 }
359}
360
361void CrowServer::handle_websocket_connect(crow::websocket::connection & conn)
362{
363 websocket_manager_->add_connection(&conn);
364}
365
366void CrowServer::handle_websocket_message(crow::websocket::connection & conn, const std::string & data)
367{
368 websocket_manager_->handle_client_message(&conn, data);
369}
370
371void CrowServer::handle_websocket_close(crow::websocket::connection & conn, const std::string & reason)
372{
373 websocket_manager_->remove_connection(&conn);
374}
375
376crow::response CrowServer::serve_static_file(const std::string & path)
377{
378 std::string full_path = static_files_path_ + "/" + path;
379
380 if (!std::filesystem::exists(full_path)) {
381 full_path = static_files_path_ + "/index.html"; // Fallback for SPA
382 }
383
384 std::ifstream file(full_path, std::ios::binary);
385 if (!file) {
386 return crow::response(404, "File not found");
387 }
388
389 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
390
391 std::string extension = std::filesystem::path(path).extension();
392 std::string mime_type = get_mime_type(extension);
393
394 crow::response res(200, content);
395 res.set_header("Content-Type", mime_type);
396 return add_cors_headers(std::move(res));
397}
398
399std::string CrowServer::get_mime_type(const std::string & extension)
400{
401 static const std::unordered_map<std::string, std::string> mime_types = {
402 {".html", "text/html"},
403 {".css", "text/css"},
404 {".js", "application/javascript"},
405 {".json", "application/json"},
406 {".png", "image/png"},
407 {".jpg", "image/jpeg"},
408 {".jpeg", "image/jpeg"},
409 {".gif", "image/gif"},
410 {".svg", "image/svg+xml"},
411 {".woff", "font/woff"},
412 {".woff2", "font/woff2"},
413 {".ttf", "font/ttf"},
414 {".eot", "application/vnd.ms-fontobject"}
415 };
416
417 auto it = mime_types.find(extension);
418 return (it != mime_types.end()) ? it->second : "application/octet-stream";
419}
420
421crow::response CrowServer::add_cors_headers(crow::response && res)
422{
423 if (cors_enabled_) {
424 res.set_header("Access-Control-Allow-Origin", "*");
425 res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
426 res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
427 }
428 return std::move(res);
429}
430
431void CrowServer::start_status_broadcaster()
432{
433 status_broadcast_thread_ = std::thread([this]() {
434 while (!should_stop_) {
435 broadcast_status();
436 std::this_thread::sleep_for(std::chrono::milliseconds(STATUS_BROADCAST_INTERVAL_MS));
437 }
438 });
439}
440
441void CrowServer::stop_status_broadcaster()
442{
443 if (status_broadcast_thread_.joinable()) {
444 status_broadcast_thread_.join();
445 }
446}
447
448void CrowServer::broadcast_status()
449{
450 auto status = recorder_node_->get_status();
451 auto json_status = recording_status_to_json(status);
452 websocket_manager_->broadcast_status_update(json_status.dump());
453}
454
455// Helper functions
456nlohmann::json recording_status_to_json(const RecordingStatus & status)
457{
458 nlohmann::json json;
459 json["is_recording"] = status.is_recording;
460 json["current_bag_path"] = status.current_bag_path;
461 json["recorded_messages"] = status.recorded_messages;
462 json["recording_duration"] = status.recording_duration;
463 json["active_topics"] = status.active_topics;
464 return json;
465}
466
467nlohmann::json recording_config_to_json(const RecordingConfig & config)
468{
469 nlohmann::json json;
470 json["output_path"] = config.output_path;
471 json["topics"] = config.topics;
472 json["storage_id"] = config.storage_id;
473 json["serialization_format"] = config.serialization_format;
474 json["max_bagfile_size"] = config.max_bagfile_size;
475 json["max_bagfile_duration"] = config.max_bagfile_duration;
476 return json;
477}
478
479std::string CrowServer::create_compressed_archive(const std::vector<std::string> & bag_paths, const std::string & archive_name)
480{
481 // Generate unique temporary file path
482 auto now = std::chrono::high_resolution_clock::now();
483 auto timestamp = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
484 std::string temp_archive_path = "/tmp/" + archive_name + "_" + std::to_string(timestamp) + ".tar.gz";
485
486 // Build tar command
487 std::stringstream tar_cmd;
488 tar_cmd << "tar -czf \"" << temp_archive_path << "\"";
489
490 // Add each bag directory to the archive
491 for (const auto & bag_path : bag_paths) {
492 tar_cmd << " -C \"" << std::filesystem::path(bag_path).parent_path().string() << "\" \""
493 << std::filesystem::path(bag_path).filename().string() << "\"";
494 }
495
496 // Execute tar command
497 int result = std::system(tar_cmd.str().c_str());
498 if (result != 0) {
499 // Clean up on failure
500 std::filesystem::remove(temp_archive_path);
501 throw std::runtime_error("Failed to create compressed archive");
502 }
503
504 return temp_archive_path;
505}
506
507crow::response CrowServer::handle_download_recording(const std::string & filename)
508{
509 nlohmann::json response;
510
511 try {
512 std::string output_dir = recorder_node_->get_output_directory();
513 std::filesystem::path recording_path = std::filesystem::path(output_dir) / filename;
514
515 // Security check - filename should not contain path traversal
516 if (filename.find("..") != std::string::npos || filename.find("/") != std::string::npos) {
517 response["success"] = false;
518 response["message"] = "Invalid recording name";
519 crow::response res(400, response.dump());
520 res.set_header("Content-Type", "application/json");
521 return add_cors_headers(std::move(res));
522 }
523
524 // Check if the recording directory exists
525 if (!std::filesystem::exists(recording_path) || !std::filesystem::is_directory(recording_path)) {
526 response["success"] = false;
527 response["message"] = "Recording not found";
528 crow::response res(404, response.dump());
529 res.set_header("Content-Type", "application/json");
530 return add_cors_headers(std::move(res));
531 }
532
533 // Don't allow download if currently recording
534 if (recorder_node_->get_status().is_recording) {
535 auto current_bag_path = recorder_node_->get_status().current_bag_path;
536 if (!current_bag_path.empty() && current_bag_path.find(filename) != std::string::npos) {
537 response["success"] = false;
538 response["message"] = "Cannot download recording that is currently in progress";
539 crow::response res(409, response.dump());
540 res.set_header("Content-Type", "application/json");
541 return add_cors_headers(std::move(res));
542 }
543 }
544
545 // Create compressed archive
546 std::vector<std::string> bag_paths = {recording_path.string()};
547 std::string temp_archive_path = create_compressed_archive(bag_paths, filename);
548
549 // Read compressed file
550 std::ifstream file(temp_archive_path, std::ios::binary);
551 if (!file) {
552 std::filesystem::remove(temp_archive_path);
553 response["success"] = false;
554 response["message"] = "Failed to read compressed file";
555 crow::response res(500, response.dump());
556 res.set_header("Content-Type", "application/json");
557 return add_cors_headers(std::move(res));
558 }
559
560 // Read file content
561 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
562 file.close();
563
564 // Clean up temporary file
565 std::filesystem::remove(temp_archive_path);
566
567 // Create response with appropriate headers
568 crow::response res(200, content);
569 res.set_header("Content-Type", "application/x-tar");
570 res.set_header("Content-Disposition", "attachment; filename=\"" + filename + ".tar.gz\"");
571 res.set_header("Content-Length", std::to_string(content.length()));
572 return add_cors_headers(std::move(res));
573
574 } catch (const std::exception & e) {
575 response["success"] = false;
576 response["message"] = std::string("Failed to download recording: ") + e.what();
577
578 crow::response res(500, response.dump());
579 res.set_header("Content-Type", "application/json");
580 return add_cors_headers(std::move(res));
581 }
582}
583
584crow::response CrowServer::handle_download_multiple_recordings(const crow::request & req)
585{
586 nlohmann::json response;
587
588 try {
589 auto json_data = nlohmann::json::parse(req.body);
590
591 if (!json_data.contains("filenames") || !json_data["filenames"].is_array()) {
592 response["success"] = false;
593 response["message"] = "Request must contain 'filenames' array";
594 crow::response res(400, response.dump());
595 res.set_header("Content-Type", "application/json");
596 return add_cors_headers(std::move(res));
597 }
598
599 auto filenames = json_data["filenames"].get<std::vector<std::string>>();
600
601 if (filenames.empty()) {
602 response["success"] = false;
603 response["message"] = "No filenames provided";
604 crow::response res(400, response.dump());
605 res.set_header("Content-Type", "application/json");
606 return add_cors_headers(std::move(res));
607 }
608
609 std::string output_dir = recorder_node_->get_output_directory();
610 std::vector<std::string> valid_bag_paths;
611 std::vector<std::string> invalid_files;
612
613 // Validate all filenames and collect paths
614 for (const auto & filename : filenames) {
615 // Security check
616 if (filename.find("..") != std::string::npos || filename.find("/") != std::string::npos) {
617 invalid_files.push_back(filename + " (invalid characters)");
618 continue;
619 }
620
621 std::filesystem::path recording_path = std::filesystem::path(output_dir) / filename;
622
623 // Check if recording exists
624 if (!std::filesystem::exists(recording_path) || !std::filesystem::is_directory(recording_path)) {
625 invalid_files.push_back(filename + " (not found)");
626 continue;
627 }
628
629 // Don't allow download if currently recording
630 if (recorder_node_->get_status().is_recording) {
631 auto current_bag_path = recorder_node_->get_status().current_bag_path;
632 if (!current_bag_path.empty() && current_bag_path.find(filename) != std::string::npos) {
633 invalid_files.push_back(filename + " (currently recording)");
634 continue;
635 }
636 }
637
638 valid_bag_paths.push_back(recording_path.string());
639 }
640
641 // If no valid files, return error
642 if (valid_bag_paths.empty()) {
643 response["success"] = false;
644 response["message"] = "No valid recordings found";
645 response["invalid_files"] = invalid_files;
646 crow::response res(400, response.dump());
647 res.set_header("Content-Type", "application/json");
648 return add_cors_headers(std::move(res));
649 }
650
651 // Create archive name
652 std::string archive_name = "bag_recordings";
653 if (valid_bag_paths.size() == 1) {
654 archive_name = std::filesystem::path(valid_bag_paths[0]).filename().string();
655 }
656
657 // Create compressed archive
658 std::string temp_archive_path = create_compressed_archive(valid_bag_paths, archive_name);
659
660 // Read compressed file
661 std::ifstream file(temp_archive_path, std::ios::binary);
662 if (!file) {
663 std::filesystem::remove(temp_archive_path);
664 response["success"] = false;
665 response["message"] = "Failed to read compressed file";
666 crow::response res(500, response.dump());
667 res.set_header("Content-Type", "application/json");
668 return add_cors_headers(std::move(res));
669 }
670
671 // Read file content
672 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
673 file.close();
674
675 // Clean up temporary file
676 std::filesystem::remove(temp_archive_path);
677
678 // Create response with appropriate headers
679 crow::response res(200, content);
680 res.set_header("Content-Type", "application/x-tar");
681 res.set_header("Content-Disposition", "attachment; filename=\"" + archive_name + ".tar.gz\"");
682 res.set_header("Content-Length", std::to_string(content.length()));
683
684 // Add info about processed files in a custom header (for debugging)
685 response["processed_files"] = valid_bag_paths.size();
686 response["invalid_files"] = invalid_files;
687 res.set_header("X-Processed-Files", std::to_string(valid_bag_paths.size()));
688
689 return add_cors_headers(std::move(res));
690
691 } catch (const std::exception & e) {
692 response["success"] = false;
693 response["message"] = std::string("Failed to download recordings: ") + e.what();
694
695 crow::response res(500, response.dump());
696 res.set_header("Content-Type", "application/json");
697 return add_cors_headers(std::move(res));
698 }
699}
700
701RecordingConfig json_to_recording_config(const nlohmann::json & json)
702{
703 RecordingConfig config;
704
705 if (json.contains("output_path")) {
706 config.output_path = json["output_path"];
707 }
708
709 if (json.contains("topics")) {
710 config.topics = json["topics"].get<std::vector<std::string>>();
711 }
712
713 if (json.contains("storage_id")) {
714 config.storage_id = json["storage_id"];
715 }
716
717 if (json.contains("serialization_format")) {
718 config.serialization_format = json["serialization_format"];
719 }
720
721 if (json.contains("max_bagfile_size")) {
722 config.max_bagfile_size = json["max_bagfile_size"];
723 }
724
725 if (json.contains("max_bagfile_duration")) {
726 config.max_bagfile_duration = json["max_bagfile_duration"];
727 }
728
729 return config;
730}
731
732crow::response CrowServer::handle_delete_multiple_recordings(const crow::request & req)
733{
734 nlohmann::json response;
735
736 try {
737 auto json_data = nlohmann::json::parse(req.body);
738
739 if (!json_data.contains("filenames") || !json_data["filenames"].is_array()) {
740 response["success"] = false;
741 response["message"] = "Invalid request: 'filenames' array required";
742 crow::response res(400, response.dump());
743 res.set_header("Content-Type", "application/json");
744 return add_cors_headers(std::move(res));
745 }
746
747 std::vector<std::string> filenames = json_data["filenames"].get<std::vector<std::string>>();
748 std::string output_dir = recorder_node_->get_output_directory();
749
750 std::vector<std::string> deleted_files;
751 std::vector<std::string> failed_files;
752 int total_files_deleted = 0;
753
754 for (const auto & filename : filenames) {
755 try {
756 // Basic security check - filename should not contain path traversal
757 if (filename.find("..") != std::string::npos || filename.find("/") != std::string::npos) {
758 failed_files.push_back(filename + " (invalid filename)");
759 continue;
760 }
761
762 std::filesystem::path recording_path = std::filesystem::path(output_dir) / filename;
763
764 // Check if the recording directory exists
765 if (!std::filesystem::exists(recording_path) || !std::filesystem::is_directory(recording_path)) {
766 failed_files.push_back(filename + " (not found)");
767 continue;
768 }
769
770 // Don't delete if currently recording
771 if (recorder_node_->get_status().is_recording) {
772 auto current_bag_path = recorder_node_->get_status().current_bag_path;
773 if (!current_bag_path.empty() && current_bag_path.find(filename) != std::string::npos) {
774 failed_files.push_back(filename + " (currently recording)");
775 continue;
776 }
777 }
778
779 // Delete the directory and all its contents
780 std::uintmax_t deleted_count = std::filesystem::remove_all(recording_path);
781 total_files_deleted += deleted_count;
782 deleted_files.push_back(filename);
783
784 } catch (const std::exception & e) {
785 failed_files.push_back(filename + " (" + std::string(e.what()) + ")");
786 }
787 }
788
789 response["success"] = true;
790 response["message"] = "Batch delete completed";
791 response["deleted_recordings"] = deleted_files;
792 response["failed_recordings"] = failed_files;
793 response["total_files_deleted"] = total_files_deleted;
794
795 int status_code = failed_files.empty() ? 200 : 207; // 207 Multi-Status if some failed
796 crow::response res(status_code, response.dump());
797 res.set_header("Content-Type", "application/json");
798 return add_cors_headers(std::move(res));
799
800 } catch (const std::exception & e) {
801 response["success"] = false;
802 response["message"] = std::string("Error processing batch delete: ") + e.what();
803
804 crow::response res(400, response.dump());
805 res.set_header("Content-Type", "application/json");
806 return add_cors_headers(std::move(res));
807 }
808}
809
810} // namespace bag_recorder_backend