[WIP] A (somewhat barebones) atproto app for creating custom sites without hosting!
at main 9.1 kB view raw
1use clap::{ArgAction, Parser}; 2use jacquard::api::com_atproto::repo::apply_writes::{ 3 self, ApplyWrites, ApplyWritesOutput, ApplyWritesWritesItem, 4}; 5use jacquard::client::AgentSessionExt; 6use jacquard::client::MemorySessionStore; 7use jacquard::oauth::loopback::LoopbackConfig; 8use jacquard::oauth::types::AuthorizeOptions; 9use jacquard::types::string::{AtStrError, RecordKey, Rkey}; 10use jacquard::{AuthorizationToken, CowStr, atproto, oauth}; 11use jacquard::{ 12 Data, 13 api::com_atproto::{self, repo::list_records::ListRecords}, 14 client::{Agent, credential_session::CredentialSession}, 15 cowstr::ToCowStr, 16 identity::JacquardResolver, 17 types::{ident::AtIdentifier, nsid::Nsid, string::AtprotoStr, uri::Uri}, 18 xrpc::XrpcExt, 19}; 20use miette::{Context, IntoDiagnostic, Result}; 21use std::io::Write; 22use std::{collections::HashMap, fs, path::PathBuf}; 23 24use crate::sitemap::{BlobRef, Sitemap, SitemapNode}; 25 26mod sitemap; 27mod utils; 28 29#[derive(Parser, Debug, Clone)] 30#[command(version, about, long_about = None)] 31struct Config { 32 /// Handle or DID to authenticate 33 #[arg(verbatim_doc_comment)] 34 user: String, 35 36 /// App password to authenticate the client 37 /// Normal passwords also work but are not advised 38 /// If ommited, oauth will be used instead 39 /// Oauth is reccomended where possible. 40 #[arg(verbatim_doc_comment, short = 'p', long = "password")] 41 password: Option<String>, 42 43 /// Include dotfiles in upload 44 /// Default: false 45 #[arg(verbatim_doc_comment, short = 'a', long = "all")] 46 all_files: bool, 47 48 /// Respect gitignore files 49 /// Note: gitignore files are not uploaded unless --all is set 50 /// Default: true 51 #[arg(verbatim_doc_comment, short = 'g', long = "no-gitignore", action = ArgAction::SetFalse, )] 52 git_ignore: bool, 53 54 /// Directory to upload 55 #[arg(verbatim_doc_comment)] 56 dir: PathBuf, 57} 58 59async fn live_records(agent: &impl AgentSessionExt, config: Config) -> Result<Vec<String>> { 60 // find live site records 61 let mut cursor = None; 62 let mut remote_records = Vec::new(); 63 let user = config.user.clone(); 64 let user = if user.contains(":") { 65 AtIdentifier::Did(user.into()) 66 } else { 67 AtIdentifier::Handle(user.into()) 68 }; 69 loop { 70 let req = com_atproto::repo::list_records::ListRecords::new() 71 .collection( 72 Nsid::new("dev.atcities.route").expect("failed to generate dev.atcities.route nsid"), 73 ) 74 .repo(user.clone()) 75 .limit(100) 76 .maybe_cursor(cursor) 77 .build(); 78 79 let res = agent 80 .xrpc(agent.endpoint().await) 81 .send::<ListRecords>(&req) 82 .await? 83 .into_output()?; 84 85 for record in res.records { 86 match record { 87 Data::Object(obj) => { 88 let obj = obj.0.clone(); 89 let uri = obj.get_key_value("uri").and_then(|x| match x.1 { 90 Data::String(str) => Some(str), 91 _ => None, 92 }); 93 94 if let Some(uri) = uri 95 && let AtprotoStr::Uri(uri) = uri 96 && let Uri::At(uri) = uri 97 && let Some(rkey) = uri.rkey() 98 { 99 let rkey = rkey.0.to_cowstr().to_string(); 100 remote_records.push(rkey); 101 } else { 102 panic!("Warning: pds returned invalid data.") 103 } 104 } 105 _ => { 106 panic!("Warning: pds returned invalid data.") 107 } 108 } 109 } 110 111 cursor = res.cursor; 112 if cursor.is_none() { 113 break; 114 } 115 } 116 Ok(remote_records) 117} 118 119async fn upload_site_blobs( 120 agent: &impl AgentSessionExt, 121 _config: Config, 122 local_sitemap: Sitemap, 123) -> Result<Sitemap> { 124 // upload local site blobs 125 let mut new_sitemap: Sitemap = HashMap::new(); 126 for (k, v) in local_sitemap { 127 print!("Uploading {k}... "); 128 let _ = std::io::stdout().flush(); 129 let blob = match v.blob { 130 BlobRef::Local(path) => path, 131 BlobRef::Remote(_) => { 132 panic!("Impossible state") 133 } 134 }; 135 let blob = fs::read(blob).into_diagnostic()?; 136 137 let req = com_atproto::repo::upload_blob::UploadBlob::new() 138 .body(blob.into()) 139 .build(); 140 let res = agent.send(req).await?.into_output()?; 141 142 new_sitemap.insert( 143 k, 144 SitemapNode { 145 mime_type: v.mime_type, 146 blob: BlobRef::Remote(res.blob.into()), 147 }, 148 ); 149 150 println!("Done!"); 151 } 152 153 Ok(new_sitemap) 154} 155 156async fn update_remote_site( 157 agent: &impl AgentSessionExt, 158 config: Config, 159 auth: AuthorizationToken<'_>, 160 remote_records: Vec<String>, 161 new_sitemap: Sitemap, 162) -> Result<ApplyWritesOutput<'static>> { 163 // batch delete/upload records 164 let mut writes = Vec::new(); 165 let mut delete_records = remote_records 166 .into_iter() 167 .map(|x| { 168 let rkey = RecordKey(Rkey::new_owned(x)?); 169 Ok(ApplyWritesWritesItem::Delete(Box::new( 170 apply_writes::Delete::builder() 171 .collection(Nsid::raw("dev.atcities.route")) 172 .rkey(rkey) 173 .build(), 174 ))) 175 }) 176 .collect::<Result<Vec<ApplyWritesWritesItem<'_>>, AtStrError>>() 177 .into_diagnostic()?; 178 179 let mut create_records = new_sitemap 180 .into_iter() 181 .map(|(k, v)| { 182 let k = match k.as_str() { 183 "404.html" => String::from("404"), 184 "index.html" => String::from("/"), 185 _ => match k.strip_suffix("/index.html") { 186 Some(k) => format!("/{k}/"), 187 None => format!("/{k}"), 188 } 189 }; 190 let rkey = 191 utils::url_to_rkey(k).wrap_err("Invalid file path. Could not be converted to rkey")?; 192 let rkey = RecordKey(Rkey::new_owned(rkey).into_diagnostic()?); 193 let blob = match v.blob { 194 BlobRef::Local(_) => panic!("Illegal local blob"), 195 BlobRef::Remote(cid) => cid, 196 }; 197 let data = atproto!({ 198 "page": { 199 "$type": "dev.atcities.route#blob", 200 "blob": { 201 "$type": "blob", 202 "ref": { 203 "$link": blob.r#ref.as_str() 204 }, 205 "mimeType": blob.mime_type.0.as_str(), 206 "size": blob.size 207 } 208 } 209 }); 210 Ok(ApplyWritesWritesItem::Create(Box::new( 211 apply_writes::Create::builder() 212 .collection(Nsid::raw("dev.atcities.route")) 213 .rkey(rkey) 214 .value(data) 215 .build(), 216 ))) 217 }) 218 .collect::<Result<Vec<ApplyWritesWritesItem<'_>>, miette::Error>>()?; 219 220 writes.append(&mut delete_records); 221 writes.append(&mut create_records); 222 223 let repo = if config.user.contains(":") { 224 AtIdentifier::Did(config.user.into()) 225 } else { 226 AtIdentifier::Handle(config.user.into()) 227 }; 228 229 let req = com_atproto::repo::apply_writes::ApplyWrites::new() 230 .repo(repo) 231 .writes(writes) 232 .build(); 233 234 let res = agent 235 .xrpc(agent.endpoint().await) 236 .auth(auth) 237 .send::<ApplyWrites>(&req) 238 .await? 239 .into_output()?; 240 241 Ok(res) 242} 243 244#[tokio::main] 245async fn main() -> Result<(), miette::Error> { 246 env_logger::init(); 247 // get config items 248 let config = Config::parse(); 249 250 // get local site info 251 let local_sitemap = 252 sitemap::local_sitemap(config.dir.clone(), config.all_files, config.git_ignore)?; 253 254 // create session 255 if let Some(password) = config.password.clone() { 256 let password = password.into(); 257 let client = JacquardResolver::default(); 258 let store = MemorySessionStore::default(); 259 let session = CredentialSession::new(store.into(), client.into()); 260 261 let auth = session 262 .login(config.user.clone().into(), password, None, None, None) 263 .await?; 264 265 let agent = Agent::from(session); 266 267 let remote_sitemap = live_records(&agent, config.clone()).await?; 268 let new_sitemap = upload_site_blobs(&agent, config.clone(), local_sitemap).await?; 269 let _ = update_remote_site( 270 &agent, 271 config.clone(), 272 AuthorizationToken::Bearer(auth.access_jwt), 273 remote_sitemap, 274 new_sitemap, 275 ) 276 .await?; 277 278 println!( 279 "Site is now updated. Live at {}", 280 utils::site_handle(config.user) 281 ); 282 } else { 283 let oauth = oauth::client::OAuthClient::with_memory_store(); 284 let session = oauth 285 .login_with_local_server( 286 config.user.clone(), 287 AuthorizeOptions::default(), 288 LoopbackConfig::default(), 289 ) 290 .await?; 291 292 // sick and twisted reference mangling BUT it works So 293 // tldr: the cowstr is a borrowed cowstr iiuc, 294 // so it needs to be turned into an owned cowstr 295 // to break reference to session which gets moved 296 let auth = session.access_token().await; 297 let auth = match auth { 298 AuthorizationToken::Bearer(cow_str) => CowStr::copy_from_str(cow_str.as_str()), 299 AuthorizationToken::Dpop(cow_str) => CowStr::copy_from_str(cow_str.as_str()), 300 }; 301 302 println!("{}", auth); 303 304 let agent = Agent::from(session); 305 306 let remote_sitemap = live_records(&agent, config.clone()).await?; 307 let new_sitemap = upload_site_blobs(&agent, config.clone(), local_sitemap).await?; 308 let _ = update_remote_site( 309 &agent, 310 config.clone(), 311 AuthorizationToken::Dpop(auth), 312 remote_sitemap, 313 new_sitemap, 314 ) 315 .await?; 316 317 println!( 318 "Site is now updated. Live at {}", 319 utils::site_handle(config.user) 320 ); 321 }; 322 323 Ok(()) 324}