Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1mod builder_types; 2mod place_wisp; 3 4use clap::Parser; 5use jacquard::CowStr; 6use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession}; 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; 18use futures::stream::{self, StreamExt}; 19 20use place_wisp::fs::*; 21 22#[derive(Parser, Debug)] 23#[command(author, version, about = "Deploy a static site to wisp.place")] 24struct Args { 25 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 26 input: CowStr<'static>, 27 28 /// Path to the directory containing your static site 29 #[arg(short, long, default_value = ".")] 30 path: PathBuf, 31 32 /// Site name (defaults to directory name) 33 #[arg(short, long)] 34 site: Option<String>, 35 36 /// Path to auth store file (will be created if missing, only used with OAuth) 37 #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 38 store: String, 39 40 /// App Password for authentication (alternative to OAuth) 41 #[arg(long)] 42 password: Option<CowStr<'static>>, 43} 44 45#[tokio::main] 46async fn main() -> miette::Result<()> { 47 let args = Args::parse(); 48 49 // Dispatch to appropriate authentication method 50 if let Some(password) = args.password { 51 run_with_app_password(args.input, password, args.path, args.site).await 52 } else { 53 run_with_oauth(args.input, args.store, args.path, args.site).await 54 } 55} 56 57/// Run deployment with app password authentication 58async fn run_with_app_password( 59 input: CowStr<'static>, 60 password: CowStr<'static>, 61 path: PathBuf, 62 site: Option<String>, 63) -> miette::Result<()> { 64 let (session, auth) = 65 MemoryCredentialSession::authenticated(input, password, None).await?; 66 println!("Signed in as {}", auth.handle); 67 68 let agent: Agent<_> = Agent::from(session); 69 deploy_site(&agent, path, site).await 70} 71 72/// Run deployment with OAuth authentication 73async fn run_with_oauth( 74 input: CowStr<'static>, 75 store: String, 76 path: PathBuf, 77 site: Option<String>, 78) -> miette::Result<()> { 79 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store)); 80 let session = oauth 81 .login_with_local_server(input, Default::default(), LoopbackConfig::default()) 82 .await?; 83 84 let agent: Agent<_> = Agent::from(session); 85 deploy_site(&agent, path, site).await 86} 87 88/// Deploy the site using the provided agent 89async fn deploy_site( 90 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 91 path: PathBuf, 92 site: Option<String>, 93) -> miette::Result<()> { 94 // Verify the path exists 95 if !path.exists() { 96 return Err(miette::miette!("Path does not exist: {}", path.display())); 97 } 98 99 // Get site name 100 let site_name = site.unwrap_or_else(|| { 101 path 102 .file_name() 103 .and_then(|n| n.to_str()) 104 .unwrap_or("site") 105 .to_string() 106 }); 107 108 println!("Deploying site '{}'...", site_name); 109 110 // Build directory tree 111 let root_dir = build_directory(agent, &path).await?; 112 113 // Count total files 114 let file_count = count_files(&root_dir); 115 116 // Create the Fs record 117 let fs_record = Fs::new() 118 .site(CowStr::from(site_name.clone())) 119 .root(root_dir) 120 .file_count(file_count as i64) 121 .created_at(Datetime::now()) 122 .build(); 123 124 // Use site name as the record key 125 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 126 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 127 128 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 129 let uri_str = output.uri.to_string(); 130 let did = uri_str 131 .strip_prefix("at://") 132 .and_then(|s| s.split('/').next()) 133 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 134 135 println!("Deployed site '{}': {}", site_name, output.uri); 136 println!("Available at: https://sites.wisp.place/{}/{}", did, site_name); 137 138 Ok(()) 139} 140 141/// Recursively build a Directory from a filesystem path 142fn build_directory<'a>( 143 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 144 dir_path: &'a Path, 145) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>> 146{ 147 Box::pin(async move { 148 // Collect all directory entries first 149 let dir_entries: Vec<_> = std::fs::read_dir(dir_path) 150 .into_diagnostic()? 151 .collect::<Result<Vec<_>, _>>() 152 .into_diagnostic()?; 153 154 // Separate files and directories 155 let mut file_tasks = Vec::new(); 156 let mut dir_tasks = Vec::new(); 157 158 for entry in dir_entries { 159 let path = entry.path(); 160 let name = entry.file_name(); 161 let name_str = name.to_str() 162 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))? 163 .to_string(); 164 165 // Skip hidden files 166 if name_str.starts_with('.') { 167 continue; 168 } 169 170 let metadata = entry.metadata().into_diagnostic()?; 171 172 if metadata.is_file() { 173 file_tasks.push((name_str, path)); 174 } else if metadata.is_dir() { 175 dir_tasks.push((name_str, path)); 176 } 177 } 178 179 // Process files concurrently with a limit of 5 180 let file_entries: Vec<Entry> = stream::iter(file_tasks) 181 .map(|(name, path)| async move { 182 let file_node = process_file(agent, &path).await?; 183 Ok::<_, miette::Report>(Entry::new() 184 .name(CowStr::from(name)) 185 .node(EntryNode::File(Box::new(file_node))) 186 .build()) 187 }) 188 .buffer_unordered(5) 189 .collect::<Vec<_>>() 190 .await 191 .into_iter() 192 .collect::<miette::Result<Vec<_>>>()?; 193 194 // Process directories recursively (sequentially to avoid too much nesting) 195 let mut dir_entries = Vec::new(); 196 for (name, path) in dir_tasks { 197 let subdir = build_directory(agent, &path).await?; 198 dir_entries.push(Entry::new() 199 .name(CowStr::from(name)) 200 .node(EntryNode::Directory(Box::new(subdir))) 201 .build()); 202 } 203 204 // Combine file and directory entries 205 let mut entries = file_entries; 206 entries.extend(dir_entries); 207 208 Ok(Directory::new() 209 .r#type(CowStr::from("directory")) 210 .entries(entries) 211 .build()) 212 }) 213} 214 215/// Process a single file: gzip -> base64 -> upload blob 216async fn process_file( 217 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 218 file_path: &Path, 219) -> miette::Result<File<'static>> 220{ 221 // Read file 222 let file_data = std::fs::read(file_path).into_diagnostic()?; 223 224 // Detect original MIME type 225 let original_mime = mime_guess::from_path(file_path) 226 .first_or_octet_stream() 227 .to_string(); 228 229 // Gzip compress 230 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 231 encoder.write_all(&file_data).into_diagnostic()?; 232 let gzipped = encoder.finish().into_diagnostic()?; 233 234 // Base64 encode the gzipped data 235 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 236 237 // Upload blob as octet-stream 238 let blob = agent.upload_blob( 239 base64_bytes, 240 MimeType::new_static("application/octet-stream"), 241 ).await?; 242 243 Ok(File::new() 244 .r#type(CowStr::from("file")) 245 .blob(blob) 246 .encoding(CowStr::from("gzip")) 247 .mime_type(CowStr::from(original_mime)) 248 .base64(true) 249 .build()) 250} 251 252/// Count total files in a directory tree 253fn count_files(dir: &Directory) -> usize { 254 let mut count = 0; 255 for entry in &dir.entries { 256 match &entry.node { 257 EntryNode::File(_) => count += 1, 258 EntryNode::Directory(subdir) => count += count_files(subdir), 259 _ => {} // Unknown variants 260 } 261 } 262 count 263}