Skip to main content

rush_sync_server/server/handlers/web/
api.rs

1use super::ServerDataWithConfig;
2use crate::server::{config, logging::ServerLogger};
3use actix_web::{web, HttpResponse, Result as ActixResult};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::collections::VecDeque;
7use std::sync::{Arc, LazyLock, Mutex};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11struct Message {
12    message: String,
13    from: String,
14    timestamp: String,
15    id: u32,
16}
17
18pub async fn status_handler(data: web::Data<ServerDataWithConfig>) -> ActixResult<HttpResponse> {
19    let uptime = SystemTime::now()
20        .duration_since(UNIX_EPOCH)
21        .unwrap_or_default()
22        .as_secs();
23    let server_dir = format!("www/{}-[{}]", data.server.name, data.server.port);
24
25    Ok(HttpResponse::Ok().json(json!({
26        "status": "running",
27        "server_id": data.server.id,
28        "server_name": data.server.name,
29        "port": data.server.port,
30        "proxy_port": data.proxy_https_port,
31        "server": config::get_server_name(),
32        "version": config::get_server_version(),
33        "uptime_seconds": uptime,
34        "static_files": true,
35        "template_system": true,
36        "hot_reload": true,
37        "websocket_endpoint": "/ws/hot-reload",
38        "server_directory": server_dir,
39        "log_file": format!(".rss/servers/{}-[{}].log", data.server.name, data.server.port),
40        "certificate_file": format!(".rss/certs/{}-{}.cert", data.server.name, data.server.port),
41        "private_key_file": format!(".rss/certs/{}-{}.key", data.server.name, data.server.port),
42        "urls": {
43            "http": format!("http://127.0.0.1:{}", data.server.port),
44            "proxy": format!("https://{}.localhost:{}", data.server.name, data.proxy_https_port)
45        }
46    })))
47}
48
49pub async fn info_handler(data: web::Data<ServerDataWithConfig>) -> ActixResult<HttpResponse> {
50    let server_dir = format!("www/{}-[{}]", data.server.name, data.server.port);
51
52    Ok(HttpResponse::Ok().json(json!({
53        "name": "Rush Sync Server",
54        "version": config::get_server_version(),
55        "server_id": data.server.id,
56        "server_name": data.server.name,
57        "port": data.server.port,
58        "proxy_port": data.proxy_https_port,
59        "static_files_enabled": true,
60        "template_system": "enabled",
61        "hot_reload_enabled": true,
62        "websocket_url": format!("ws://127.0.0.1:{}/ws/hot-reload", data.server.port),
63        "server_directory": server_dir,
64        "certificate": {
65            "cert_file": format!(".rss/certs/{}-{}.cert", data.server.name, data.server.port),
66            "key_file": format!(".rss/certs/{}-{}.key", data.server.name, data.server.port),
67            "common_name": format!("{}.localhost", data.server.name)
68        },
69        "urls": {
70            "http": format!("http://127.0.0.1:{}", data.server.port),
71            "proxy": format!("https://{}.localhost:{}", data.server.name, data.proxy_https_port),
72            "websocket": format!("ws://127.0.0.1:{}/ws/hot-reload", data.server.port)
73        },
74        "endpoints": [
75            { "path": "/", "method": "GET", "description": "Static files from server directory", "type": "static" },
76            { "path": "/.rss/favicon.svg", "method": "GET", "description": "SVG favicon", "type": "static" },
77            { "path": "/api/status", "method": "GET", "description": "Server status", "type": "api" },
78            { "path": "/api/info", "method": "GET", "description": "API information", "type": "api" },
79            { "path": "/api/metrics", "method": "GET", "description": "Server metrics", "type": "api" },
80            { "path": "/api/stats", "method": "GET", "description": "Request statistics", "type": "api" },
81            { "path": "/api/logs", "method": "GET", "description": "Live server logs", "type": "api" },
82            { "path": "/api/logs/raw", "method": "GET", "description": "Raw log data (JSON)", "type": "api" },
83            { "path": "/api/health", "method": "GET", "description": "Health check", "type": "api" },
84            { "path": "/ws/hot-reload", "method": "GET", "description": "WebSocket hot reload", "type": "websocket" }
85        ]
86    })))
87}
88
89pub async fn metrics_handler(data: web::Data<ServerDataWithConfig>) -> ActixResult<HttpResponse> {
90    let uptime = SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_secs();
94    let server_dir = format!("www/{}-[{}]", data.server.name, data.server.port);
95    let log_file_size = if let Ok(logger) = ServerLogger::new(&data.server.name, data.server.port) {
96        logger.get_log_file_size_bytes().unwrap_or(0)
97    } else {
98        0
99    };
100
101    let file_count = std::fs::read_dir(&server_dir)
102        .map(|entries| entries.count())
103        .unwrap_or(0);
104
105    Ok(HttpResponse::Ok().json(json!({
106        "server_id": data.server.id,
107        "server_name": data.server.name,
108        "port": data.server.port,
109        "uptime_seconds": uptime,
110        "status": "running",
111        "hot_reload": {
112            "enabled": true,
113            "websocket_url": format!("ws://127.0.0.1:{}/ws/hot-reload", data.server.port),
114            "watching_directory": server_dir,
115            "file_watcher": "active"
116        },
117        "static_files": {
118            "directory": server_dir,
119            "file_count": file_count,
120            "enabled": true,
121            "template_based": true
122        },
123        "logging": {
124            "file_size_bytes": log_file_size,
125            "enabled": true
126        },
127        "endpoints_count": 10,
128        "last_updated": uptime
129    })))
130}
131
132pub async fn stats_handler(data: web::Data<ServerDataWithConfig>) -> ActixResult<HttpResponse> {
133    let server_dir = format!("www/{}-[{}]", data.server.name, data.server.port);
134
135    let stats = if let Ok(logger) = ServerLogger::new(&data.server.name, data.server.port) {
136        logger.get_request_stats().await.unwrap_or_default()
137    } else {
138        Default::default()
139    };
140
141    Ok(HttpResponse::Ok().json(json!({
142        "server_id": data.server.id,
143        "server_name": data.server.name,
144        "server_directory": server_dir,
145        "total_requests": stats.total_requests,
146        "unique_ips": stats.unique_ips,
147        "error_requests": stats.error_requests,
148        "security_alerts": stats.security_alerts,
149        "performance_warnings": stats.performance_warnings,
150        "avg_response_time_ms": stats.avg_response_time,
151        "max_response_time_ms": stats.max_response_time,
152        "total_bytes_sent": stats.total_bytes_sent,
153        "uptime_seconds": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
154        "hot_reload_status": "active"
155    })))
156}
157
158pub async fn health_handler(_data: web::Data<ServerDataWithConfig>) -> ActixResult<HttpResponse> {
159    let timestamp = SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .unwrap_or_default()
162        .as_secs();
163
164    Ok(HttpResponse::Ok().json(json!({
165        "status": "healthy",
166        "timestamp": timestamp,
167        "uptime": "running",
168        "logging": "active",
169        "static_files": "enabled",
170        "template_system": "active",
171        "hot_reload": "active",
172        "file_watcher": "monitoring",
173        "config": "loaded from TOML"
174    })))
175}
176
177pub async fn ping_handler() -> ActixResult<HttpResponse> {
178    Ok(HttpResponse::Ok().json(json!({
179        "status": "pong",
180        "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
181        "server": "rush-sync-server",
182        "message": "Ping received successfully"
183    })))
184}
185
186// Static Message Store (In-Memory)
187static MESSAGES: LazyLock<Arc<Mutex<VecDeque<Message>>>> =
188    LazyLock::new(|| Arc::new(Mutex::new(VecDeque::new())));
189static MESSAGE_COUNTER: LazyLock<Arc<Mutex<u32>>> = LazyLock::new(|| Arc::new(Mutex::new(0)));
190
191// POST /api/message - receive a message
192pub async fn message_handler(body: web::Json<serde_json::Value>) -> ActixResult<HttpResponse> {
193    let message_text = body
194        .get("message")
195        .and_then(|v| v.as_str())
196        .unwrap_or("No message");
197
198    let from = body
199        .get("from")
200        .and_then(|v| v.as_str())
201        .unwrap_or("Unknown");
202
203    let timestamp = body
204        .get("timestamp")
205        .and_then(|v| v.as_str())
206        .map(|s| s.to_string())
207        .unwrap_or_else(|| chrono::Local::now().to_rfc3339());
208
209    // Store message
210    let message_id = {
211        let mut messages = MESSAGES.lock().unwrap_or_else(|p| p.into_inner());
212        let mut counter = MESSAGE_COUNTER.lock().unwrap_or_else(|p| p.into_inner());
213        *counter += 1;
214        let id = *counter;
215
216        messages.push_back(Message {
217            message: message_text.to_string(),
218            from: from.to_string(),
219            timestamp: timestamp.to_string(),
220            id,
221        });
222
223        // Keep at most 100 messages
224        if messages.len() > 100 {
225            messages.pop_front();
226        }
227        id
228    };
229
230    log::info!("Message received from {}: {}", from, message_text);
231
232    Ok(HttpResponse::Ok().json(json!({
233        "status": "received",
234        "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
235        "message_id": message_id
236    })))
237}
238
239// GET /api/messages - retrieve all messages
240pub async fn messages_handler() -> ActixResult<HttpResponse> {
241    let messages = {
242        let messages_lock = MESSAGES.lock().unwrap_or_else(|p| p.into_inner());
243        messages_lock.iter().cloned().collect::<Vec<_>>()
244    };
245
246    Ok(HttpResponse::Ok().json(json!({
247        "messages": messages,
248        "count": messages.len(),
249        "status": "success"
250    })))
251}
252
253pub async fn close_browser_handler() -> ActixResult<HttpResponse> {
254    let html = r#"
255<script>
256setTimeout(() => { window.close(); }, 100);
257document.write('<h1>Server stopped - closing...</h1>');
258</script>
259"#;
260    Ok(HttpResponse::Ok().content_type("text/html").body(html))
261}
262
263// PUT /api/files/{path} — Upload/create a file
264pub async fn upload_file(
265    data: web::Data<ServerDataWithConfig>,
266    path: web::Path<String>,
267    body: web::Bytes,
268) -> ActixResult<HttpResponse> {
269    let file_path = path.into_inner();
270
271    if file_path.is_empty() {
272        return Ok(HttpResponse::BadRequest().json(json!({"error": "Path required"})));
273    }
274
275    let base_dir = crate::core::helpers::get_base_dir().map_err(|e| {
276        actix_web::error::ErrorInternalServerError(format!("Base dir error: {}", e))
277    })?;
278    let server_dir = base_dir
279        .join("www")
280        .join(format!("{}-[{}]", data.server.name, data.server.port));
281
282    // Reject path traversal attempts early
283    if file_path.contains("..") {
284        return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
285    }
286
287    let target = server_dir.join(&file_path);
288
289    // Create parent directories
290    if let Some(parent) = target.parent() {
291        tokio::fs::create_dir_all(parent).await.map_err(|e| {
292            actix_web::error::ErrorInternalServerError(format!("Directory creation failed: {}", e))
293        })?;
294    }
295
296    // Path traversal protection (verify resolved path is within server dir)
297    let canonical_server = server_dir
298        .canonicalize()
299        .unwrap_or_else(|_| server_dir.clone());
300    if let Some(canonical_parent) = target.parent().and_then(|p| p.canonicalize().ok()) {
301        if !canonical_parent.starts_with(&canonical_server) {
302            return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
303        }
304    }
305
306    let size = body.len();
307    tokio::fs::write(&target, &body)
308        .await
309        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("Write failed: {}", e)))?;
310
311    log::info!(
312        "File uploaded: {} ({} bytes) to {}-[{}]",
313        file_path,
314        size,
315        data.server.name,
316        data.server.port
317    );
318
319    Ok(HttpResponse::Ok().json(json!({
320        "status": "uploaded",
321        "path": file_path,
322        "size": size
323    })))
324}
325
326// GET /api/files — List files in server directory
327pub async fn list_files(
328    data: web::Data<ServerDataWithConfig>,
329    query: web::Query<std::collections::HashMap<String, String>>,
330) -> ActixResult<HttpResponse> {
331    let base_dir = crate::core::helpers::get_base_dir().map_err(|e| {
332        actix_web::error::ErrorInternalServerError(format!("Base dir error: {}", e))
333    })?;
334    let server_dir = base_dir
335        .join("www")
336        .join(format!("{}-[{}]", data.server.name, data.server.port));
337
338    let subpath = query.get("path").map(|s| s.as_str()).unwrap_or("");
339
340    if subpath.contains("..") {
341        return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
342    }
343
344    let target = if subpath.is_empty() {
345        server_dir.clone()
346    } else {
347        server_dir.join(subpath)
348    };
349
350    // Path traversal protection
351    let canonical_server = server_dir
352        .canonicalize()
353        .unwrap_or_else(|_| server_dir.clone());
354    if let Ok(canonical_target) = target.canonicalize() {
355        if !canonical_target.starts_with(&canonical_server) {
356            return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
357        }
358    }
359
360    if !target.exists() {
361        return Ok(HttpResponse::NotFound().json(json!({"error": "Directory not found"})));
362    }
363
364    let mut entries = vec![];
365    if let Ok(mut dir) = tokio::fs::read_dir(&target).await {
366        while let Ok(Some(entry)) = dir.next_entry().await {
367            let name = entry.file_name().to_string_lossy().to_string();
368            let metadata = entry.metadata().await.ok();
369            entries.push(json!({
370                "name": name,
371                "is_dir": metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false),
372                "size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
373            }));
374        }
375    }
376
377    Ok(HttpResponse::Ok().json(json!({
378        "server_name": data.server.name,
379        "port": data.server.port,
380        "path": subpath,
381        "files": entries,
382        "count": entries.len()
383    })))
384}
385
386// DELETE /api/files/{path} — Delete a file or directory
387pub async fn delete_file(
388    data: web::Data<ServerDataWithConfig>,
389    path: web::Path<String>,
390) -> ActixResult<HttpResponse> {
391    let file_path = path.into_inner();
392
393    if file_path.is_empty() {
394        return Ok(HttpResponse::BadRequest().json(json!({"error": "Path required"})));
395    }
396
397    if file_path.contains("..") {
398        return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
399    }
400
401    let base_dir = crate::core::helpers::get_base_dir().map_err(|e| {
402        actix_web::error::ErrorInternalServerError(format!("Base dir error: {}", e))
403    })?;
404    let server_dir = base_dir
405        .join("www")
406        .join(format!("{}-[{}]", data.server.name, data.server.port));
407
408    let target = server_dir.join(&file_path);
409
410    // Path traversal protection
411    let canonical_server = server_dir
412        .canonicalize()
413        .unwrap_or_else(|_| server_dir.clone());
414    if let Ok(canonical_target) = target.canonicalize() {
415        if !canonical_target.starts_with(&canonical_server) {
416            return Ok(HttpResponse::Forbidden().json(json!({"error": "Path traversal blocked"})));
417        }
418    }
419
420    if !target.exists() {
421        return Ok(HttpResponse::NotFound().json(json!({"error": "File not found"})));
422    }
423
424    if target.is_dir() {
425        tokio::fs::remove_dir_all(&target).await.map_err(|e| {
426            actix_web::error::ErrorInternalServerError(format!("Delete failed: {}", e))
427        })?;
428    } else {
429        tokio::fs::remove_file(&target).await.map_err(|e| {
430            actix_web::error::ErrorInternalServerError(format!("Delete failed: {}", e))
431        })?;
432    }
433
434    log::info!(
435        "File deleted: {} from {}-[{}]",
436        file_path,
437        data.server.name,
438        data.server.port
439    );
440
441    Ok(HttpResponse::Ok().json(json!({
442        "status": "deleted",
443        "path": file_path
444    })))
445}
446
447// GET /api/settings — Read server settings
448pub async fn settings_get_handler(
449    data: web::Data<ServerDataWithConfig>,
450) -> ActixResult<HttpResponse> {
451    let server_dir =
452        crate::server::settings::ServerSettings::get_server_dir(&data.server.name, data.server.port);
453    let settings = match server_dir {
454        Some(dir) => crate::server::settings::ServerSettings::load(&dir),
455        None => crate::server::settings::ServerSettings::default(),
456    };
457
458    // Return settings but mask the PIN code
459    Ok(HttpResponse::Ok().json(json!({
460        "custom_404_enabled": settings.custom_404_enabled,
461        "custom_404_path": settings.custom_404_path,
462        "pin_enabled": settings.pin_enabled,
463        "pin_set": !settings.pin_code.is_empty(),
464    })))
465}
466
467// POST /api/settings — Save server settings
468pub async fn settings_post_handler(
469    data: web::Data<ServerDataWithConfig>,
470    body: web::Json<serde_json::Value>,
471) -> ActixResult<HttpResponse> {
472    let server_dir =
473        crate::server::settings::ServerSettings::get_server_dir(&data.server.name, data.server.port);
474    let server_dir = match server_dir {
475        Some(dir) => dir,
476        None => {
477            return Ok(
478                HttpResponse::InternalServerError().json(json!({"error": "Server directory not found"}))
479            )
480        }
481    };
482
483    // Ensure directory exists
484    if !server_dir.exists() {
485        let _ = std::fs::create_dir_all(&server_dir);
486    }
487
488    let mut settings = crate::server::settings::ServerSettings::load(&server_dir);
489
490    // Update fields if present
491    if let Some(v) = body.get("custom_404_enabled").and_then(|v| v.as_bool()) {
492        settings.custom_404_enabled = v;
493    }
494    if let Some(v) = body.get("custom_404_path").and_then(|v| v.as_str()) {
495        let path = v.trim();
496        if !path.is_empty() && !path.contains("..") {
497            settings.custom_404_path = path.to_string();
498        }
499    }
500    if let Some(v) = body.get("pin_enabled").and_then(|v| v.as_bool()) {
501        settings.pin_enabled = v;
502    }
503    if let Some(v) = body.get("pin_code").and_then(|v| v.as_str()) {
504        let pin = v.trim();
505        if !pin.is_empty() {
506            settings.pin_code = crate::server::settings::ServerSettings::encode_pin(pin);
507        }
508    }
509
510    // Auto-create 404.html if enabled and file doesn't exist
511    settings.ensure_404_page(&server_dir, &data.server.name);
512
513    match settings.save(&server_dir) {
514        Ok(_) => {
515            log::info!("Settings saved for {}-[{}]", data.server.name, data.server.port);
516            Ok(HttpResponse::Ok().json(json!({
517                "status": "saved",
518                "custom_404_enabled": settings.custom_404_enabled,
519                "custom_404_path": settings.custom_404_path,
520                "pin_enabled": settings.pin_enabled,
521                "pin_set": !settings.pin_code.is_empty(),
522            })))
523        }
524        Err(e) => {
525            log::error!("Failed to save settings: {}", e);
526            Ok(HttpResponse::InternalServerError().json(json!({"error": format!("Save failed: {}", e)})))
527        }
528    }
529}
530
531// POST /api/pin/verify — Verify PIN and set cookie
532pub async fn pin_verify_handler(
533    data: web::Data<ServerDataWithConfig>,
534    body: web::Json<serde_json::Value>,
535) -> ActixResult<HttpResponse> {
536    let server_dir =
537        crate::server::settings::ServerSettings::get_server_dir(&data.server.name, data.server.port);
538    let settings = match server_dir {
539        Some(dir) => crate::server::settings::ServerSettings::load(&dir),
540        None => crate::server::settings::ServerSettings::default(),
541    };
542
543    let input_pin = body
544        .get("pin")
545        .and_then(|v| v.as_str())
546        .unwrap_or("");
547
548    if settings.verify_pin(input_pin) {
549        // Create a simple token from server name + port
550        let token = format!("rss-pin-{}-{}", data.server.name, data.server.port);
551        Ok(HttpResponse::Ok()
552            .cookie(
553                actix_web::cookie::Cookie::build("rss_pin", &token)
554                    .path("/")
555                    .http_only(true)
556                    .max_age(actix_web::cookie::time::Duration::minutes(3))
557                    .finish(),
558            )
559            .json(json!({"status": "ok"})))
560    } else {
561        Ok(HttpResponse::Unauthorized().json(json!({"error": "Invalid PIN"})))
562    }
563}
564
565// POST /api/pin/logout — Clear PIN cookie
566pub async fn pin_logout_handler() -> ActixResult<HttpResponse> {
567    Ok(HttpResponse::Ok()
568        .cookie(
569            actix_web::cookie::Cookie::build("rss_pin", "")
570                .path("/")
571                .http_only(true)
572                .max_age(actix_web::cookie::time::Duration::ZERO)
573                .finish(),
574        )
575        .json(json!({"status": "logged_out"})))
576}
577
578// ACME challenge handler for Let's Encrypt HTTP-01 validation
579pub async fn acme_challenge_handler(path: web::Path<String>) -> ActixResult<HttpResponse> {
580    let token = path.into_inner();
581    if let Some(key_auth) = crate::server::acme::get_challenge_response(&token) {
582        Ok(HttpResponse::Ok().content_type("text/plain").body(key_auth))
583    } else {
584        Ok(HttpResponse::NotFound().body("Challenge not found"))
585    }
586}
587
588// GET /api/acme/status — ACME/TLS certificate status
589pub async fn acme_status_handler() -> ActixResult<HttpResponse> {
590    let status = crate::server::acme::get_acme_status();
591    Ok(HttpResponse::Ok().json(status))
592}
593
594// GET /api/acme/dashboard — ACME/TLS status dashboard
595pub async fn acme_dashboard_handler() -> ActixResult<HttpResponse> {
596    let status = crate::server::acme::get_acme_status();
597    let json_data = serde_json::to_string(&status)
598        .unwrap_or_else(|_| "{}".to_string())
599        .replace("</", "<\\/"); // Prevent XSS in inline script
600    let html = crate::server::acme::ACME_DASHBOARD_HTML.replace("__ACME_DATA__", &json_data);
601    Ok(HttpResponse::Ok()
602        .content_type("text/html; charset=utf-8")
603        .body(html))
604}
605
606// GET /api/analytics — Analytics summary JSON
607pub async fn analytics_handler() -> ActixResult<HttpResponse> {
608    let summary = crate::server::analytics::get_summary();
609    Ok(HttpResponse::Ok().json(summary))
610}
611
612// GET /api/analytics/dashboard — Embedded analytics dashboard
613pub async fn analytics_dashboard_handler() -> ActixResult<HttpResponse> {
614    let summary = crate::server::analytics::get_summary();
615    let json_data = serde_json::to_string(&summary)
616        .unwrap_or_else(|_| "{}".to_string())
617        .replace("</", "<\\/"); // Prevent XSS in inline script
618    let html = crate::server::analytics::DASHBOARD_HTML.replace("__ANALYTICS_DATA__", &json_data);
619    Ok(HttpResponse::Ok()
620        .content_type("text/html; charset=utf-8")
621        .body(html))
622}