Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1mod builder_types; 2mod place_wisp; 3mod cid; 4mod blob_map; 5mod metadata; 6mod download; 7mod pull; 8mod serve; 9mod subfs_utils; 10mod redirects; 11mod ignore_patterns; 12 13use clap::{Parser, Subcommand}; 14use jacquard::CowStr; 15use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession}; 16use jacquard::oauth::client::OAuthClient; 17use jacquard::oauth::loopback::LoopbackConfig; 18use jacquard::prelude::IdentityResolver; 19use jacquard_common::types::string::{Datetime, Rkey, RecordKey, AtUri}; 20use jacquard_common::types::blob::MimeType; 21use miette::IntoDiagnostic; 22use std::path::{Path, PathBuf}; 23use std::collections::HashMap; 24use flate2::Compression; 25use flate2::write::GzEncoder; 26use std::io::Write; 27use base64::Engine; 28use futures::stream::{self, StreamExt}; 29 30use place_wisp::fs::*; 31use place_wisp::settings::*; 32 33#[derive(Parser, Debug)] 34#[command(author, version, about = "wisp.place CLI tool")] 35struct Args { 36 #[command(subcommand)] 37 command: Option<Commands>, 38 39 // Deploy arguments (when no subcommand is specified) 40 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 41 #[arg(global = true, conflicts_with = "command")] 42 input: Option<CowStr<'static>>, 43 44 /// Path to the directory containing your static site 45 #[arg(short, long, global = true, conflicts_with = "command")] 46 path: Option<PathBuf>, 47 48 /// Site name (defaults to directory name) 49 #[arg(short, long, global = true, conflicts_with = "command")] 50 site: Option<String>, 51 52 /// Path to auth store file 53 #[arg(long, global = true, conflicts_with = "command")] 54 store: Option<String>, 55 56 /// App Password for authentication 57 #[arg(long, global = true, conflicts_with = "command")] 58 password: Option<CowStr<'static>>, 59 60 /// Enable directory listing mode for paths without index files 61 #[arg(long, global = true, conflicts_with = "command")] 62 directory: bool, 63 64 /// Enable SPA mode (serve index.html for all routes) 65 #[arg(long, global = true, conflicts_with = "command")] 66 spa: bool, 67} 68 69#[derive(Subcommand, Debug)] 70enum Commands { 71 /// Deploy a static site to wisp.place (default command) 72 Deploy { 73 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 74 input: CowStr<'static>, 75 76 /// Path to the directory containing your static site 77 #[arg(short, long, default_value = ".")] 78 path: PathBuf, 79 80 /// Site name (defaults to directory name) 81 #[arg(short, long)] 82 site: Option<String>, 83 84 /// Path to auth store file (will be created if missing, only used with OAuth) 85 #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 86 store: String, 87 88 /// App Password for authentication (alternative to OAuth) 89 #[arg(long)] 90 password: Option<CowStr<'static>>, 91 92 /// Enable directory listing mode for paths without index files 93 #[arg(long)] 94 directory: bool, 95 96 /// Enable SPA mode (serve index.html for all routes) 97 #[arg(long)] 98 spa: bool, 99 }, 100 /// Pull a site from the PDS to a local directory 101 Pull { 102 /// Handle (e.g., alice.bsky.social) or DID 103 input: CowStr<'static>, 104 105 /// Site name (record key) 106 #[arg(short, long)] 107 site: String, 108 109 /// Output directory for the downloaded site 110 #[arg(short, long, default_value = ".")] 111 output: PathBuf, 112 }, 113 /// Serve a site locally with real-time firehose updates 114 Serve { 115 /// Handle (e.g., alice.bsky.social) or DID 116 input: CowStr<'static>, 117 118 /// Site name (record key) 119 #[arg(short, long)] 120 site: String, 121 122 /// Output directory for the site files 123 #[arg(short, long, default_value = ".")] 124 output: PathBuf, 125 126 /// Port to serve on 127 #[arg(short, long, default_value = "8080")] 128 port: u16, 129 }, 130} 131 132#[tokio::main] 133async fn main() -> miette::Result<()> { 134 let args = Args::parse(); 135 136 let result = match args.command { 137 Some(Commands::Deploy { input, path, site, store, password, directory, spa }) => { 138 // Dispatch to appropriate authentication method 139 if let Some(password) = password { 140 run_with_app_password(input, password, path, site, directory, spa).await 141 } else { 142 run_with_oauth(input, store, path, site, directory, spa).await 143 } 144 } 145 Some(Commands::Pull { input, site, output }) => { 146 pull::pull_site(input, CowStr::from(site), output).await 147 } 148 Some(Commands::Serve { input, site, output, port }) => { 149 serve::serve_site(input, CowStr::from(site), output, port).await 150 } 151 None => { 152 // Legacy mode: if input is provided, assume deploy command 153 if let Some(input) = args.input { 154 let path = args.path.unwrap_or_else(|| PathBuf::from(".")); 155 let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string()); 156 157 // Dispatch to appropriate authentication method 158 if let Some(password) = args.password { 159 run_with_app_password(input, password, path, args.site, args.directory, args.spa).await 160 } else { 161 run_with_oauth(input, store, path, args.site, args.directory, args.spa).await 162 } 163 } else { 164 // No command and no input, show help 165 use clap::CommandFactory; 166 Args::command().print_help().into_diagnostic()?; 167 Ok(()) 168 } 169 } 170 }; 171 172 // Force exit to avoid hanging on background tasks/connections 173 match result { 174 Ok(_) => std::process::exit(0), 175 Err(e) => { 176 eprintln!("{:?}", e); 177 std::process::exit(1) 178 } 179 } 180} 181 182/// Run deployment with app password authentication 183async fn run_with_app_password( 184 input: CowStr<'static>, 185 password: CowStr<'static>, 186 path: PathBuf, 187 site: Option<String>, 188 directory: bool, 189 spa: bool, 190) -> miette::Result<()> { 191 let (session, auth) = 192 MemoryCredentialSession::authenticated(input, password, None, None).await?; 193 println!("Signed in as {}", auth.handle); 194 195 let agent: Agent<_> = Agent::from(session); 196 deploy_site(&agent, path, site, directory, spa).await 197} 198 199/// Run deployment with OAuth authentication 200async fn run_with_oauth( 201 input: CowStr<'static>, 202 store: String, 203 path: PathBuf, 204 site: Option<String>, 205 directory: bool, 206 spa: bool, 207) -> miette::Result<()> { 208 use jacquard::oauth::scopes::Scope; 209 use jacquard::oauth::atproto::AtprotoClientMetadata; 210 use jacquard::oauth::session::ClientData; 211 use url::Url; 212 213 // Request the necessary scopes for wisp.place (including settings) 214 let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*") 215 .map_err(|e| miette::miette!("Failed to parse scopes: {:?}", e))?; 216 217 // Create redirect URIs that match the loopback server (port 4000, path /oauth/callback) 218 let redirect_uris = vec![ 219 Url::parse("http://127.0.0.1:4000/oauth/callback").into_diagnostic()?, 220 Url::parse("http://[::1]:4000/oauth/callback").into_diagnostic()?, 221 ]; 222 223 // Create client metadata with matching redirect URIs and scopes 224 let client_data = ClientData { 225 keyset: None, 226 config: AtprotoClientMetadata::new_localhost( 227 Some(redirect_uris), 228 Some(scopes), 229 ), 230 }; 231 232 let oauth = OAuthClient::new(FileAuthStore::new(&store), client_data); 233 234 let session = oauth 235 .login_with_local_server(input, Default::default(), LoopbackConfig::default()) 236 .await?; 237 238 let agent: Agent<_> = Agent::from(session); 239 deploy_site(&agent, path, site, directory, spa).await 240} 241 242/// Deploy the site using the provided agent 243async fn deploy_site( 244 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 245 path: PathBuf, 246 site: Option<String>, 247 directory_listing: bool, 248 spa_mode: bool, 249) -> miette::Result<()> { 250 // Verify the path exists 251 if !path.exists() { 252 return Err(miette::miette!("Path does not exist: {}", path.display())); 253 } 254 255 // Get site name 256 let site_name = site.unwrap_or_else(|| { 257 path 258 .file_name() 259 .and_then(|n| n.to_str()) 260 .unwrap_or("site") 261 .to_string() 262 }); 263 264 println!("Deploying site '{}'...", site_name); 265 266 // Try to fetch existing manifest for incremental updates 267 let (existing_blob_map, old_subfs_uris): (HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, Vec<(String, String)>) = { 268 use jacquard_common::types::string::AtUri; 269 270 // Get the DID for this session 271 let session_info = agent.session_info().await; 272 if let Some((did, _)) = session_info { 273 // Construct the AT URI for the record 274 let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name); 275 if let Ok(uri) = AtUri::new(&uri_string) { 276 match agent.get_record::<Fs>(&uri).await { 277 Ok(response) => { 278 match response.into_output() { 279 Ok(record_output) => { 280 let existing_manifest = record_output.value; 281 let mut blob_map = blob_map::extract_blob_map(&existing_manifest.root); 282 println!("Found existing manifest with {} files in main record", blob_map.len()); 283 284 // Extract subfs URIs from main record 285 let subfs_uris = subfs_utils::extract_subfs_uris(&existing_manifest.root, String::new()); 286 287 if !subfs_uris.is_empty() { 288 println!("Found {} subfs records, fetching for blob reuse...", subfs_uris.len()); 289 290 // Merge blob maps from all subfs records 291 match subfs_utils::merge_subfs_blob_maps(agent, subfs_uris.clone(), &mut blob_map).await { 292 Ok(merged_count) => { 293 println!("Total blob map: {} files (main + {} from subfs)", blob_map.len(), merged_count); 294 } 295 Err(e) => { 296 eprintln!("⚠️ Failed to merge some subfs blob maps: {}", e); 297 } 298 } 299 300 (blob_map, subfs_uris) 301 } else { 302 (blob_map, Vec::new()) 303 } 304 } 305 Err(_) => { 306 println!("No existing manifest found, uploading all files..."); 307 (HashMap::new(), Vec::new()) 308 } 309 } 310 } 311 Err(_) => { 312 // Record doesn't exist yet - this is a new site 313 println!("No existing manifest found, uploading all files..."); 314 (HashMap::new(), Vec::new()) 315 } 316 } 317 } else { 318 println!("No existing manifest found (invalid URI), uploading all files..."); 319 (HashMap::new(), Vec::new()) 320 } 321 } else { 322 println!("No existing manifest found (could not get DID), uploading all files..."); 323 (HashMap::new(), Vec::new()) 324 } 325 }; 326 327 // Build directory tree with ignore patterns 328 let ignore_matcher = ignore_patterns::IgnoreMatcher::new(&path)?; 329 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new(), &ignore_matcher).await?; 330 let uploaded_count = total_files - reused_count; 331 332 // Check if we need to split into subfs records 333 const MAX_MANIFEST_SIZE: usize = 140 * 1024; // 140KB (PDS limit is 150KB) 334 const FILE_COUNT_THRESHOLD: usize = 250; // Start splitting at this many files 335 const TARGET_FILE_COUNT: usize = 200; // Keep main manifest under this 336 337 let mut working_directory = root_dir; 338 let mut current_file_count = total_files; 339 let mut new_subfs_uris: Vec<(String, String)> = Vec::new(); 340 341 // Estimate initial manifest size 342 let mut manifest_size = subfs_utils::estimate_directory_size(&working_directory); 343 344 if total_files >= FILE_COUNT_THRESHOLD || manifest_size > MAX_MANIFEST_SIZE { 345 println!("\n⚠️ Large site detected ({} files, {:.1}KB manifest), splitting into subfs records...", 346 total_files, manifest_size as f64 / 1024.0); 347 348 let mut attempts = 0; 349 const MAX_SPLIT_ATTEMPTS: usize = 50; 350 351 while (manifest_size > MAX_MANIFEST_SIZE || current_file_count > TARGET_FILE_COUNT) && attempts < MAX_SPLIT_ATTEMPTS { 352 attempts += 1; 353 354 // Find large directories to split 355 let directories = subfs_utils::find_large_directories(&working_directory, String::new()); 356 357 if let Some(largest_dir) = directories.first() { 358 println!(" Split #{}: {} ({} files, {:.1}KB)", 359 attempts, largest_dir.path, largest_dir.file_count, largest_dir.size as f64 / 1024.0); 360 361 // Check if this directory is itself too large for a single subfs record 362 const MAX_SUBFS_SIZE: usize = 75 * 1024; // 75KB soft limit for safety 363 let mut subfs_uri = String::new(); 364 365 if largest_dir.size > MAX_SUBFS_SIZE { 366 // Need to split this directory into multiple chunks 367 println!(" → Directory too large, splitting into chunks..."); 368 let chunks = subfs_utils::split_directory_into_chunks(&largest_dir.directory, MAX_SUBFS_SIZE); 369 println!(" → Created {} chunks", chunks.len()); 370 371 // Upload each chunk as a subfs record 372 let mut chunk_uris = Vec::new(); 373 for (i, chunk) in chunks.iter().enumerate() { 374 use jacquard_common::types::string::Tid; 375 let chunk_tid = Tid::now_0(); 376 let chunk_rkey = chunk_tid.to_string(); 377 378 let chunk_file_count = subfs_utils::count_files_in_directory(chunk); 379 let chunk_size = subfs_utils::estimate_directory_size(chunk); 380 381 let chunk_manifest = crate::place_wisp::subfs::SubfsRecord::new() 382 .root(convert_fs_dir_to_subfs_dir(chunk.clone())) 383 .file_count(Some(chunk_file_count as i64)) 384 .created_at(Datetime::now()) 385 .build(); 386 387 println!(" → Uploading chunk {}/{} ({} files, {:.1}KB)...", 388 i + 1, chunks.len(), chunk_file_count, chunk_size as f64 / 1024.0); 389 390 let chunk_output = agent.put_record( 391 RecordKey::from(Rkey::new(&chunk_rkey).into_diagnostic()?), 392 chunk_manifest 393 ).await.into_diagnostic()?; 394 395 let chunk_uri = chunk_output.uri.to_string(); 396 chunk_uris.push((chunk_uri.clone(), format!("{}#{}", largest_dir.path, i))); 397 new_subfs_uris.push((chunk_uri.clone(), format!("{}#{}", largest_dir.path, i))); 398 } 399 400 // Create a parent subfs record that references all chunks 401 // Each chunk reference MUST have flat: true to merge chunk contents 402 println!(" → Creating parent subfs with {} chunk references...", chunk_uris.len()); 403 use jacquard_common::CowStr; 404 use crate::place_wisp::fs::{Subfs}; 405 406 // Convert to fs::Subfs (which has the 'flat' field) instead of subfs::Subfs 407 let parent_entries_fs: Vec<Entry> = chunk_uris.iter().enumerate().map(|(i, (uri, _))| { 408 let uri_string = uri.clone(); 409 let at_uri = AtUri::new_cow(CowStr::from(uri_string)).expect("valid URI"); 410 Entry::new() 411 .name(CowStr::from(format!("chunk{}", i))) 412 .node(EntryNode::Subfs(Box::new( 413 Subfs::new() 414 .r#type(CowStr::from("subfs")) 415 .subject(at_uri) 416 .flat(Some(true)) // EXPLICITLY TRUE - merge chunk contents 417 .build() 418 ))) 419 .build() 420 }).collect(); 421 422 let parent_root_fs = Directory::new() 423 .r#type(CowStr::from("directory")) 424 .entries(parent_entries_fs) 425 .build(); 426 427 // Convert to subfs::Directory for the parent subfs record 428 let parent_root_subfs = convert_fs_dir_to_subfs_dir(parent_root_fs); 429 430 use jacquard_common::types::string::Tid; 431 let parent_tid = Tid::now_0(); 432 let parent_rkey = parent_tid.to_string(); 433 434 let parent_manifest = crate::place_wisp::subfs::SubfsRecord::new() 435 .root(parent_root_subfs) 436 .file_count(Some(largest_dir.file_count as i64)) 437 .created_at(Datetime::now()) 438 .build(); 439 440 let parent_output = agent.put_record( 441 RecordKey::from(Rkey::new(&parent_rkey).into_diagnostic()?), 442 parent_manifest 443 ).await.into_diagnostic()?; 444 445 subfs_uri = parent_output.uri.to_string(); 446 println!(" ✅ Created parent subfs with chunks (flat=true on each chunk): {}", subfs_uri); 447 } else { 448 // Directory fits in a single subfs record 449 use jacquard_common::types::string::Tid; 450 let subfs_tid = Tid::now_0(); 451 let subfs_rkey = subfs_tid.to_string(); 452 453 let subfs_manifest = crate::place_wisp::subfs::SubfsRecord::new() 454 .root(convert_fs_dir_to_subfs_dir(largest_dir.directory.clone())) 455 .file_count(Some(largest_dir.file_count as i64)) 456 .created_at(Datetime::now()) 457 .build(); 458 459 // Upload subfs record 460 let subfs_output = agent.put_record( 461 RecordKey::from(Rkey::new(&subfs_rkey).into_diagnostic()?), 462 subfs_manifest 463 ).await.into_diagnostic()?; 464 465 subfs_uri = subfs_output.uri.to_string(); 466 println!(" ✅ Created subfs: {}", subfs_uri); 467 } 468 469 // Replace directory with subfs node (flat: false to preserve directory structure) 470 working_directory = subfs_utils::replace_directory_with_subfs( 471 working_directory, 472 &largest_dir.path, 473 &subfs_uri, 474 false // Preserve directory - the chunks inside have flat=true 475 )?; 476 477 new_subfs_uris.push((subfs_uri, largest_dir.path.clone())); 478 current_file_count -= largest_dir.file_count; 479 480 // Recalculate manifest size 481 manifest_size = subfs_utils::estimate_directory_size(&working_directory); 482 println!(" → Manifest now {:.1}KB with {} files ({} subfs total)", 483 manifest_size as f64 / 1024.0, current_file_count, new_subfs_uris.len()); 484 485 if manifest_size <= MAX_MANIFEST_SIZE && current_file_count <= TARGET_FILE_COUNT { 486 println!("✅ Manifest now fits within limits"); 487 break; 488 } 489 } else { 490 println!(" No more subdirectories to split - stopping"); 491 break; 492 } 493 } 494 495 if attempts >= MAX_SPLIT_ATTEMPTS { 496 return Err(miette::miette!( 497 "Exceeded maximum split attempts ({}). Manifest still too large: {:.1}KB with {} files", 498 MAX_SPLIT_ATTEMPTS, 499 manifest_size as f64 / 1024.0, 500 current_file_count 501 )); 502 } 503 504 println!("✅ Split complete: {} subfs records, {} files in main manifest, {:.1}KB", 505 new_subfs_uris.len(), current_file_count, manifest_size as f64 / 1024.0); 506 } else { 507 println!("Manifest created ({} files, {:.1}KB) - no splitting needed", 508 total_files, manifest_size as f64 / 1024.0); 509 } 510 511 // Create the final Fs record 512 let fs_record = Fs::new() 513 .site(CowStr::from(site_name.clone())) 514 .root(working_directory) 515 .file_count(current_file_count as i64) 516 .created_at(Datetime::now()) 517 .build(); 518 519 // Use site name as the record key 520 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 521 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 522 523 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 524 let uri_str = output.uri.to_string(); 525 let did = uri_str 526 .strip_prefix("at://") 527 .and_then(|s| s.split('/').next()) 528 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 529 530 println!("\n✓ Deployed site '{}': {}", site_name, output.uri); 531 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 532 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 533 534 // Clean up old subfs records 535 if !old_subfs_uris.is_empty() { 536 println!("\nCleaning up {} old subfs records...", old_subfs_uris.len()); 537 538 let mut deleted_count = 0; 539 let mut failed_count = 0; 540 541 for (uri, _path) in old_subfs_uris { 542 match subfs_utils::delete_subfs_record(agent, &uri).await { 543 Ok(_) => { 544 deleted_count += 1; 545 println!(" 🗑️ Deleted old subfs: {}", uri); 546 } 547 Err(e) => { 548 failed_count += 1; 549 eprintln!(" ⚠️ Failed to delete {}: {}", uri, e); 550 } 551 } 552 } 553 554 if failed_count > 0 { 555 eprintln!("⚠️ Cleanup completed with {} deleted, {} failed", deleted_count, failed_count); 556 } else { 557 println!("✅ Cleanup complete: {} old subfs records deleted", deleted_count); 558 } 559 } 560 561 // Upload settings if either flag is set 562 if directory_listing || spa_mode { 563 // Validate mutual exclusivity 564 if directory_listing && spa_mode { 565 return Err(miette::miette!("Cannot enable both --directory and --SPA modes")); 566 } 567 568 println!("\n⚙️ Uploading site settings..."); 569 570 // Build settings record 571 let mut settings_builder = Settings::new(); 572 573 if directory_listing { 574 settings_builder = settings_builder.directory_listing(Some(true)); 575 println!(" • Directory listing: enabled"); 576 } 577 578 if spa_mode { 579 settings_builder = settings_builder.spa_mode(Some(CowStr::from("index.html"))); 580 println!(" • SPA mode: enabled (serving index.html for all routes)"); 581 } 582 583 let settings_record = settings_builder.build(); 584 585 // Upload settings record with same rkey as site 586 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 587 match agent.put_record(RecordKey::from(rkey), settings_record).await { 588 Ok(settings_output) => { 589 println!("✅ Settings uploaded: {}", settings_output.uri); 590 } 591 Err(e) => { 592 eprintln!("⚠️ Failed to upload settings: {}", e); 593 eprintln!(" Site was deployed successfully, but settings may need to be configured manually."); 594 } 595 } 596 } 597 598 Ok(()) 599} 600 601/// Recursively build a Directory from a filesystem path 602/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir) 603fn build_directory<'a>( 604 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 605 dir_path: &'a Path, 606 existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 607 current_path: String, 608 ignore_matcher: &'a ignore_patterns::IgnoreMatcher, 609) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>> 610{ 611 Box::pin(async move { 612 // Collect all directory entries first 613 let dir_entries: Vec<_> = std::fs::read_dir(dir_path) 614 .into_diagnostic()? 615 .collect::<Result<Vec<_>, _>>() 616 .into_diagnostic()?; 617 618 // Separate files and directories 619 let mut file_tasks = Vec::new(); 620 let mut dir_tasks = Vec::new(); 621 622 for entry in dir_entries { 623 let path = entry.path(); 624 let name = entry.file_name(); 625 let name_str = name.to_str() 626 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))? 627 .to_string(); 628 629 // Construct full path for ignore checking 630 let full_path = if current_path.is_empty() { 631 name_str.clone() 632 } else { 633 format!("{}/{}", current_path, name_str) 634 }; 635 636 // Skip files/directories that match ignore patterns 637 if ignore_matcher.is_ignored(&full_path) || ignore_matcher.is_filename_ignored(&name_str) { 638 continue; 639 } 640 641 let metadata = entry.metadata().into_diagnostic()?; 642 643 if metadata.is_file() { 644 // Construct full path for this file (for blob map lookup) 645 let full_path = if current_path.is_empty() { 646 name_str.clone() 647 } else { 648 format!("{}/{}", current_path, name_str) 649 }; 650 file_tasks.push((name_str, path, full_path)); 651 } else if metadata.is_dir() { 652 dir_tasks.push((name_str, path)); 653 } 654 } 655 656 // Process files concurrently with a limit of 5 657 let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks) 658 .map(|(name, path, full_path)| async move { 659 let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?; 660 let entry = Entry::new() 661 .name(CowStr::from(name)) 662 .node(EntryNode::File(Box::new(file_node))) 663 .build(); 664 Ok::<_, miette::Report>((entry, reused)) 665 }) 666 .buffer_unordered(5) 667 .collect::<Vec<_>>() 668 .await 669 .into_iter() 670 .collect::<miette::Result<Vec<_>>>()?; 671 672 let mut file_entries = Vec::new(); 673 let mut reused_count = 0; 674 let mut total_files = 0; 675 676 for (entry, reused) in file_results { 677 file_entries.push(entry); 678 total_files += 1; 679 if reused { 680 reused_count += 1; 681 } 682 } 683 684 // Process directories recursively (sequentially to avoid too much nesting) 685 let mut dir_entries = Vec::new(); 686 for (name, path) in dir_tasks { 687 // Construct full path for subdirectory 688 let subdir_path = if current_path.is_empty() { 689 name.clone() 690 } else { 691 format!("{}/{}", current_path, name) 692 }; 693 let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path, ignore_matcher).await?; 694 dir_entries.push(Entry::new() 695 .name(CowStr::from(name)) 696 .node(EntryNode::Directory(Box::new(subdir))) 697 .build()); 698 total_files += sub_total; 699 reused_count += sub_reused; 700 } 701 702 // Combine file and directory entries 703 let mut entries = file_entries; 704 entries.extend(dir_entries); 705 706 let directory = Directory::new() 707 .r#type(CowStr::from("directory")) 708 .entries(entries) 709 .build(); 710 711 Ok((directory, total_files, reused_count)) 712 }) 713} 714 715/// Process a single file: gzip -> base64 -> upload blob (or reuse existing) 716/// Returns (File, reused: bool) 717/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup 718/// 719/// Special handling: _redirects files are NOT compressed (uploaded as-is) 720async fn process_file( 721 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 722 file_path: &Path, 723 file_path_key: &str, 724 existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 725) -> miette::Result<(File<'static>, bool)> 726{ 727 // Read file 728 let file_data = std::fs::read(file_path).into_diagnostic()?; 729 730 // Detect original MIME type 731 let original_mime = mime_guess::from_path(file_path) 732 .first_or_octet_stream() 733 .to_string(); 734 735 // Check if this is a _redirects file (don't compress it) 736 let is_redirects_file = file_path.file_name() 737 .and_then(|n| n.to_str()) 738 .map(|n| n == "_redirects") 739 .unwrap_or(false); 740 741 let (upload_bytes, encoding, is_base64) = if is_redirects_file { 742 // Don't compress _redirects - upload as-is 743 (file_data.clone(), None, false) 744 } else { 745 // Gzip compress 746 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 747 encoder.write_all(&file_data).into_diagnostic()?; 748 let gzipped = encoder.finish().into_diagnostic()?; 749 750 // Base64 encode the gzipped data 751 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 752 (base64_bytes, Some("gzip"), true) 753 }; 754 755 // Compute CID for this file 756 let file_cid = cid::compute_cid(&upload_bytes); 757 758 // Check if we have an existing blob with the same CID 759 let existing_blob = existing_blobs.get(file_path_key); 760 761 if let Some((existing_blob_ref, existing_cid)) = existing_blob { 762 if existing_cid == &file_cid { 763 // CIDs match - reuse existing blob 764 println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid); 765 let mut file_builder = File::new() 766 .r#type(CowStr::from("file")) 767 .blob(existing_blob_ref.clone()) 768 .mime_type(CowStr::from(original_mime)); 769 770 if let Some(enc) = encoding { 771 file_builder = file_builder.encoding(CowStr::from(enc)); 772 } 773 if is_base64 { 774 file_builder = file_builder.base64(true); 775 } 776 777 return Ok((file_builder.build(), true)); 778 } else { 779 // CID mismatch - file changed 780 println!(" → File changed: {} (old CID: {}, new CID: {})", file_path_key, existing_cid, file_cid); 781 } 782 } else { 783 // File not in existing blob map 784 if file_path_key.starts_with("imgs/") { 785 println!(" → New file (not in blob map): {}", file_path_key); 786 } 787 } 788 789 // File is new or changed - upload it 790 let mime_type = if is_redirects_file { 791 MimeType::new_static("text/plain") 792 } else { 793 MimeType::new_static("application/octet-stream") 794 }; 795 796 println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, upload_bytes.len(), file_cid); 797 let blob = agent.upload_blob(upload_bytes, mime_type).await?; 798 799 let mut file_builder = File::new() 800 .r#type(CowStr::from("file")) 801 .blob(blob) 802 .mime_type(CowStr::from(original_mime)); 803 804 if let Some(enc) = encoding { 805 file_builder = file_builder.encoding(CowStr::from(enc)); 806 } 807 if is_base64 { 808 file_builder = file_builder.base64(true); 809 } 810 811 Ok((file_builder.build(), false)) 812} 813 814/// Convert fs::Directory to subfs::Directory 815/// They have the same structure, but different types 816fn convert_fs_dir_to_subfs_dir(fs_dir: place_wisp::fs::Directory<'static>) -> place_wisp::subfs::Directory<'static> { 817 use place_wisp::subfs::{Directory as SubfsDirectory, Entry as SubfsEntry, EntryNode as SubfsEntryNode, File as SubfsFile}; 818 819 let subfs_entries: Vec<SubfsEntry> = fs_dir.entries.into_iter().map(|entry| { 820 let node = match entry.node { 821 place_wisp::fs::EntryNode::File(file) => { 822 SubfsEntryNode::File(Box::new(SubfsFile::new() 823 .r#type(file.r#type) 824 .blob(file.blob) 825 .encoding(file.encoding) 826 .mime_type(file.mime_type) 827 .base64(file.base64) 828 .build())) 829 } 830 place_wisp::fs::EntryNode::Directory(dir) => { 831 SubfsEntryNode::Directory(Box::new(convert_fs_dir_to_subfs_dir(*dir))) 832 } 833 place_wisp::fs::EntryNode::Subfs(subfs) => { 834 // Nested subfs in the directory we're converting 835 // Note: subfs::Subfs doesn't have the 'flat' field - that's only in fs::Subfs 836 SubfsEntryNode::Subfs(Box::new(place_wisp::subfs::Subfs::new() 837 .r#type(subfs.r#type) 838 .subject(subfs.subject) 839 .build())) 840 } 841 place_wisp::fs::EntryNode::Unknown(unknown) => { 842 SubfsEntryNode::Unknown(unknown) 843 } 844 }; 845 846 SubfsEntry::new() 847 .name(entry.name) 848 .node(node) 849 .build() 850 }).collect(); 851 852 SubfsDirectory::new() 853 .r#type(fs_dir.r#type) 854 .entries(subfs_entries) 855 .build() 856} 857