Skip to main content

braid_core/fs/
api.rs

1use super::blob_handlers::{handle_get_blob, handle_put_blob};
2use super::mapping;
3use super::server_handlers::{handle_get_file, handle_get_file_api, handle_put_file};
4use crate::core::server::BraidLayer;
5use crate::core::Result;
6use crate::fs::state::{Command, DaemonState};
7use axum::{
8    extract::State,
9    routing::{delete, put},
10    Json, Router,
11};
12use serde::Deserialize;
13use std::net::SocketAddr;
14
15#[derive(Deserialize)]
16pub struct SyncParams {
17    url: String,
18}
19
20#[derive(Deserialize)]
21pub struct PushParams {
22    url: String,
23    content: String,
24    content_type: Option<String>,
25}
26
27#[derive(Deserialize)]
28pub struct CookieParams {
29    pub domain: String,
30    pub value: String,
31}
32
33#[derive(Deserialize)]
34pub struct IdentityParams {
35    pub domain: String,
36    pub email: String,
37}
38
39#[derive(Deserialize)]
40pub struct MountParams {
41    pub port: Option<u16>,
42    pub mount_point: Option<String>,
43}
44
45pub async fn run_server(port: u16, state: DaemonState) -> Result<()> {
46    let mut app = Router::new()
47        .route("/api/sync", put(handle_sync))
48        .route("/api/sync", delete(handle_unsync))
49        .route("/api/push", put(handle_push))
50        .route("/api/get", axum::routing::get(handle_get_file_api))
51        .route("/api/cookie", put(handle_cookie))
52        .route("/api/identity", put(handle_identity));
53
54    #[cfg(feature = "nfs")]
55    {
56        app = app
57            .route("/api/mount", put(handle_mount))
58            .route("/api/mount", delete(handle_unmount));
59    }
60
61    let app = app
62        .route(
63            "/.braidfs/config",
64            axum::routing::get(handle_braidfs_config),
65        )
66        .route(
67            "/.braidfs/errors",
68            axum::routing::get(handle_braidfs_errors),
69        )
70        .route(
71            "/.braidfs/get_version/{fullpath}/{hash}",
72            axum::routing::get(handle_get_version),
73        )
74        .route(
75            "/.braidfs/set_version/{fullpath}/{parents}",
76            axum::routing::put(handle_set_version),
77        )
78        .route("/api/blob/{hash}", axum::routing::get(handle_get_blob))
79        .route("/api/blob", put(handle_put_blob))
80        .route("/{*path}", axum::routing::get(handle_get_file))
81        .route("/{*path}", put(handle_put_file))
82        .layer(BraidLayer::new().middleware())
83        .with_state(state);
84
85    let addr = SocketAddr::from(([127, 0, 0, 1], port));
86    tracing::info!("Daemon API listening on {}", addr);
87
88    let listener = tokio::net::TcpListener::bind(addr).await?;
89    axum::serve(listener, app).await?;
90
91    Ok(())
92}
93
94async fn handle_sync(
95    State(state): State<DaemonState>,
96    Json(params): Json<SyncParams>,
97) -> Json<serde_json::Value> {
98    tracing::info!("IPC Command: Sync {}", params.url);
99
100    if let Err(e) = state
101        .tx_cmd
102        .send(Command::Sync {
103            url: params.url.clone(),
104        })
105        .await
106    {
107        tracing::error!("Failed to send sync command: {}", e);
108        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
109    }
110
111    Json(serde_json::json!({ "status": "ok", "url": params.url }))
112}
113
114async fn handle_unsync(
115    State(state): State<DaemonState>,
116    Json(params): Json<SyncParams>,
117) -> Json<serde_json::Value> {
118    tracing::info!("IPC Command: Unsync {}", params.url);
119
120    if let Err(e) = state
121        .tx_cmd
122        .send(Command::Unsync {
123            url: params.url.clone(),
124        })
125        .await
126    {
127        tracing::error!("Failed to send unsync command: {}", e);
128        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
129    }
130
131    Json(serde_json::json!({ "status": "ok", "url": params.url }))
132}
133
134async fn handle_push(
135    State(state): State<DaemonState>,
136    Json(params): Json<PushParams>,
137) -> Json<serde_json::Value> {
138    tracing::info!(
139        "IPC Command: Push {} ({} bytes)",
140        params.url,
141        params.content.len()
142    );
143
144    // 1. Write content to local file
145    let path = match mapping::url_to_path(&params.url) {
146        Ok(p) => p,
147        Err(e) => {
148            tracing::error!("Failed to map URL to path: {}", e);
149            return Json(
150                serde_json::json!({ "status": "error", "message": format!("Path mapping failed: {}", e) }),
151            );
152        }
153    };
154
155    if let Some(parent) = path.parent() {
156        if let Err(e) = tokio::fs::create_dir_all(parent).await {
157            tracing::error!("Failed to create parent directory: {}", e);
158            return Json(
159                serde_json::json!({ "status": "error", "message": format!("Directory creation failed: {}", e) }),
160            );
161        }
162    }
163
164    // 2. Get current version (parents)
165    let parents = {
166        let store = state.version_store.read().await;
167        store
168            .get(&params.url)
169            .map(|v| v.current_version.clone())
170            .unwrap_or_default()
171    };
172
173    // 3. Get original content for diff
174    let original_content = {
175        let cache = state.content_cache.read().await;
176        cache.get(&params.url).cloned()
177    };
178
179    // 4. Push to remote FIRST (Confirm)
180    match crate::fs::sync::sync_local_to_remote(
181        &path,
182        &params.url,
183        &parents,
184        original_content,
185        params.content.clone(),
186        params.content_type,
187        state.clone(),
188    )
189    .await
190    {
191        Ok(()) => {
192            tracing::info!("Successfully pushed {} to remote", params.url);
193
194            // 5. Commit to local disk only after server confirmation
195            if let Err(e) = tokio::fs::write(&path, &params.content).await {
196                tracing::error!("Failed to write file after successful sync: {}", e);
197                return Json(
198                    serde_json::json!({ "status": "error", "message": format!("Server accepted but local write failed: {}", e) }),
199                );
200            }
201            // Add to pending to avoid loop if we had a watcher trigger
202            state.pending.add(path.clone());
203
204            Json(serde_json::json!({ "status": "ok", "url": params.url }))
205        }
206        Err(e) => {
207            tracing::error!("Push failed for {}: {}", params.url, e);
208            let err_str = e.to_string();
209            let status = if err_str.contains("401") || err_str.contains("Unauthorized") {
210                "unauthorized"
211            } else if err_str.contains("403") || err_str.contains("Forbidden") {
212                "forbidden"
213            } else {
214                "error"
215            };
216
217            tracing::error!("Server error detail: {}", e);
218
219            Json(serde_json::json!({
220                "status": status,
221                "message": format!("Push failed: {}", e),
222                "domain": url::Url::parse(&params.url).ok().and_then(|u| u.domain().map(|d| d.to_string()))
223            }))
224        }
225    }
226}
227
228async fn handle_cookie(
229    State(state): State<DaemonState>,
230    Json(params): Json<CookieParams>,
231) -> Json<serde_json::Value> {
232    tracing::info!("IPC Command: SetCookie {}={}", params.domain, params.value);
233
234    if let Err(e) = state
235        .tx_cmd
236        .send(Command::SetCookie {
237            domain: params.domain.clone(),
238            value: params.value.clone(),
239        })
240        .await
241    {
242        tracing::error!("Failed to send cookie command: {}", e);
243        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
244    }
245
246    Json(serde_json::json!({ "status": "ok", "domain": params.domain }))
247}
248
249async fn handle_identity(
250    State(state): State<DaemonState>,
251    Json(params): Json<IdentityParams>,
252) -> Json<serde_json::Value> {
253    tracing::info!(
254        "IPC Command: SetIdentity {}={}",
255        params.domain,
256        params.email
257    );
258
259    if let Err(e) = state
260        .tx_cmd
261        .send(Command::SetIdentity {
262            domain: params.domain.clone(),
263            email: params.email.clone(),
264        })
265        .await
266    {
267        tracing::error!("Failed to send identity command: {}", e);
268        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
269    }
270
271    Json(serde_json::json!({ "status": "ok", "domain": params.domain }))
272}
273
274/// Handle /.braidfs/config - returns the current configuration.
275async fn handle_braidfs_config(State(state): State<DaemonState>) -> Json<serde_json::Value> {
276    let config = state.config.read().await;
277    Json(serde_json::json!({
278        "sync": config.sync,
279        "cookies": config.cookies,
280        "port": config.port,
281        "debounce_ms": config.debounce_ms,
282        "ignore_patterns": config.ignore_patterns,
283    }))
284}
285
286/// Error log storage (in-memory for now).
287static ERRORS: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> = std::sync::OnceLock::new();
288
289fn get_errors() -> &'static std::sync::Mutex<Vec<String>> {
290    ERRORS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
291}
292
293/// Log an error to the in-memory error log.
294pub fn log_error(text: &str) {
295    tracing::error!("LOGGING ERROR: {}", text);
296    if let Ok(mut errors) = get_errors().lock() {
297        errors.push(format!(
298            "{}: {}",
299            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
300            text
301        ));
302        // Keep only last 100 errors
303        if errors.len() > 100 {
304            errors.remove(0);
305        }
306    }
307}
308
309/// Handle /.braidfs/errors - returns the error log.
310async fn handle_braidfs_errors() -> String {
311    if let Ok(errors) = get_errors().lock() {
312        errors.join("\n")
313    } else {
314        "Error reading error log".to_string()
315    }
316}
317
318/// Handle /.braidfs/get_version/{fullpath}/{hash} - get version by content hash.
319async fn handle_get_version(
320    axum::extract::Path((fullpath, hash)): axum::extract::Path<(String, String)>,
321    State(state): State<DaemonState>,
322) -> Json<serde_json::Value> {
323    use percent_encoding::percent_decode_str;
324    let fullpath = percent_decode_str(&fullpath)
325        .decode_utf8_lossy()
326        .to_string();
327    let hash = percent_decode_str(&hash).decode_utf8_lossy().to_string();
328
329    tracing::debug!("get_version: {} hash={}", fullpath, hash);
330
331    // Look up version in version store
332    let versions = state.version_store.read().await;
333    if let Some(version) = versions.get_version_by_hash(&fullpath, &hash) {
334        Json(serde_json::json!(version))
335    } else {
336        Json(serde_json::json!(null))
337    }
338}
339
340/// Handle /.braidfs/set_version/{fullpath}/{parents} - set version by content hash.
341async fn handle_set_version(
342    axum::extract::Path((fullpath, parents)): axum::extract::Path<(String, String)>,
343    State(state): State<DaemonState>,
344    body: String,
345) -> Json<serde_json::Value> {
346    use percent_encoding::percent_decode_str;
347    let fullpath = percent_decode_str(&fullpath)
348        .decode_utf8_lossy()
349        .to_string();
350    let parents_json = percent_decode_str(&parents).decode_utf8_lossy().to_string();
351
352    let parents: Vec<String> = serde_json::from_str(&parents_json).unwrap_or_default();
353
354    tracing::info!("set_version: {} parents={:?}", fullpath, parents);
355
356    match mapping::path_to_url(std::path::Path::new(&fullpath)) {
357        Ok(url) => {
358            let mut store = state.version_store.write().await;
359            let my_id = crate::fs::PEER_ID.read().await.clone();
360
361            // Generate a new version ID
362            let version_id = format!(
363                "{}-{}",
364                my_id,
365                uuid::Uuid::new_v4().to_string()[..8].to_string()
366            );
367
368            store.update(
369                &url,
370                vec![crate::core::Version::new(&version_id)],
371                parents
372                    .into_iter()
373                    .map(|p| crate::core::Version::new(&p))
374                    .collect(),
375            );
376            let _ = store.save().await;
377
378            // Also update content cache
379            let mut cache = state.content_cache.write().await;
380            cache.insert(url, body);
381
382            Json(serde_json::json!({ "status": "ok", "version": version_id }))
383        }
384        Err(e) => {
385            tracing::error!("Failed to map path to URL: {}", e);
386            Json(serde_json::json!({ "status": "error", "message": e.to_string() }))
387        }
388    }
389}
390
391/// Check if a file is read-only.
392/// Matches JS `is_read_only()` from braidfs/index.js.
393#[cfg(unix)]
394pub async fn is_read_only(path: &std::path::Path) -> std::io::Result<bool> {
395    use std::os::unix::fs::PermissionsExt;
396    let metadata = tokio::fs::metadata(path).await?;
397    let mode = metadata.permissions().mode();
398    // Check if write bit is set for owner
399    Ok((mode & 0o200) == 0)
400}
401
402#[cfg(windows)]
403pub async fn is_read_only(path: &std::path::Path) -> std::io::Result<bool> {
404    let metadata = tokio::fs::metadata(path).await?;
405    Ok(metadata.permissions().readonly())
406}
407
408/// Set a file to read-only or writable.
409/// Matches JS `set_read_only()` from braidfs/index.js.
410#[cfg(unix)]
411pub async fn set_read_only(path: &std::path::Path, read_only: bool) -> std::io::Result<()> {
412    use std::os::unix::fs::PermissionsExt;
413    let metadata = tokio::fs::metadata(path).await?;
414    let mut perms = metadata.permissions();
415    let mode = perms.mode();
416
417    let new_mode = if read_only {
418        mode & !0o222 // Remove write bits
419    } else {
420        mode | 0o200 // Add owner write bit
421    };
422
423    perms.set_mode(new_mode);
424    tokio::fs::set_permissions(path, perms).await
425}
426
427#[cfg(windows)]
428pub async fn set_read_only(path: &std::path::Path, read_only: bool) -> std::io::Result<()> {
429    let metadata = tokio::fs::metadata(path).await?;
430    let mut perms = metadata.permissions();
431    perms.set_readonly(read_only);
432    tokio::fs::set_permissions(path, perms).await
433}
434
435#[cfg(feature = "nfs")]
436async fn handle_mount(
437    State(state): State<DaemonState>,
438    Json(params): Json<MountParams>,
439) -> Json<serde_json::Value> {
440    let port = params.port.unwrap_or(2049);
441    tracing::info!("IPC Command: Mount on port {}", port);
442
443    if let Err(e) = state
444        .tx_cmd
445        .send(Command::Mount {
446            port,
447            mount_point: params.mount_point,
448        })
449        .await
450    {
451        tracing::error!("Failed to send mount command: {}", e);
452        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
453    }
454
455    Json(serde_json::json!({ "status": "ok", "port": port }))
456}
457
458#[cfg(feature = "nfs")]
459async fn handle_unmount(State(state): State<DaemonState>) -> Json<serde_json::Value> {
460    tracing::info!("IPC Command: Unmount");
461
462    if let Err(e) = state.tx_cmd.send(Command::Unmount).await {
463        tracing::error!("Failed to send unmount command: {}", e);
464        return Json(serde_json::json!({ "status": "error", "message": "Internal channel error" }));
465    }
466
467    Json(serde_json::json!({ "status": "ok" }))
468}
469async fn handle_push_binary(
470    State(state): State<DaemonState>,
471    query: axum::extract::Query<SyncParams>,
472    headers: axum::http::HeaderMap,
473    body: axum::body::Bytes,
474) -> Json<serde_json::Value> {
475    tracing::info!(
476        "IPC Command: Push Binary {} ({} bytes)",
477        query.url,
478        body.len()
479    );
480
481    // 1. Map URL to path
482    let path = match mapping::url_to_path(&query.url) {
483        Ok(p) => p,
484        Err(e) => {
485            return Json(
486                serde_json::json!({ "status": "error", "message": format!("Path mapping failed: {}", e) }),
487            );
488        }
489    };
490
491    if let Some(parent) = path.parent() {
492        let _ = tokio::fs::create_dir_all(parent).await;
493    }
494
495    // 2. Parents/Version
496    let parents = {
497        let store = state.version_store.read().await;
498        store
499            .get(&query.url)
500            .map(|v| v.current_version.clone())
501            .unwrap_or_default()
502    };
503
504    let content_type = headers
505        .get(axum::http::header::CONTENT_TYPE)
506        .and_then(|v| v.to_str().ok())
507        .map(|s| s.to_string());
508
509    // 3. Push to remote
510    match crate::fs::sync::sync_binary_to_remote(
511        &path,
512        &query.url,
513        &parents,
514        body.clone(),
515        content_type,
516        state.clone(),
517    )
518    .await
519    {
520        Ok(()) => {
521            // 4. Commit to local disk
522            if let Err(e) = tokio::fs::write(&path, &body).await {
523                return Json(
524                    serde_json::json!({ "status": "error", "message": format!("Server accepted but local write failed: {}", e) }),
525                );
526            }
527            state.pending.add(path);
528
529            Json(serde_json::json!({ "status": "ok", "url": query.url }))
530        }
531        Err(e) => Json(
532            serde_json::json!({ "status": "error", "message": format!("Binary push failed: {}", e) }),
533        ),
534    }
535}