+1
-1
cli/Cargo.lock
+1
-1
cli/Cargo.lock
+1
-1
cli/Cargo.toml
+1
-1
cli/Cargo.toml
+74
-9
cli/src/main.rs
+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
+1
cli/src/place_wisp.rs
+653
cli/src/place_wisp/settings.rs
+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
+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
}