forked from
nekomimi.pet/wisp.place-monorepo
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, 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}