[WIP] A (somewhat barebones) atproto app for creating custom sites without hosting!
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}