Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

place.wisp.settings support for the cli

nekomimi.pet 778efd81 6f6d06da

verified
Changed files
+992 -25
cli
+1 -1
cli/Cargo.lock
··· 4939 4939 4940 4940 [[package]] 4941 4941 name = "wisp-cli" 4942 - version = "0.3.0" 4942 + version = "0.4.0" 4943 4943 dependencies = [ 4944 4944 "axum", 4945 4945 "base64 0.22.1",
+1 -1
cli/Cargo.toml
··· 1 1 [package] 2 2 name = "wisp-cli" 3 - version = "0.3.0" 3 + version = "0.4.0" 4 4 edition = "2024" 5 5 6 6 [features]
+74 -9
cli/src/main.rs
··· 27 27 use futures::stream::{self, StreamExt}; 28 28 29 29 use place_wisp::fs::*; 30 + use place_wisp::settings::*; 30 31 31 32 #[derive(Parser, Debug)] 32 33 #[command(author, version, about = "wisp.place CLI tool")] ··· 54 55 /// App Password for authentication 55 56 #[arg(long, global = true, conflicts_with = "command")] 56 57 password: Option<CowStr<'static>>, 58 + 59 + /// Enable directory listing mode for paths without index files 60 + #[arg(long, global = true, conflicts_with = "command")] 61 + directory: bool, 62 + 63 + /// Enable SPA mode (serve index.html for all routes) 64 + #[arg(long, global = true, conflicts_with = "command")] 65 + spa: bool, 57 66 } 58 67 59 68 #[derive(Subcommand, Debug)] ··· 78 87 /// App Password for authentication (alternative to OAuth) 79 88 #[arg(long)] 80 89 password: Option<CowStr<'static>>, 90 + 91 + /// Enable directory listing mode for paths without index files 92 + #[arg(long)] 93 + directory: bool, 94 + 95 + /// Enable SPA mode (serve index.html for all routes) 96 + #[arg(long)] 97 + spa: bool, 81 98 }, 82 99 /// Pull a site from the PDS to a local directory 83 100 Pull { ··· 116 133 let args = Args::parse(); 117 134 118 135 let result = match args.command { 119 - Some(Commands::Deploy { input, path, site, store, password }) => { 136 + Some(Commands::Deploy { input, path, site, store, password, directory, spa }) => { 120 137 // Dispatch to appropriate authentication method 121 138 if let Some(password) = password { 122 - run_with_app_password(input, password, path, site).await 139 + run_with_app_password(input, password, path, site, directory, spa).await 123 140 } else { 124 - run_with_oauth(input, store, path, site).await 141 + run_with_oauth(input, store, path, site, directory, spa).await 125 142 } 126 143 } 127 144 Some(Commands::Pull { input, site, output }) => { ··· 138 155 139 156 // Dispatch to appropriate authentication method 140 157 if let Some(password) = args.password { 141 - run_with_app_password(input, password, path, args.site).await 158 + run_with_app_password(input, password, path, args.site, args.directory, args.spa).await 142 159 } else { 143 - run_with_oauth(input, store, path, args.site).await 160 + run_with_oauth(input, store, path, args.site, args.directory, args.spa).await 144 161 } 145 162 } else { 146 163 // No command and no input, show help ··· 167 184 password: CowStr<'static>, 168 185 path: PathBuf, 169 186 site: Option<String>, 187 + directory: bool, 188 + spa: bool, 170 189 ) -> miette::Result<()> { 171 190 let (session, auth) = 172 191 MemoryCredentialSession::authenticated(input, password, None, None).await?; 173 192 println!("Signed in as {}", auth.handle); 174 193 175 194 let agent: Agent<_> = Agent::from(session); 176 - deploy_site(&agent, path, site).await 195 + deploy_site(&agent, path, site, directory, spa).await 177 196 } 178 197 179 198 /// Run deployment with OAuth authentication ··· 182 201 store: String, 183 202 path: PathBuf, 184 203 site: Option<String>, 204 + directory: bool, 205 + spa: bool, 185 206 ) -> miette::Result<()> { 186 207 use jacquard::oauth::scopes::Scope; 187 208 use jacquard::oauth::atproto::AtprotoClientMetadata; 188 209 use jacquard::oauth::session::ClientData; 189 210 use url::Url; 190 211 191 - // Request the necessary scopes for wisp.place 192 - let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs blob:*/*") 212 + // Request the necessary scopes for wisp.place (including settings) 213 + let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*") 193 214 .map_err(|e| miette::miette!("Failed to parse scopes: {:?}", e))?; 194 215 195 216 // Create redirect URIs that match the loopback server (port 4000, path /oauth/callback) ··· 214 235 .await?; 215 236 216 237 let agent: Agent<_> = Agent::from(session); 217 - deploy_site(&agent, path, site).await 238 + deploy_site(&agent, path, site, directory, spa).await 218 239 } 219 240 220 241 /// Deploy the site using the provided agent ··· 222 243 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 223 244 path: PathBuf, 224 245 site: Option<String>, 246 + directory_listing: bool, 247 + spa_mode: bool, 225 248 ) -> miette::Result<()> { 226 249 // Verify the path exists 227 250 if !path.exists() { ··· 445 468 } 446 469 } 447 470 471 + // Upload settings if either flag is set 472 + if directory_listing || spa_mode { 473 + // Validate mutual exclusivity 474 + if directory_listing && spa_mode { 475 + return Err(miette::miette!("Cannot enable both --directory and --SPA modes")); 476 + } 477 + 478 + println!("\n⚙️ Uploading site settings..."); 479 + 480 + // Build settings record 481 + let mut settings_builder = Settings::new(); 482 + 483 + if directory_listing { 484 + settings_builder = settings_builder.directory_listing(Some(true)); 485 + println!(" • Directory listing: enabled"); 486 + } 487 + 488 + if spa_mode { 489 + settings_builder = settings_builder.spa_mode(Some(CowStr::from("index.html"))); 490 + println!(" • SPA mode: enabled (serving index.html for all routes)"); 491 + } 492 + 493 + let settings_record = settings_builder.build(); 494 + 495 + // Upload settings record with same rkey as site 496 + let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 497 + match agent.put_record(RecordKey::from(rkey), settings_record).await { 498 + Ok(settings_output) => { 499 + println!("✅ Settings uploaded: {}", settings_output.uri); 500 + } 501 + Err(e) => { 502 + eprintln!("⚠️ Failed to upload settings: {}", e); 503 + eprintln!(" Site was deployed successfully, but settings may need to be configured manually."); 504 + } 505 + } 506 + } 507 + 448 508 Ok(()) 449 509 } 450 510 ··· 484 544 485 545 // .DS_Store (macOS metadata - can leak info) 486 546 if name_str == ".DS_Store" { 547 + continue; 548 + } 549 + 550 + // .wisp.metadata.json (wisp internal metadata - should not be uploaded) 551 + if name_str == ".wisp.metadata.json" { 487 552 continue; 488 553 } 489 554
+1
cli/src/place_wisp.rs
··· 4 4 // Any manual changes will be overwritten on the next regeneration. 5 5 6 6 pub mod fs; 7 + pub mod settings; 7 8 pub mod subfs;
+653
cli/src/place_wisp/settings.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: place.wisp.settings 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + /// Custom HTTP header configuration 9 + #[jacquard_derive::lexicon] 10 + #[derive( 11 + serde::Serialize, 12 + serde::Deserialize, 13 + Debug, 14 + Clone, 15 + PartialEq, 16 + Eq, 17 + jacquard_derive::IntoStatic, 18 + Default 19 + )] 20 + #[serde(rename_all = "camelCase")] 21 + pub struct CustomHeader<'a> { 22 + /// HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') 23 + #[serde(borrow)] 24 + pub name: jacquard_common::CowStr<'a>, 25 + /// Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. 26 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 27 + #[serde(borrow)] 28 + pub path: std::option::Option<jacquard_common::CowStr<'a>>, 29 + /// HTTP header value 30 + #[serde(borrow)] 31 + pub value: jacquard_common::CowStr<'a>, 32 + } 33 + 34 + fn lexicon_doc_place_wisp_settings() -> ::jacquard_lexicon::lexicon::LexiconDoc< 35 + 'static, 36 + > { 37 + ::jacquard_lexicon::lexicon::LexiconDoc { 38 + lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1, 39 + id: ::jacquard_common::CowStr::new_static("place.wisp.settings"), 40 + revision: None, 41 + description: None, 42 + defs: { 43 + let mut map = ::std::collections::BTreeMap::new(); 44 + map.insert( 45 + ::jacquard_common::smol_str::SmolStr::new_static("customHeader"), 46 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 47 + description: Some( 48 + ::jacquard_common::CowStr::new_static( 49 + "Custom HTTP header configuration", 50 + ), 51 + ), 52 + required: Some( 53 + vec![ 54 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 55 + ::jacquard_common::smol_str::SmolStr::new_static("value") 56 + ], 57 + ), 58 + nullable: None, 59 + properties: { 60 + #[allow(unused_mut)] 61 + let mut map = ::std::collections::BTreeMap::new(); 62 + map.insert( 63 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 64 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 65 + description: Some( 66 + ::jacquard_common::CowStr::new_static( 67 + "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", 68 + ), 69 + ), 70 + format: None, 71 + default: None, 72 + min_length: None, 73 + max_length: Some(100usize), 74 + min_graphemes: None, 75 + max_graphemes: None, 76 + r#enum: None, 77 + r#const: None, 78 + known_values: None, 79 + }), 80 + ); 81 + map.insert( 82 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 83 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 84 + description: Some( 85 + ::jacquard_common::CowStr::new_static( 86 + "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", 87 + ), 88 + ), 89 + format: None, 90 + default: None, 91 + min_length: None, 92 + max_length: Some(500usize), 93 + min_graphemes: None, 94 + max_graphemes: None, 95 + r#enum: None, 96 + r#const: None, 97 + known_values: None, 98 + }), 99 + ); 100 + map.insert( 101 + ::jacquard_common::smol_str::SmolStr::new_static("value"), 102 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 103 + description: Some( 104 + ::jacquard_common::CowStr::new_static("HTTP header value"), 105 + ), 106 + format: None, 107 + default: None, 108 + min_length: None, 109 + max_length: Some(1000usize), 110 + min_graphemes: None, 111 + max_graphemes: None, 112 + r#enum: None, 113 + r#const: None, 114 + known_values: None, 115 + }), 116 + ); 117 + map 118 + }, 119 + }), 120 + ); 121 + map.insert( 122 + ::jacquard_common::smol_str::SmolStr::new_static("main"), 123 + ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord { 124 + description: Some( 125 + ::jacquard_common::CowStr::new_static( 126 + "Configuration settings for a static site hosted on wisp.place", 127 + ), 128 + ), 129 + key: Some(::jacquard_common::CowStr::new_static("any")), 130 + record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject { 131 + description: None, 132 + required: None, 133 + nullable: None, 134 + properties: { 135 + #[allow(unused_mut)] 136 + let mut map = ::std::collections::BTreeMap::new(); 137 + map.insert( 138 + ::jacquard_common::smol_str::SmolStr::new_static( 139 + "cleanUrls", 140 + ), 141 + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { 142 + description: None, 143 + default: None, 144 + r#const: None, 145 + }), 146 + ); 147 + map.insert( 148 + ::jacquard_common::smol_str::SmolStr::new_static( 149 + "custom404", 150 + ), 151 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 152 + description: Some( 153 + ::jacquard_common::CowStr::new_static( 154 + "Custom 404 error page file path. Incompatible with directoryListing and spaMode.", 155 + ), 156 + ), 157 + format: None, 158 + default: None, 159 + min_length: None, 160 + max_length: Some(500usize), 161 + min_graphemes: None, 162 + max_graphemes: None, 163 + r#enum: None, 164 + r#const: None, 165 + known_values: None, 166 + }), 167 + ); 168 + map.insert( 169 + ::jacquard_common::smol_str::SmolStr::new_static( 170 + "directoryListing", 171 + ), 172 + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { 173 + description: None, 174 + default: None, 175 + r#const: None, 176 + }), 177 + ); 178 + map.insert( 179 + ::jacquard_common::smol_str::SmolStr::new_static("headers"), 180 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 181 + description: Some( 182 + ::jacquard_common::CowStr::new_static( 183 + "Custom HTTP headers to set on responses", 184 + ), 185 + ), 186 + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { 187 + description: None, 188 + r#ref: ::jacquard_common::CowStr::new_static( 189 + "#customHeader", 190 + ), 191 + }), 192 + min_length: None, 193 + max_length: Some(50usize), 194 + }), 195 + ); 196 + map.insert( 197 + ::jacquard_common::smol_str::SmolStr::new_static( 198 + "indexFiles", 199 + ), 200 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 201 + description: Some( 202 + ::jacquard_common::CowStr::new_static( 203 + "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", 204 + ), 205 + ), 206 + items: ::jacquard_lexicon::lexicon::LexArrayItem::String(::jacquard_lexicon::lexicon::LexString { 207 + description: None, 208 + format: None, 209 + default: None, 210 + min_length: None, 211 + max_length: Some(255usize), 212 + min_graphemes: None, 213 + max_graphemes: None, 214 + r#enum: None, 215 + r#const: None, 216 + known_values: None, 217 + }), 218 + min_length: None, 219 + max_length: Some(10usize), 220 + }), 221 + ); 222 + map.insert( 223 + ::jacquard_common::smol_str::SmolStr::new_static("spaMode"), 224 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 225 + description: Some( 226 + ::jacquard_common::CowStr::new_static( 227 + "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", 228 + ), 229 + ), 230 + format: None, 231 + default: None, 232 + min_length: None, 233 + max_length: Some(500usize), 234 + min_graphemes: None, 235 + max_graphemes: None, 236 + r#enum: None, 237 + r#const: None, 238 + known_values: None, 239 + }), 240 + ); 241 + map 242 + }, 243 + }), 244 + }), 245 + ); 246 + map 247 + }, 248 + } 249 + } 250 + 251 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for CustomHeader<'a> { 252 + fn nsid() -> &'static str { 253 + "place.wisp.settings" 254 + } 255 + fn def_name() -> &'static str { 256 + "customHeader" 257 + } 258 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 259 + lexicon_doc_place_wisp_settings() 260 + } 261 + fn validate( 262 + &self, 263 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 264 + { 265 + let value = &self.name; 266 + #[allow(unused_comparisons)] 267 + if <str>::len(value.as_ref()) > 100usize { 268 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 269 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 270 + "name", 271 + ), 272 + max: 100usize, 273 + actual: <str>::len(value.as_ref()), 274 + }); 275 + } 276 + } 277 + if let Some(ref value) = self.path { 278 + #[allow(unused_comparisons)] 279 + if <str>::len(value.as_ref()) > 500usize { 280 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 281 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 282 + "path", 283 + ), 284 + max: 500usize, 285 + actual: <str>::len(value.as_ref()), 286 + }); 287 + } 288 + } 289 + { 290 + let value = &self.value; 291 + #[allow(unused_comparisons)] 292 + if <str>::len(value.as_ref()) > 1000usize { 293 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 294 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 295 + "value", 296 + ), 297 + max: 1000usize, 298 + actual: <str>::len(value.as_ref()), 299 + }); 300 + } 301 + } 302 + Ok(()) 303 + } 304 + } 305 + 306 + /// Configuration settings for a static site hosted on wisp.place 307 + #[jacquard_derive::lexicon] 308 + #[derive( 309 + serde::Serialize, 310 + serde::Deserialize, 311 + Debug, 312 + Clone, 313 + PartialEq, 314 + Eq, 315 + jacquard_derive::IntoStatic 316 + )] 317 + #[serde(rename_all = "camelCase")] 318 + pub struct Settings<'a> { 319 + /// Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. 320 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 321 + pub clean_urls: std::option::Option<bool>, 322 + /// Custom 404 error page file path. Incompatible with directoryListing and spaMode. 323 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 324 + #[serde(borrow)] 325 + pub custom404: std::option::Option<jacquard_common::CowStr<'a>>, 326 + /// Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. 327 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 328 + pub directory_listing: std::option::Option<bool>, 329 + /// Custom HTTP headers to set on responses 330 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 331 + #[serde(borrow)] 332 + pub headers: std::option::Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>, 333 + /// Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. 334 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 335 + #[serde(borrow)] 336 + pub index_files: std::option::Option<Vec<jacquard_common::CowStr<'a>>>, 337 + /// File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. 338 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 339 + #[serde(borrow)] 340 + pub spa_mode: std::option::Option<jacquard_common::CowStr<'a>>, 341 + } 342 + 343 + pub mod settings_state { 344 + 345 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 346 + #[allow(unused)] 347 + use ::core::marker::PhantomData; 348 + mod sealed { 349 + pub trait Sealed {} 350 + } 351 + /// State trait tracking which required fields have been set 352 + pub trait State: sealed::Sealed {} 353 + /// Empty state - all required fields are unset 354 + pub struct Empty(()); 355 + impl sealed::Sealed for Empty {} 356 + impl State for Empty {} 357 + /// Marker types for field names 358 + #[allow(non_camel_case_types)] 359 + pub mod members {} 360 + } 361 + 362 + /// Builder for constructing an instance of this type 363 + pub struct SettingsBuilder<'a, S: settings_state::State> { 364 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 365 + __unsafe_private_named: ( 366 + ::core::option::Option<bool>, 367 + ::core::option::Option<jacquard_common::CowStr<'a>>, 368 + ::core::option::Option<bool>, 369 + ::core::option::Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>, 370 + ::core::option::Option<Vec<jacquard_common::CowStr<'a>>>, 371 + ::core::option::Option<jacquard_common::CowStr<'a>>, 372 + ), 373 + _phantom: ::core::marker::PhantomData<&'a ()>, 374 + } 375 + 376 + impl<'a> Settings<'a> { 377 + /// Create a new builder for this type 378 + pub fn new() -> SettingsBuilder<'a, settings_state::Empty> { 379 + SettingsBuilder::new() 380 + } 381 + } 382 + 383 + impl<'a> SettingsBuilder<'a, settings_state::Empty> { 384 + /// Create a new builder with all fields unset 385 + pub fn new() -> Self { 386 + SettingsBuilder { 387 + _phantom_state: ::core::marker::PhantomData, 388 + __unsafe_private_named: (None, None, None, None, None, None), 389 + _phantom: ::core::marker::PhantomData, 390 + } 391 + } 392 + } 393 + 394 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 395 + /// Set the `cleanUrls` field (optional) 396 + pub fn clean_urls(mut self, value: impl Into<Option<bool>>) -> Self { 397 + self.__unsafe_private_named.0 = value.into(); 398 + self 399 + } 400 + /// Set the `cleanUrls` field to an Option value (optional) 401 + pub fn maybe_clean_urls(mut self, value: Option<bool>) -> Self { 402 + self.__unsafe_private_named.0 = value; 403 + self 404 + } 405 + } 406 + 407 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 408 + /// Set the `custom404` field (optional) 409 + pub fn custom404( 410 + mut self, 411 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 412 + ) -> Self { 413 + self.__unsafe_private_named.1 = value.into(); 414 + self 415 + } 416 + /// Set the `custom404` field to an Option value (optional) 417 + pub fn maybe_custom404( 418 + mut self, 419 + value: Option<jacquard_common::CowStr<'a>>, 420 + ) -> Self { 421 + self.__unsafe_private_named.1 = value; 422 + self 423 + } 424 + } 425 + 426 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 427 + /// Set the `directoryListing` field (optional) 428 + pub fn directory_listing(mut self, value: impl Into<Option<bool>>) -> Self { 429 + self.__unsafe_private_named.2 = value.into(); 430 + self 431 + } 432 + /// Set the `directoryListing` field to an Option value (optional) 433 + pub fn maybe_directory_listing(mut self, value: Option<bool>) -> Self { 434 + self.__unsafe_private_named.2 = value; 435 + self 436 + } 437 + } 438 + 439 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 440 + /// Set the `headers` field (optional) 441 + pub fn headers( 442 + mut self, 443 + value: impl Into<Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>>, 444 + ) -> Self { 445 + self.__unsafe_private_named.3 = value.into(); 446 + self 447 + } 448 + /// Set the `headers` field to an Option value (optional) 449 + pub fn maybe_headers( 450 + mut self, 451 + value: Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>, 452 + ) -> Self { 453 + self.__unsafe_private_named.3 = value; 454 + self 455 + } 456 + } 457 + 458 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 459 + /// Set the `indexFiles` field (optional) 460 + pub fn index_files( 461 + mut self, 462 + value: impl Into<Option<Vec<jacquard_common::CowStr<'a>>>>, 463 + ) -> Self { 464 + self.__unsafe_private_named.4 = value.into(); 465 + self 466 + } 467 + /// Set the `indexFiles` field to an Option value (optional) 468 + pub fn maybe_index_files( 469 + mut self, 470 + value: Option<Vec<jacquard_common::CowStr<'a>>>, 471 + ) -> Self { 472 + self.__unsafe_private_named.4 = value; 473 + self 474 + } 475 + } 476 + 477 + impl<'a, S: settings_state::State> SettingsBuilder<'a, S> { 478 + /// Set the `spaMode` field (optional) 479 + pub fn spa_mode( 480 + mut self, 481 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 482 + ) -> Self { 483 + self.__unsafe_private_named.5 = value.into(); 484 + self 485 + } 486 + /// Set the `spaMode` field to an Option value (optional) 487 + pub fn maybe_spa_mode(mut self, value: Option<jacquard_common::CowStr<'a>>) -> Self { 488 + self.__unsafe_private_named.5 = value; 489 + self 490 + } 491 + } 492 + 493 + impl<'a, S> SettingsBuilder<'a, S> 494 + where 495 + S: settings_state::State, 496 + { 497 + /// Build the final struct 498 + pub fn build(self) -> Settings<'a> { 499 + Settings { 500 + clean_urls: self.__unsafe_private_named.0, 501 + custom404: self.__unsafe_private_named.1, 502 + directory_listing: self.__unsafe_private_named.2, 503 + headers: self.__unsafe_private_named.3, 504 + index_files: self.__unsafe_private_named.4, 505 + spa_mode: self.__unsafe_private_named.5, 506 + extra_data: Default::default(), 507 + } 508 + } 509 + /// Build the final struct with custom extra_data 510 + pub fn build_with_data( 511 + self, 512 + extra_data: std::collections::BTreeMap< 513 + jacquard_common::smol_str::SmolStr, 514 + jacquard_common::types::value::Data<'a>, 515 + >, 516 + ) -> Settings<'a> { 517 + Settings { 518 + clean_urls: self.__unsafe_private_named.0, 519 + custom404: self.__unsafe_private_named.1, 520 + directory_listing: self.__unsafe_private_named.2, 521 + headers: self.__unsafe_private_named.3, 522 + index_files: self.__unsafe_private_named.4, 523 + spa_mode: self.__unsafe_private_named.5, 524 + extra_data: Some(extra_data), 525 + } 526 + } 527 + } 528 + 529 + impl<'a> Settings<'a> { 530 + pub fn uri( 531 + uri: impl Into<jacquard_common::CowStr<'a>>, 532 + ) -> Result< 533 + jacquard_common::types::uri::RecordUri<'a, SettingsRecord>, 534 + jacquard_common::types::uri::UriError, 535 + > { 536 + jacquard_common::types::uri::RecordUri::try_from_uri( 537 + jacquard_common::types::string::AtUri::new_cow(uri.into())?, 538 + ) 539 + } 540 + } 541 + 542 + /// Typed wrapper for GetRecord response with this collection's record type. 543 + #[derive( 544 + serde::Serialize, 545 + serde::Deserialize, 546 + Debug, 547 + Clone, 548 + PartialEq, 549 + Eq, 550 + jacquard_derive::IntoStatic 551 + )] 552 + #[serde(rename_all = "camelCase")] 553 + pub struct SettingsGetRecordOutput<'a> { 554 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 555 + #[serde(borrow)] 556 + pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>, 557 + #[serde(borrow)] 558 + pub uri: jacquard_common::types::string::AtUri<'a>, 559 + #[serde(borrow)] 560 + pub value: Settings<'a>, 561 + } 562 + 563 + impl From<SettingsGetRecordOutput<'_>> for Settings<'_> { 564 + fn from(output: SettingsGetRecordOutput<'_>) -> Self { 565 + use jacquard_common::IntoStatic; 566 + output.value.into_static() 567 + } 568 + } 569 + 570 + impl jacquard_common::types::collection::Collection for Settings<'_> { 571 + const NSID: &'static str = "place.wisp.settings"; 572 + type Record = SettingsRecord; 573 + } 574 + 575 + /// Marker type for deserializing records from this collection. 576 + #[derive(Debug, serde::Serialize, serde::Deserialize)] 577 + pub struct SettingsRecord; 578 + impl jacquard_common::xrpc::XrpcResp for SettingsRecord { 579 + const NSID: &'static str = "place.wisp.settings"; 580 + const ENCODING: &'static str = "application/json"; 581 + type Output<'de> = SettingsGetRecordOutput<'de>; 582 + type Err<'de> = jacquard_common::types::collection::RecordError<'de>; 583 + } 584 + 585 + impl jacquard_common::types::collection::Collection for SettingsRecord { 586 + const NSID: &'static str = "place.wisp.settings"; 587 + type Record = SettingsRecord; 588 + } 589 + 590 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Settings<'a> { 591 + fn nsid() -> &'static str { 592 + "place.wisp.settings" 593 + } 594 + fn def_name() -> &'static str { 595 + "main" 596 + } 597 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 598 + lexicon_doc_place_wisp_settings() 599 + } 600 + fn validate( 601 + &self, 602 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 603 + if let Some(ref value) = self.custom404 { 604 + #[allow(unused_comparisons)] 605 + if <str>::len(value.as_ref()) > 500usize { 606 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 607 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 608 + "custom404", 609 + ), 610 + max: 500usize, 611 + actual: <str>::len(value.as_ref()), 612 + }); 613 + } 614 + } 615 + if let Some(ref value) = self.headers { 616 + #[allow(unused_comparisons)] 617 + if value.len() > 50usize { 618 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 619 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 620 + "headers", 621 + ), 622 + max: 50usize, 623 + actual: value.len(), 624 + }); 625 + } 626 + } 627 + if let Some(ref value) = self.index_files { 628 + #[allow(unused_comparisons)] 629 + if value.len() > 10usize { 630 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 631 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 632 + "index_files", 633 + ), 634 + max: 10usize, 635 + actual: value.len(), 636 + }); 637 + } 638 + } 639 + if let Some(ref value) = self.spa_mode { 640 + #[allow(unused_comparisons)] 641 + if <str>::len(value.as_ref()) > 500usize { 642 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 643 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 644 + "spa_mode", 645 + ), 646 + max: 500usize, 647 + actual: <str>::len(value.as_ref()), 648 + }); 649 + } 650 + } 651 + Ok(()) 652 + } 653 + }
+262 -14
cli/src/serve.rs
··· 1 1 use crate::pull::pull_site; 2 2 use crate::redirects::{load_redirect_rules, match_redirect_rule, RedirectRule}; 3 + use crate::place_wisp::settings::Settings; 3 4 use axum::{ 4 5 Router, 5 6 extract::Request, 6 7 response::{Response, IntoResponse, Redirect}, 7 - http::{StatusCode, Uri}, 8 + http::{StatusCode, Uri, header}, 9 + body::Body, 8 10 }; 9 11 use jacquard::CowStr; 10 12 use jacquard::api::com_atproto::sync::subscribe_repos::{SubscribeRepos, SubscribeReposMessage}; 13 + use jacquard::api::com_atproto::repo::get_record::GetRecord; 11 14 use jacquard_common::types::string::Did; 12 - use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient}; 15 + use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient, XrpcExt}; 16 + use jacquard_common::IntoStatic; 17 + use jacquard_common::types::value::from_data; 13 18 use miette::IntoDiagnostic; 14 19 use n0_future::StreamExt; 15 20 use std::collections::HashMap; 16 - use std::path::PathBuf; 21 + use std::path::{PathBuf, Path}; 17 22 use std::sync::Arc; 18 23 use tokio::sync::RwLock; 19 24 use tower::Service; ··· 28 33 output_dir: PathBuf, 29 34 last_cid: Arc<RwLock<Option<String>>>, 30 35 redirect_rules: Arc<RwLock<Vec<RedirectRule>>>, 36 + settings: Arc<RwLock<Option<Settings<'static>>>>, 37 + } 38 + 39 + /// Fetch settings for a site from the PDS 40 + async fn fetch_settings( 41 + pds_url: &url::Url, 42 + did: &Did<'_>, 43 + rkey: &str, 44 + ) -> miette::Result<Option<Settings<'static>>> { 45 + use jacquard_common::types::ident::AtIdentifier; 46 + use jacquard_common::types::string::{Rkey as RkeyType, RecordKey}; 47 + 48 + let client = reqwest::Client::new(); 49 + let rkey_parsed = RkeyType::new(rkey).into_diagnostic()?; 50 + 51 + let request = GetRecord::new() 52 + .repo(AtIdentifier::Did(did.clone())) 53 + .collection(CowStr::from("place.wisp.settings")) 54 + .rkey(RecordKey::from(rkey_parsed)) 55 + .build(); 56 + 57 + match client.xrpc(pds_url.clone()).send(&request).await { 58 + Ok(response) => { 59 + let output = response.into_output().into_diagnostic()?; 60 + 61 + // Parse the record value as Settings 62 + match from_data::<Settings>(&output.value) { 63 + Ok(settings) => { 64 + Ok(Some(settings.into_static())) 65 + } 66 + Err(_) => { 67 + // Settings record exists but couldn't parse - use defaults 68 + Ok(None) 69 + } 70 + } 71 + } 72 + Err(_) => { 73 + // Settings record doesn't exist 74 + Ok(None) 75 + } 76 + } 31 77 } 32 78 33 79 /// Serve a site locally with real-time firehose updates ··· 54 100 55 101 println!("Resolved to DID: {}", did.as_str()); 56 102 103 + // Resolve PDS URL (needed for settings fetch) 104 + let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?; 105 + 57 106 // Create output directory if it doesn't exist 58 107 std::fs::create_dir_all(&output_dir).into_diagnostic()?; 59 108 ··· 62 111 let did_str = CowStr::from(did.as_str().to_string()); 63 112 pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?; 64 113 114 + // Fetch settings 115 + let settings = fetch_settings(&pds_url, &did, rkey.as_ref()).await?; 116 + if let Some(ref s) = settings { 117 + println!("\nSettings loaded:"); 118 + if let Some(true) = s.directory_listing { 119 + println!(" • Directory listing: enabled"); 120 + } 121 + if let Some(ref spa_file) = s.spa_mode { 122 + println!(" • SPA mode: enabled ({})", spa_file); 123 + } 124 + if let Some(ref custom404) = s.custom404 { 125 + println!(" • Custom 404: {}", custom404); 126 + } 127 + } else { 128 + println!("No settings configured (using defaults)"); 129 + } 130 + 65 131 // Load redirect rules 66 132 let redirect_rules = load_redirect_rules(&output_dir); 67 133 if !redirect_rules.is_empty() { ··· 75 141 output_dir: output_dir.clone(), 76 142 last_cid: Arc::new(RwLock::new(None)), 77 143 redirect_rules: Arc::new(RwLock::new(redirect_rules)), 144 + settings: Arc::new(RwLock::new(settings)), 78 145 }; 79 146 80 147 // Start firehose listener in background ··· 111 178 Ok(()) 112 179 } 113 180 114 - /// Handle a request with redirect support 181 + /// Serve a file for SPA mode 182 + async fn serve_file_for_spa(output_dir: &Path, spa_file: &str) -> Response { 183 + let file_path = output_dir.join(spa_file.trim_start_matches('/')); 184 + 185 + match tokio::fs::read(&file_path).await { 186 + Ok(contents) => { 187 + Response::builder() 188 + .status(StatusCode::OK) 189 + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 190 + .body(Body::from(contents)) 191 + .unwrap() 192 + } 193 + Err(_) => { 194 + StatusCode::NOT_FOUND.into_response() 195 + } 196 + } 197 + } 198 + 199 + /// Serve custom 404 page 200 + async fn serve_custom_404(output_dir: &Path, custom404_file: &str) -> Response { 201 + let file_path = output_dir.join(custom404_file.trim_start_matches('/')); 202 + 203 + match tokio::fs::read(&file_path).await { 204 + Ok(contents) => { 205 + Response::builder() 206 + .status(StatusCode::NOT_FOUND) 207 + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 208 + .body(Body::from(contents)) 209 + .unwrap() 210 + } 211 + Err(_) => { 212 + StatusCode::NOT_FOUND.into_response() 213 + } 214 + } 215 + } 216 + 217 + /// Serve directory listing 218 + async fn serve_directory_listing(dir_path: &Path, url_path: &str) -> Response { 219 + match tokio::fs::read_dir(dir_path).await { 220 + Ok(mut entries) => { 221 + let mut html = String::from("<!DOCTYPE html><html><head><meta charset='utf-8'><title>Directory listing</title>"); 222 + html.push_str("<style>body{font-family:sans-serif;margin:2em}a{display:block;padding:0.5em;text-decoration:none;color:#0066cc}a:hover{background:#f0f0f0}</style>"); 223 + html.push_str("</head><body>"); 224 + html.push_str(&format!("<h1>Index of {}</h1>", url_path)); 225 + html.push_str("<hr>"); 226 + 227 + // Add parent directory link if not at root 228 + if url_path != "/" { 229 + let parent = if url_path.ends_with('/') { 230 + format!("{}../", url_path) 231 + } else { 232 + format!("{}/", url_path.rsplitn(2, '/').nth(1).unwrap_or("/")) 233 + }; 234 + html.push_str(&format!("<a href='{}'>../</a>", parent)); 235 + } 236 + 237 + let mut items = Vec::new(); 238 + while let Ok(Some(entry)) = entries.next_entry().await { 239 + if let Ok(name) = entry.file_name().into_string() { 240 + let is_dir = entry.path().is_dir(); 241 + let display_name = if is_dir { 242 + format!("{}/", name) 243 + } else { 244 + name.clone() 245 + }; 246 + 247 + let link_path = if url_path.ends_with('/') { 248 + format!("{}{}", url_path, name) 249 + } else { 250 + format!("{}/{}", url_path, name) 251 + }; 252 + 253 + items.push((display_name, link_path, is_dir)); 254 + } 255 + } 256 + 257 + // Sort: directories first, then alphabetically 258 + items.sort_by(|a, b| { 259 + match (a.2, b.2) { 260 + (true, false) => std::cmp::Ordering::Less, 261 + (false, true) => std::cmp::Ordering::Greater, 262 + _ => a.0.cmp(&b.0), 263 + } 264 + }); 265 + 266 + for (display_name, link_path, _) in items { 267 + html.push_str(&format!("<a href='{}'>{}</a>", link_path, display_name)); 268 + } 269 + 270 + html.push_str("</body></html>"); 271 + 272 + Response::builder() 273 + .status(StatusCode::OK) 274 + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 275 + .body(Body::from(html)) 276 + .unwrap() 277 + } 278 + Err(_) => { 279 + StatusCode::NOT_FOUND.into_response() 280 + } 281 + } 282 + } 283 + 284 + /// Handle a request with redirect and settings support 115 285 async fn handle_request_with_redirects( 116 286 req: Request, 117 287 state: ServerState, ··· 132 302 params 133 303 }); 134 304 135 - // Check for redirect rules 305 + // Get settings 306 + let settings = state.settings.read().await.clone(); 307 + 308 + // Check for redirect rules first 136 309 let redirect_rules = state.redirect_rules.read().await; 137 310 if let Some(redirect_match) = match_redirect_rule(path, &redirect_rules, query_params.as_ref()) { 138 311 let is_force = redirect_match.force; ··· 215 388 } 216 389 } else { 217 390 drop(redirect_rules); 218 - // No redirect match, serve normally 219 - match serve_dir.call(req).await { 391 + 392 + // No redirect match, try to serve the file 393 + let response_result = serve_dir.call(req).await; 394 + 395 + match response_result { 396 + Ok(response) if response.status().is_success() => { 397 + // File served successfully 398 + response.into_response() 399 + } 400 + Ok(response) if response.status() == StatusCode::NOT_FOUND => { 401 + // File not found, check settings for fallback behavior 402 + if let Some(ref settings) = settings { 403 + // SPA mode takes precedence 404 + if let Some(ref spa_file) = settings.spa_mode { 405 + // Serve the SPA file for all non-file routes 406 + return serve_file_for_spa(&state.output_dir, spa_file.as_ref()).await; 407 + } 408 + 409 + // Check if path is a directory and directory listing is enabled 410 + if let Some(true) = settings.directory_listing { 411 + let file_path = state.output_dir.join(path.trim_start_matches('/')); 412 + if file_path.is_dir() { 413 + return serve_directory_listing(&file_path, path).await; 414 + } 415 + } 416 + 417 + // Check for custom 404 418 + if let Some(ref custom404) = settings.custom404 { 419 + return serve_custom_404(&state.output_dir, custom404.as_ref()).await; 420 + } 421 + } 422 + 423 + // No special handling, return 404 424 + StatusCode::NOT_FOUND.into_response() 425 + } 220 426 Ok(response) => response.into_response(), 221 - Err(_) => StatusCode::NOT_FOUND.into_response(), 427 + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), 222 428 } 223 429 } 224 430 } ··· 291 497 return Ok(()); 292 498 } 293 499 294 - // Check if any operation affects our site 295 - let target_path = format!("place.wisp.fs/{}", state.rkey); 296 - let has_site_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == target_path); 500 + // Check if any operation affects our site or settings 501 + let site_path = format!("place.wisp.fs/{}", state.rkey); 502 + let settings_path = format!("place.wisp.settings/{}", state.rkey); 503 + let has_site_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == site_path); 504 + let has_settings_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == settings_path); 297 505 298 506 if has_site_update { 299 507 // Debug: log all operations for this commit 300 508 println!("[Debug] Commit has {} ops for {}", commit_msg.ops.len(), state.rkey); 301 509 for op in &commit_msg.ops { 302 - if op.path.as_ref() == target_path { 510 + if op.path.as_ref() == site_path { 303 511 println!("[Debug] - {} {}", op.action.as_ref(), op.path.as_ref()); 304 512 } 305 513 } ··· 318 526 if should_update { 319 527 // Check operation types 320 528 let has_create_or_update = commit_msg.ops.iter().any(|op| { 321 - op.path.as_ref() == target_path && 529 + op.path.as_ref() == site_path && 322 530 (op.action.as_ref() == "create" || op.action.as_ref() == "update") 323 531 }); 324 532 let has_delete = commit_msg.ops.iter().any(|op| { 325 - op.path.as_ref() == target_path && op.action.as_ref() == "delete" 533 + op.path.as_ref() == site_path && op.action.as_ref() == "delete" 326 534 }); 327 535 328 536 // If there's a create/update, pull the site (even if there's also a delete in the same commit) ··· 361 569 // Update last CID so we don't process this commit again 362 570 let mut last_cid = state.last_cid.write().await; 363 571 *last_cid = Some(commit_cid); 572 + } 573 + } 574 + } 575 + 576 + // Handle settings updates 577 + if has_settings_update { 578 + println!("\n[Settings] Detected change to settings"); 579 + 580 + // Resolve PDS URL 581 + use jacquard_identity::PublicResolver; 582 + use jacquard::prelude::IdentityResolver; 583 + 584 + let resolver = PublicResolver::default(); 585 + let did = Did::new(&state.did).into_diagnostic()?; 586 + let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?; 587 + 588 + // Fetch updated settings 589 + match fetch_settings(&pds_url, &did, state.rkey.as_ref()).await { 590 + Ok(new_settings) => { 591 + let mut settings = state.settings.write().await; 592 + *settings = new_settings.clone(); 593 + drop(settings); 594 + 595 + if let Some(ref s) = new_settings { 596 + println!("[Settings] Updated:"); 597 + if let Some(true) = s.directory_listing { 598 + println!(" • Directory listing: enabled"); 599 + } 600 + if let Some(ref spa_file) = s.spa_mode { 601 + println!(" • SPA mode: enabled ({})", spa_file); 602 + } 603 + if let Some(ref custom404) = s.custom404 { 604 + println!(" • Custom 404: {}", custom404); 605 + } 606 + } else { 607 + println!("[Settings] Cleared (using defaults)"); 608 + } 609 + } 610 + Err(e) => { 611 + eprintln!("[Settings] Failed to fetch updated settings: {}", e); 364 612 } 365 613 } 366 614 }