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};
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}