|
| 1 | +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; |
1 | 2 | use serde::{Deserialize, Serialize}; |
2 | 3 | use std::fs; |
3 | 4 | use std::path::{Path, PathBuf}; |
@@ -60,6 +61,10 @@ fn walk_dir(dir: &Path, notes: &mut Vec<Note>, base_path: &Path) -> Result<(), S |
60 | 61 | let entry = entry.map_err(|e| e.to_string())?; |
61 | 62 | let path = entry.path(); |
62 | 63 | if path.is_dir() { |
| 64 | + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); |
| 65 | + if dir_name == ".images" || dir_name == ".audio" { |
| 66 | + continue; |
| 67 | + } |
63 | 68 | walk_dir(&path, notes, base_path)?; |
64 | 69 | } else if path.is_file() { |
65 | 70 | let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); |
@@ -496,3 +501,117 @@ pub fn run_onboarding(app: &AppHandle) { |
496 | 501 | } |
497 | 502 | } |
498 | 503 | } |
| 504 | + |
| 505 | +#[tauri::command(async)] |
| 506 | +pub async fn save_asset(data_base64: String, ext: String, folder: String) -> Result<String, String> { |
| 507 | + if folder.contains("..") || folder.contains('/') || folder.contains('\\') { |
| 508 | + return Err("Invalid folder name".to_string()); |
| 509 | + } |
| 510 | + let folder_name = if folder.starts_with('.') { |
| 511 | + folder.clone() |
| 512 | + } else { |
| 513 | + format!(".{}", folder) |
| 514 | + }; |
| 515 | + if folder_name != ".images" && folder_name != ".audio" { |
| 516 | + return Err("Unsupported asset folder".to_string()); |
| 517 | + } |
| 518 | + |
| 519 | + let base = get_papercache_dir()?; |
| 520 | + let asset_dir = base.join(&folder_name); |
| 521 | + if !asset_dir.exists() { |
| 522 | + tokio::fs::create_dir_all(&asset_dir).await.map_err(|e| e.to_string())?; |
| 523 | + } |
| 524 | + |
| 525 | + let clean_ext: String = ext |
| 526 | + .trim_start_matches('.') |
| 527 | + .chars() |
| 528 | + .filter(|c| c.is_alphanumeric()) |
| 529 | + .collect(); |
| 530 | + let timestamp = std::time::SystemTime::now() |
| 531 | + .duration_since(std::time::UNIX_EPOCH) |
| 532 | + .map_err(|e| e.to_string())? |
| 533 | + .as_millis(); |
| 534 | + let prefix = folder_name.trim_start_matches('.'); |
| 535 | + |
| 536 | + // Generate unique filename with random suffix to avoid collisions |
| 537 | + use rand::Rng; |
| 538 | + let mut rng = rand::thread_rng(); |
| 539 | + let random_suffix: u32 = rng.gen(); |
| 540 | + let filename = format!("{}_{}_{:08x}.{}", prefix, timestamp, random_suffix, clean_ext); |
| 541 | + let file_path = asset_dir.join(&filename); |
| 542 | + |
| 543 | + let b64_str = if let Some(idx) = data_base64.find(',') { |
| 544 | + &data_base64[idx + 1..] |
| 545 | + } else { |
| 546 | + &data_base64 |
| 547 | + }; |
| 548 | + |
| 549 | + let decoded = BASE64.decode(b64_str).map_err(|e| format!("Failed to decode base64: {}", e))?; |
| 550 | + tokio::fs::write(&file_path, &decoded).await.map_err(|e| e.to_string())?; |
| 551 | + |
| 552 | + Ok(format!("/{}/{}", folder_name, filename)) |
| 553 | +} |
| 554 | + |
| 555 | +#[tauri::command(async)] |
| 556 | +pub async fn read_asset(path: String) -> Result<String, String> { |
| 557 | + let clean_path = path.trim_start_matches('/'); |
| 558 | + if clean_path.contains("..") { |
| 559 | + return Err("Invalid asset path".to_string()); |
| 560 | + } |
| 561 | + |
| 562 | + // Read-only validation: ensure path is within allowed asset folders |
| 563 | + let path_parts: Vec<&str> = clean_path.split('/').collect(); |
| 564 | + if path_parts.is_empty() { |
| 565 | + return Err("Invalid asset path".to_string()); |
| 566 | + } |
| 567 | + let first_component = path_parts[0]; |
| 568 | + if first_component != ".images" && first_component != ".audio" { |
| 569 | + return Err("Asset path must start with .images or .audio".to_string()); |
| 570 | + } |
| 571 | + |
| 572 | + let base = get_papercache_dir()?; |
| 573 | + let mut target = base.clone(); |
| 574 | + for comp in clean_path.split('/') { |
| 575 | + if !comp.is_empty() && comp != "." && comp != ".." { |
| 576 | + target.push(comp); |
| 577 | + } else if comp == ".." { |
| 578 | + return Err("Path traversal detected".to_string()); |
| 579 | + } |
| 580 | + } |
| 581 | + |
| 582 | + // Verify the resolved path is within base without creating any directories |
| 583 | + let canonical_base = base.canonicalize().map_err(|e| e.to_string())?; |
| 584 | + if !target.exists() { |
| 585 | + return Err("Asset file not found".to_string()); |
| 586 | + } |
| 587 | + let canonical_target = target.canonicalize().map_err(|e| e.to_string())?; |
| 588 | + if !canonical_target.starts_with(&canonical_base) { |
| 589 | + return Err("Path traversal detected".to_string()); |
| 590 | + } |
| 591 | + |
| 592 | + let file_path = canonical_target; |
| 593 | + let bytes = tokio::fs::read(&file_path).await.map_err(|e| e.to_string())?; |
| 594 | + |
| 595 | + let ext = file_path |
| 596 | + .extension() |
| 597 | + .and_then(|e| e.to_str()) |
| 598 | + .unwrap_or("") |
| 599 | + .to_lowercase(); |
| 600 | + let mime = match ext.as_str() { |
| 601 | + "png" => "image/png", |
| 602 | + "jpg" | "jpeg" => "image/jpeg", |
| 603 | + "gif" => "image/gif", |
| 604 | + "webp" => "image/webp", |
| 605 | + "svg" => "image/svg+xml", |
| 606 | + "webm" => "audio/webm", |
| 607 | + "m4a" | "mp4" => "audio/mp4", |
| 608 | + "aac" => "audio/aac", |
| 609 | + "wav" => "audio/wav", |
| 610 | + "mp3" => "audio/mpeg", |
| 611 | + "ogg" => "audio/ogg", |
| 612 | + _ => "application/octet-stream", |
| 613 | + }; |
| 614 | + |
| 615 | + let encoded = BASE64.encode(&bytes); |
| 616 | + Ok(format!("data:{};base64,{}", mime, encoded)) |
| 617 | +} |
0 commit comments