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
···
[[package]]
name = "wisp-cli"
-
version = "0.3.0"
+
version = "0.4.0"
dependencies = [
"axum",
"base64 0.22.1",
+1 -1
cli/Cargo.toml
···
[package]
name = "wisp-cli"
-
version = "0.3.0"
+
version = "0.4.0"
edition = "2024"
[features]
+74 -9
cli/src/main.rs
···
use futures::stream::{self, StreamExt};
use place_wisp::fs::*;
+
use place_wisp::settings::*;
#[derive(Parser, Debug)]
#[command(author, version, about = "wisp.place CLI tool")]
···
/// App Password for authentication
#[arg(long, global = true, conflicts_with = "command")]
password: Option<CowStr<'static>>,
+
+
/// Enable directory listing mode for paths without index files
+
#[arg(long, global = true, conflicts_with = "command")]
+
directory: bool,
+
+
/// Enable SPA mode (serve index.html for all routes)
+
#[arg(long, global = true, conflicts_with = "command")]
+
spa: bool,
}
#[derive(Subcommand, Debug)]
···
/// App Password for authentication (alternative to OAuth)
#[arg(long)]
password: Option<CowStr<'static>>,
+
+
/// Enable directory listing mode for paths without index files
+
#[arg(long)]
+
directory: bool,
+
+
/// Enable SPA mode (serve index.html for all routes)
+
#[arg(long)]
+
spa: bool,
},
/// Pull a site from the PDS to a local directory
Pull {
···
let args = Args::parse();
let result = match args.command {
-
Some(Commands::Deploy { input, path, site, store, password }) => {
+
Some(Commands::Deploy { input, path, site, store, password, directory, spa }) => {
// Dispatch to appropriate authentication method
if let Some(password) = password {
-
run_with_app_password(input, password, path, site).await
+
run_with_app_password(input, password, path, site, directory, spa).await
} else {
-
run_with_oauth(input, store, path, site).await
+
run_with_oauth(input, store, path, site, directory, spa).await
}
}
Some(Commands::Pull { input, site, output }) => {
···
// Dispatch to appropriate authentication method
if let Some(password) = args.password {
-
run_with_app_password(input, password, path, args.site).await
+
run_with_app_password(input, password, path, args.site, args.directory, args.spa).await
} else {
-
run_with_oauth(input, store, path, args.site).await
+
run_with_oauth(input, store, path, args.site, args.directory, args.spa).await
}
} else {
// No command and no input, show help
···
password: CowStr<'static>,
path: PathBuf,
site: Option<String>,
+
directory: bool,
+
spa: bool,
) -> miette::Result<()> {
let (session, auth) =
MemoryCredentialSession::authenticated(input, password, None, None).await?;
println!("Signed in as {}", auth.handle);
let agent: Agent<_> = Agent::from(session);
-
deploy_site(&agent, path, site).await
+
deploy_site(&agent, path, site, directory, spa).await
}
/// Run deployment with OAuth authentication
···
store: String,
path: PathBuf,
site: Option<String>,
+
directory: bool,
+
spa: bool,
) -> miette::Result<()> {
use jacquard::oauth::scopes::Scope;
use jacquard::oauth::atproto::AtprotoClientMetadata;
use jacquard::oauth::session::ClientData;
use url::Url;
-
// Request the necessary scopes for wisp.place
-
let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs blob:*/*")
+
// Request the necessary scopes for wisp.place (including settings)
+
let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*")
.map_err(|e| miette::miette!("Failed to parse scopes: {:?}", e))?;
// Create redirect URIs that match the loopback server (port 4000, path /oauth/callback)
···
.await?;
let agent: Agent<_> = Agent::from(session);
-
deploy_site(&agent, path, site).await
+
deploy_site(&agent, path, site, directory, spa).await
}
/// Deploy the site using the provided agent
···
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
path: PathBuf,
site: Option<String>,
+
directory_listing: bool,
+
spa_mode: bool,
) -> miette::Result<()> {
// Verify the path exists
if !path.exists() {
···
}
}
+
// Upload settings if either flag is set
+
if directory_listing || spa_mode {
+
// Validate mutual exclusivity
+
if directory_listing && spa_mode {
+
return Err(miette::miette!("Cannot enable both --directory and --SPA modes"));
+
}
+
+
println!("\n⚙️ Uploading site settings...");
+
+
// Build settings record
+
let mut settings_builder = Settings::new();
+
+
if directory_listing {
+
settings_builder = settings_builder.directory_listing(Some(true));
+
println!(" • Directory listing: enabled");
+
}
+
+
if spa_mode {
+
settings_builder = settings_builder.spa_mode(Some(CowStr::from("index.html")));
+
println!(" • SPA mode: enabled (serving index.html for all routes)");
+
}
+
+
let settings_record = settings_builder.build();
+
+
// Upload settings record with same rkey as site
+
let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?;
+
match agent.put_record(RecordKey::from(rkey), settings_record).await {
+
Ok(settings_output) => {
+
println!("✅ Settings uploaded: {}", settings_output.uri);
+
}
+
Err(e) => {
+
eprintln!("⚠️ Failed to upload settings: {}", e);
+
eprintln!(" Site was deployed successfully, but settings may need to be configured manually.");
+
}
+
}
+
}
+
Ok(())
}
···
// .DS_Store (macOS metadata - can leak info)
if name_str == ".DS_Store" {
+
continue;
+
}
+
+
// .wisp.metadata.json (wisp internal metadata - should not be uploaded)
+
if name_str == ".wisp.metadata.json" {
continue;
}
+1
cli/src/place_wisp.rs
···
// Any manual changes will be overwritten on the next regeneration.
pub mod fs;
+
pub mod settings;
pub mod subfs;
+653
cli/src/place_wisp/settings.rs
···
+
// @generated by jacquard-lexicon. DO NOT EDIT.
+
//
+
// Lexicon: place.wisp.settings
+
//
+
// This file was automatically generated from Lexicon schemas.
+
// Any manual changes will be overwritten on the next regeneration.
+
+
/// Custom HTTP header configuration
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic,
+
Default
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct CustomHeader<'a> {
+
/// HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')
+
#[serde(borrow)]
+
pub name: jacquard_common::CowStr<'a>,
+
/// Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub path: std::option::Option<jacquard_common::CowStr<'a>>,
+
/// HTTP header value
+
#[serde(borrow)]
+
pub value: jacquard_common::CowStr<'a>,
+
}
+
+
fn lexicon_doc_place_wisp_settings() -> ::jacquard_lexicon::lexicon::LexiconDoc<
+
'static,
+
> {
+
::jacquard_lexicon::lexicon::LexiconDoc {
+
lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1,
+
id: ::jacquard_common::CowStr::new_static("place.wisp.settings"),
+
revision: None,
+
description: None,
+
defs: {
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("customHeader"),
+
::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Custom HTTP header configuration",
+
),
+
),
+
required: Some(
+
vec![
+
::jacquard_common::smol_str::SmolStr::new_static("name"),
+
::jacquard_common::smol_str::SmolStr::new_static("value")
+
],
+
),
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("name"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(100usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("path"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(500usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("value"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static("HTTP header value"),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(1000usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map
+
},
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("main"),
+
::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Configuration settings for a static site hosted on wisp.place",
+
),
+
),
+
key: Some(::jacquard_common::CowStr::new_static("any")),
+
record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: None,
+
required: None,
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"cleanUrls",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean {
+
description: None,
+
default: None,
+
r#const: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"custom404",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Custom 404 error page file path. Incompatible with directoryListing and spaMode.",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(500usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"directoryListing",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean {
+
description: None,
+
default: None,
+
r#const: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("headers"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Custom HTTP headers to set on responses",
+
),
+
),
+
items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef {
+
description: None,
+
r#ref: ::jacquard_common::CowStr::new_static(
+
"#customHeader",
+
),
+
}),
+
min_length: None,
+
max_length: Some(50usize),
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"indexFiles",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
+
),
+
),
+
items: ::jacquard_lexicon::lexicon::LexArrayItem::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(255usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
min_length: None,
+
max_length: Some(10usize),
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("spaMode"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"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.",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(500usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map
+
},
+
}),
+
}),
+
);
+
map
+
},
+
}
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for CustomHeader<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.settings"
+
}
+
fn def_name() -> &'static str {
+
"customHeader"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_settings()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
{
+
let value = &self.name;
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 100usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"name",
+
),
+
max: 100usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
if let Some(ref value) = self.path {
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 500usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"path",
+
),
+
max: 500usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
{
+
let value = &self.value;
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 1000usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"value",
+
),
+
max: 1000usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
Ok(())
+
}
+
}
+
+
/// Configuration settings for a static site hosted on wisp.place
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Settings<'a> {
+
/// Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub clean_urls: std::option::Option<bool>,
+
/// Custom 404 error page file path. Incompatible with directoryListing and spaMode.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub custom404: std::option::Option<jacquard_common::CowStr<'a>>,
+
/// Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub directory_listing: std::option::Option<bool>,
+
/// Custom HTTP headers to set on responses
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub headers: std::option::Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>,
+
/// Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub index_files: std::option::Option<Vec<jacquard_common::CowStr<'a>>>,
+
/// 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.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub spa_mode: std::option::Option<jacquard_common::CowStr<'a>>,
+
}
+
+
pub mod settings_state {
+
+
pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
+
#[allow(unused)]
+
use ::core::marker::PhantomData;
+
mod sealed {
+
pub trait Sealed {}
+
}
+
/// State trait tracking which required fields have been set
+
pub trait State: sealed::Sealed {}
+
/// Empty state - all required fields are unset
+
pub struct Empty(());
+
impl sealed::Sealed for Empty {}
+
impl State for Empty {}
+
/// Marker types for field names
+
#[allow(non_camel_case_types)]
+
pub mod members {}
+
}
+
+
/// Builder for constructing an instance of this type
+
pub struct SettingsBuilder<'a, S: settings_state::State> {
+
_phantom_state: ::core::marker::PhantomData<fn() -> S>,
+
__unsafe_private_named: (
+
::core::option::Option<bool>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
::core::option::Option<bool>,
+
::core::option::Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>,
+
::core::option::Option<Vec<jacquard_common::CowStr<'a>>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
),
+
_phantom: ::core::marker::PhantomData<&'a ()>,
+
}
+
+
impl<'a> Settings<'a> {
+
/// Create a new builder for this type
+
pub fn new() -> SettingsBuilder<'a, settings_state::Empty> {
+
SettingsBuilder::new()
+
}
+
}
+
+
impl<'a> SettingsBuilder<'a, settings_state::Empty> {
+
/// Create a new builder with all fields unset
+
pub fn new() -> Self {
+
SettingsBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: (None, None, None, None, None, None),
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `cleanUrls` field (optional)
+
pub fn clean_urls(mut self, value: impl Into<Option<bool>>) -> Self {
+
self.__unsafe_private_named.0 = value.into();
+
self
+
}
+
/// Set the `cleanUrls` field to an Option value (optional)
+
pub fn maybe_clean_urls(mut self, value: Option<bool>) -> Self {
+
self.__unsafe_private_named.0 = value;
+
self
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `custom404` field (optional)
+
pub fn custom404(
+
mut self,
+
value: impl Into<Option<jacquard_common::CowStr<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.1 = value.into();
+
self
+
}
+
/// Set the `custom404` field to an Option value (optional)
+
pub fn maybe_custom404(
+
mut self,
+
value: Option<jacquard_common::CowStr<'a>>,
+
) -> Self {
+
self.__unsafe_private_named.1 = value;
+
self
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `directoryListing` field (optional)
+
pub fn directory_listing(mut self, value: impl Into<Option<bool>>) -> Self {
+
self.__unsafe_private_named.2 = value.into();
+
self
+
}
+
/// Set the `directoryListing` field to an Option value (optional)
+
pub fn maybe_directory_listing(mut self, value: Option<bool>) -> Self {
+
self.__unsafe_private_named.2 = value;
+
self
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `headers` field (optional)
+
pub fn headers(
+
mut self,
+
value: impl Into<Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>>,
+
) -> Self {
+
self.__unsafe_private_named.3 = value.into();
+
self
+
}
+
/// Set the `headers` field to an Option value (optional)
+
pub fn maybe_headers(
+
mut self,
+
value: Option<Vec<crate::place_wisp::settings::CustomHeader<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.3 = value;
+
self
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `indexFiles` field (optional)
+
pub fn index_files(
+
mut self,
+
value: impl Into<Option<Vec<jacquard_common::CowStr<'a>>>>,
+
) -> Self {
+
self.__unsafe_private_named.4 = value.into();
+
self
+
}
+
/// Set the `indexFiles` field to an Option value (optional)
+
pub fn maybe_index_files(
+
mut self,
+
value: Option<Vec<jacquard_common::CowStr<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.4 = value;
+
self
+
}
+
}
+
+
impl<'a, S: settings_state::State> SettingsBuilder<'a, S> {
+
/// Set the `spaMode` field (optional)
+
pub fn spa_mode(
+
mut self,
+
value: impl Into<Option<jacquard_common::CowStr<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.5 = value.into();
+
self
+
}
+
/// Set the `spaMode` field to an Option value (optional)
+
pub fn maybe_spa_mode(mut self, value: Option<jacquard_common::CowStr<'a>>) -> Self {
+
self.__unsafe_private_named.5 = value;
+
self
+
}
+
}
+
+
impl<'a, S> SettingsBuilder<'a, S>
+
where
+
S: settings_state::State,
+
{
+
/// Build the final struct
+
pub fn build(self) -> Settings<'a> {
+
Settings {
+
clean_urls: self.__unsafe_private_named.0,
+
custom404: self.__unsafe_private_named.1,
+
directory_listing: self.__unsafe_private_named.2,
+
headers: self.__unsafe_private_named.3,
+
index_files: self.__unsafe_private_named.4,
+
spa_mode: self.__unsafe_private_named.5,
+
extra_data: Default::default(),
+
}
+
}
+
/// Build the final struct with custom extra_data
+
pub fn build_with_data(
+
self,
+
extra_data: std::collections::BTreeMap<
+
jacquard_common::smol_str::SmolStr,
+
jacquard_common::types::value::Data<'a>,
+
>,
+
) -> Settings<'a> {
+
Settings {
+
clean_urls: self.__unsafe_private_named.0,
+
custom404: self.__unsafe_private_named.1,
+
directory_listing: self.__unsafe_private_named.2,
+
headers: self.__unsafe_private_named.3,
+
index_files: self.__unsafe_private_named.4,
+
spa_mode: self.__unsafe_private_named.5,
+
extra_data: Some(extra_data),
+
}
+
}
+
}
+
+
impl<'a> Settings<'a> {
+
pub fn uri(
+
uri: impl Into<jacquard_common::CowStr<'a>>,
+
) -> Result<
+
jacquard_common::types::uri::RecordUri<'a, SettingsRecord>,
+
jacquard_common::types::uri::UriError,
+
> {
+
jacquard_common::types::uri::RecordUri::try_from_uri(
+
jacquard_common::types::string::AtUri::new_cow(uri.into())?,
+
)
+
}
+
}
+
+
/// Typed wrapper for GetRecord response with this collection's record type.
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct SettingsGetRecordOutput<'a> {
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
+
#[serde(borrow)]
+
pub uri: jacquard_common::types::string::AtUri<'a>,
+
#[serde(borrow)]
+
pub value: Settings<'a>,
+
}
+
+
impl From<SettingsGetRecordOutput<'_>> for Settings<'_> {
+
fn from(output: SettingsGetRecordOutput<'_>) -> Self {
+
use jacquard_common::IntoStatic;
+
output.value.into_static()
+
}
+
}
+
+
impl jacquard_common::types::collection::Collection for Settings<'_> {
+
const NSID: &'static str = "place.wisp.settings";
+
type Record = SettingsRecord;
+
}
+
+
/// Marker type for deserializing records from this collection.
+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
+
pub struct SettingsRecord;
+
impl jacquard_common::xrpc::XrpcResp for SettingsRecord {
+
const NSID: &'static str = "place.wisp.settings";
+
const ENCODING: &'static str = "application/json";
+
type Output<'de> = SettingsGetRecordOutput<'de>;
+
type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
+
}
+
+
impl jacquard_common::types::collection::Collection for SettingsRecord {
+
const NSID: &'static str = "place.wisp.settings";
+
type Record = SettingsRecord;
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Settings<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.settings"
+
}
+
fn def_name() -> &'static str {
+
"main"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_settings()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
if let Some(ref value) = self.custom404 {
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 500usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"custom404",
+
),
+
max: 500usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
if let Some(ref value) = self.headers {
+
#[allow(unused_comparisons)]
+
if value.len() > 50usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"headers",
+
),
+
max: 50usize,
+
actual: value.len(),
+
});
+
}
+
}
+
if let Some(ref value) = self.index_files {
+
#[allow(unused_comparisons)]
+
if value.len() > 10usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"index_files",
+
),
+
max: 10usize,
+
actual: value.len(),
+
});
+
}
+
}
+
if let Some(ref value) = self.spa_mode {
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 500usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"spa_mode",
+
),
+
max: 500usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
Ok(())
+
}
+
}
+262 -14
cli/src/serve.rs
···
use crate::pull::pull_site;
use crate::redirects::{load_redirect_rules, match_redirect_rule, RedirectRule};
+
use crate::place_wisp::settings::Settings;
use axum::{
Router,
extract::Request,
response::{Response, IntoResponse, Redirect},
-
http::{StatusCode, Uri},
+
http::{StatusCode, Uri, header},
+
body::Body,
};
use jacquard::CowStr;
use jacquard::api::com_atproto::sync::subscribe_repos::{SubscribeRepos, SubscribeReposMessage};
+
use jacquard::api::com_atproto::repo::get_record::GetRecord;
use jacquard_common::types::string::Did;
-
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient};
+
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient, XrpcExt};
+
use jacquard_common::IntoStatic;
+
use jacquard_common::types::value::from_data;
use miette::IntoDiagnostic;
use n0_future::StreamExt;
use std::collections::HashMap;
-
use std::path::PathBuf;
+
use std::path::{PathBuf, Path};
use std::sync::Arc;
use tokio::sync::RwLock;
use tower::Service;
···
output_dir: PathBuf,
last_cid: Arc<RwLock<Option<String>>>,
redirect_rules: Arc<RwLock<Vec<RedirectRule>>>,
+
settings: Arc<RwLock<Option<Settings<'static>>>>,
+
}
+
+
/// Fetch settings for a site from the PDS
+
async fn fetch_settings(
+
pds_url: &url::Url,
+
did: &Did<'_>,
+
rkey: &str,
+
) -> miette::Result<Option<Settings<'static>>> {
+
use jacquard_common::types::ident::AtIdentifier;
+
use jacquard_common::types::string::{Rkey as RkeyType, RecordKey};
+
+
let client = reqwest::Client::new();
+
let rkey_parsed = RkeyType::new(rkey).into_diagnostic()?;
+
+
let request = GetRecord::new()
+
.repo(AtIdentifier::Did(did.clone()))
+
.collection(CowStr::from("place.wisp.settings"))
+
.rkey(RecordKey::from(rkey_parsed))
+
.build();
+
+
match client.xrpc(pds_url.clone()).send(&request).await {
+
Ok(response) => {
+
let output = response.into_output().into_diagnostic()?;
+
+
// Parse the record value as Settings
+
match from_data::<Settings>(&output.value) {
+
Ok(settings) => {
+
Ok(Some(settings.into_static()))
+
}
+
Err(_) => {
+
// Settings record exists but couldn't parse - use defaults
+
Ok(None)
+
}
+
}
+
}
+
Err(_) => {
+
// Settings record doesn't exist
+
Ok(None)
+
}
+
}
}
/// Serve a site locally with real-time firehose updates
···
println!("Resolved to DID: {}", did.as_str());
+
// Resolve PDS URL (needed for settings fetch)
+
let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?;
+
// Create output directory if it doesn't exist
std::fs::create_dir_all(&output_dir).into_diagnostic()?;
···
let did_str = CowStr::from(did.as_str().to_string());
pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?;
+
// Fetch settings
+
let settings = fetch_settings(&pds_url, &did, rkey.as_ref()).await?;
+
if let Some(ref s) = settings {
+
println!("\nSettings loaded:");
+
if let Some(true) = s.directory_listing {
+
println!(" • Directory listing: enabled");
+
}
+
if let Some(ref spa_file) = s.spa_mode {
+
println!(" • SPA mode: enabled ({})", spa_file);
+
}
+
if let Some(ref custom404) = s.custom404 {
+
println!(" • Custom 404: {}", custom404);
+
}
+
} else {
+
println!("No settings configured (using defaults)");
+
}
+
// Load redirect rules
let redirect_rules = load_redirect_rules(&output_dir);
if !redirect_rules.is_empty() {
···
output_dir: output_dir.clone(),
last_cid: Arc::new(RwLock::new(None)),
redirect_rules: Arc::new(RwLock::new(redirect_rules)),
+
settings: Arc::new(RwLock::new(settings)),
};
// Start firehose listener in background
···
Ok(())
}
-
/// Handle a request with redirect support
+
/// Serve a file for SPA mode
+
async fn serve_file_for_spa(output_dir: &Path, spa_file: &str) -> Response {
+
let file_path = output_dir.join(spa_file.trim_start_matches('/'));
+
+
match tokio::fs::read(&file_path).await {
+
Ok(contents) => {
+
Response::builder()
+
.status(StatusCode::OK)
+
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+
.body(Body::from(contents))
+
.unwrap()
+
}
+
Err(_) => {
+
StatusCode::NOT_FOUND.into_response()
+
}
+
}
+
}
+
+
/// Serve custom 404 page
+
async fn serve_custom_404(output_dir: &Path, custom404_file: &str) -> Response {
+
let file_path = output_dir.join(custom404_file.trim_start_matches('/'));
+
+
match tokio::fs::read(&file_path).await {
+
Ok(contents) => {
+
Response::builder()
+
.status(StatusCode::NOT_FOUND)
+
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+
.body(Body::from(contents))
+
.unwrap()
+
}
+
Err(_) => {
+
StatusCode::NOT_FOUND.into_response()
+
}
+
}
+
}
+
+
/// Serve directory listing
+
async fn serve_directory_listing(dir_path: &Path, url_path: &str) -> Response {
+
match tokio::fs::read_dir(dir_path).await {
+
Ok(mut entries) => {
+
let mut html = String::from("<!DOCTYPE html><html><head><meta charset='utf-8'><title>Directory listing</title>");
+
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>");
+
html.push_str("</head><body>");
+
html.push_str(&format!("<h1>Index of {}</h1>", url_path));
+
html.push_str("<hr>");
+
+
// Add parent directory link if not at root
+
if url_path != "/" {
+
let parent = if url_path.ends_with('/') {
+
format!("{}../", url_path)
+
} else {
+
format!("{}/", url_path.rsplitn(2, '/').nth(1).unwrap_or("/"))
+
};
+
html.push_str(&format!("<a href='{}'>../</a>", parent));
+
}
+
+
let mut items = Vec::new();
+
while let Ok(Some(entry)) = entries.next_entry().await {
+
if let Ok(name) = entry.file_name().into_string() {
+
let is_dir = entry.path().is_dir();
+
let display_name = if is_dir {
+
format!("{}/", name)
+
} else {
+
name.clone()
+
};
+
+
let link_path = if url_path.ends_with('/') {
+
format!("{}{}", url_path, name)
+
} else {
+
format!("{}/{}", url_path, name)
+
};
+
+
items.push((display_name, link_path, is_dir));
+
}
+
}
+
+
// Sort: directories first, then alphabetically
+
items.sort_by(|a, b| {
+
match (a.2, b.2) {
+
(true, false) => std::cmp::Ordering::Less,
+
(false, true) => std::cmp::Ordering::Greater,
+
_ => a.0.cmp(&b.0),
+
}
+
});
+
+
for (display_name, link_path, _) in items {
+
html.push_str(&format!("<a href='{}'>{}</a>", link_path, display_name));
+
}
+
+
html.push_str("</body></html>");
+
+
Response::builder()
+
.status(StatusCode::OK)
+
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+
.body(Body::from(html))
+
.unwrap()
+
}
+
Err(_) => {
+
StatusCode::NOT_FOUND.into_response()
+
}
+
}
+
}
+
+
/// Handle a request with redirect and settings support
async fn handle_request_with_redirects(
req: Request,
state: ServerState,
···
params
});
-
// Check for redirect rules
+
// Get settings
+
let settings = state.settings.read().await.clone();
+
+
// Check for redirect rules first
let redirect_rules = state.redirect_rules.read().await;
if let Some(redirect_match) = match_redirect_rule(path, &redirect_rules, query_params.as_ref()) {
let is_force = redirect_match.force;
···
}
} else {
drop(redirect_rules);
-
// No redirect match, serve normally
-
match serve_dir.call(req).await {
+
+
// No redirect match, try to serve the file
+
let response_result = serve_dir.call(req).await;
+
+
match response_result {
+
Ok(response) if response.status().is_success() => {
+
// File served successfully
+
response.into_response()
+
}
+
Ok(response) if response.status() == StatusCode::NOT_FOUND => {
+
// File not found, check settings for fallback behavior
+
if let Some(ref settings) = settings {
+
// SPA mode takes precedence
+
if let Some(ref spa_file) = settings.spa_mode {
+
// Serve the SPA file for all non-file routes
+
return serve_file_for_spa(&state.output_dir, spa_file.as_ref()).await;
+
}
+
+
// Check if path is a directory and directory listing is enabled
+
if let Some(true) = settings.directory_listing {
+
let file_path = state.output_dir.join(path.trim_start_matches('/'));
+
if file_path.is_dir() {
+
return serve_directory_listing(&file_path, path).await;
+
}
+
}
+
+
// Check for custom 404
+
if let Some(ref custom404) = settings.custom404 {
+
return serve_custom_404(&state.output_dir, custom404.as_ref()).await;
+
}
+
}
+
+
// No special handling, return 404
+
StatusCode::NOT_FOUND.into_response()
+
}
Ok(response) => response.into_response(),
-
Err(_) => StatusCode::NOT_FOUND.into_response(),
+
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
···
return Ok(());
}
-
// Check if any operation affects our site
-
let target_path = format!("place.wisp.fs/{}", state.rkey);
-
let has_site_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == target_path);
+
// Check if any operation affects our site or settings
+
let site_path = format!("place.wisp.fs/{}", state.rkey);
+
let settings_path = format!("place.wisp.settings/{}", state.rkey);
+
let has_site_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == site_path);
+
let has_settings_update = commit_msg.ops.iter().any(|op| op.path.as_ref() == settings_path);
if has_site_update {
// Debug: log all operations for this commit
println!("[Debug] Commit has {} ops for {}", commit_msg.ops.len(), state.rkey);
for op in &commit_msg.ops {
-
if op.path.as_ref() == target_path {
+
if op.path.as_ref() == site_path {
println!("[Debug] - {} {}", op.action.as_ref(), op.path.as_ref());
}
}
···
if should_update {
// Check operation types
let has_create_or_update = commit_msg.ops.iter().any(|op| {
-
op.path.as_ref() == target_path &&
+
op.path.as_ref() == site_path &&
(op.action.as_ref() == "create" || op.action.as_ref() == "update")
});
let has_delete = commit_msg.ops.iter().any(|op| {
-
op.path.as_ref() == target_path && op.action.as_ref() == "delete"
+
op.path.as_ref() == site_path && op.action.as_ref() == "delete"
});
// If there's a create/update, pull the site (even if there's also a delete in the same commit)
···
// Update last CID so we don't process this commit again
let mut last_cid = state.last_cid.write().await;
*last_cid = Some(commit_cid);
+
}
+
}
+
}
+
+
// Handle settings updates
+
if has_settings_update {
+
println!("\n[Settings] Detected change to settings");
+
+
// Resolve PDS URL
+
use jacquard_identity::PublicResolver;
+
use jacquard::prelude::IdentityResolver;
+
+
let resolver = PublicResolver::default();
+
let did = Did::new(&state.did).into_diagnostic()?;
+
let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?;
+
+
// Fetch updated settings
+
match fetch_settings(&pds_url, &did, state.rkey.as_ref()).await {
+
Ok(new_settings) => {
+
let mut settings = state.settings.write().await;
+
*settings = new_settings.clone();
+
drop(settings);
+
+
if let Some(ref s) = new_settings {
+
println!("[Settings] Updated:");
+
if let Some(true) = s.directory_listing {
+
println!(" • Directory listing: enabled");
+
}
+
if let Some(ref spa_file) = s.spa_mode {
+
println!(" • SPA mode: enabled ({})", spa_file);
+
}
+
if let Some(ref custom404) = s.custom404 {
+
println!(" • Custom 404: {}", custom404);
+
}
+
} else {
+
println!("[Settings] Cleared (using defaults)");
+
}
+
}
+
Err(e) => {
+
eprintln!("[Settings] Failed to fetch updated settings: {}", e);
}
}
}