Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1mod builder_types; 2mod place_wisp; 3 4use clap::Parser; 5use jacquard::CowStr; 6use jacquard::client::{Agent, FileAuthStore, AgentSessionExt}; 7use jacquard::oauth::client::OAuthClient; 8use jacquard::oauth::loopback::LoopbackConfig; 9use jacquard::prelude::IdentityResolver; 10use jacquard_common::types::string::{Datetime, Rkey, RecordKey}; 11use jacquard_common::types::blob::MimeType; 12use miette::IntoDiagnostic; 13use std::path::{Path, PathBuf}; 14use flate2::Compression; 15use flate2::write::GzEncoder; 16use std::io::Write; 17use base64::Engine; 18 19use place_wisp::fs::*; 20 21#[derive(Parser, Debug)] 22#[command(author, version, about = "Deploy a static site to wisp.place")] 23struct Args { 24 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 25 input: CowStr<'static>, 26 27 /// Path to the directory containing your static site 28 #[arg(short, long, default_value = ".")] 29 path: PathBuf, 30 31 /// Site name (defaults to directory name) 32 #[arg(short, long)] 33 site: Option<String>, 34 35 /// Path to auth store file (will be created if missing) 36 #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 37 store: String, 38} 39 40#[tokio::main] 41async fn main() -> miette::Result<()> { 42 let args = Args::parse(); 43 44 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 45 let session = oauth 46 .login_with_local_server(args.input, Default::default(), LoopbackConfig::default()) 47 .await?; 48 49 let agent: Agent<_> = Agent::from(session); 50 51 // Verify the path exists 52 if !args.path.exists() { 53 return Err(miette::miette!("Path does not exist: {}", args.path.display())); 54 } 55 56 // Get site name 57 let site_name = args.site.unwrap_or_else(|| { 58 args.path 59 .file_name() 60 .and_then(|n| n.to_str()) 61 .unwrap_or("site") 62 .to_string() 63 }); 64 65 println!("Deploying site '{}'...", site_name); 66 67 // Build directory tree 68 let root_dir = build_directory(&agent, &args.path).await?; 69 70 // Count total files 71 let file_count = count_files(&root_dir); 72 73 // Create the Fs record 74 let fs_record = Fs::new() 75 .site(CowStr::from(site_name.clone())) 76 .root(root_dir) 77 .file_count(file_count as i64) 78 .created_at(Datetime::now()) 79 .build(); 80 81 // Use site name as the record key 82 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 83 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 84 85 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 86 let uri_str = output.uri.to_string(); 87 let did = uri_str 88 .strip_prefix("at://") 89 .and_then(|s| s.split('/').next()) 90 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 91 92 println!("Deployed site '{}': {}", site_name, output.uri); 93 println!("Available at: https://sites.wisp.place/{}/{}", did, site_name); 94 95 Ok(()) 96} 97 98/// Recursively build a Directory from a filesystem path 99fn build_directory<'a>( 100 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 101 dir_path: &'a Path, 102) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>> 103{ 104 Box::pin(async move { 105 let mut entries = Vec::new(); 106 107 for entry in std::fs::read_dir(dir_path).into_diagnostic()? { 108 let entry = entry.into_diagnostic()?; 109 let path = entry.path(); 110 let name = entry.file_name(); 111 let name_str = name.to_str() 112 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?; 113 114 // Skip hidden files 115 if name_str.starts_with('.') { 116 continue; 117 } 118 119 let metadata = entry.metadata().into_diagnostic()?; 120 121 if metadata.is_file() { 122 let file_node = process_file(agent, &path).await?; 123 entries.push(Entry::new() 124 .name(CowStr::from(name_str.to_string())) 125 .node(EntryNode::File(Box::new(file_node))) 126 .build()); 127 } else if metadata.is_dir() { 128 let subdir = build_directory(agent, &path).await?; 129 entries.push(Entry::new() 130 .name(CowStr::from(name_str.to_string())) 131 .node(EntryNode::Directory(Box::new(subdir))) 132 .build()); 133 } 134 } 135 136 Ok(Directory::new() 137 .r#type(CowStr::from("directory")) 138 .entries(entries) 139 .build()) 140 }) 141} 142 143/// Process a single file: gzip -> base64 -> upload blob 144async fn process_file( 145 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 146 file_path: &Path, 147) -> miette::Result<File<'static>> 148{ 149 // Read file 150 let file_data = std::fs::read(file_path).into_diagnostic()?; 151 152 // Detect original MIME type 153 let original_mime = mime_guess::from_path(file_path) 154 .first_or_octet_stream() 155 .to_string(); 156 157 // Gzip compress 158 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 159 encoder.write_all(&file_data).into_diagnostic()?; 160 let gzipped = encoder.finish().into_diagnostic()?; 161 162 // Base64 encode the gzipped data 163 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 164 165 // Upload blob as octet-stream 166 let blob = agent.upload_blob( 167 base64_bytes, 168 MimeType::new_static("application/octet-stream"), 169 ).await?; 170 171 Ok(File::new() 172 .r#type(CowStr::from("file")) 173 .blob(blob) 174 .encoding(CowStr::from("gzip")) 175 .mime_type(CowStr::from(original_mime)) 176 .base64(true) 177 .build()) 178} 179 180/// Count total files in a directory tree 181fn count_files(dir: &Directory) -> usize { 182 let mut count = 0; 183 for entry in &dir.entries { 184 match &entry.node { 185 EntryNode::File(_) => count += 1, 186 EntryNode::Directory(subdir) => count += count_files(subdir), 187 _ => {} // Unknown variants 188 } 189 } 190 count 191}