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

add _redirects, use PDS firehose, TODO use git again when #20 is fixed

Changed files
+712 -131
cli
+31 -23
cli/Cargo.lock
···
[[package]]
name = "clap"
-
version = "4.5.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
dependencies = [
"clap_builder",
"clap_derive",
···
[[package]]
name = "clap_builder"
-
version = "4.5.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
dependencies = [
"anstream",
"anstyle",
···
[[package]]
name = "jacquard"
-
version = "0.9.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"bytes",
"getrandom 0.2.16",
···
"jose-jwk",
"miette",
"regex",
"reqwest",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-api"
-
version = "0.9.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-common"
-
version = "0.9.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"base64 0.22.1",
"bon",
···
"p256",
"rand 0.9.2",
"regex",
"reqwest",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-derive"
-
version = "0.9.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
···
[[package]]
name = "jacquard-identity"
-
version = "0.9.1"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-lexicon"
-
version = "0.9.1"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"cid",
"dashmap",
···
[[package]]
name = "jacquard-oauth"
-
version = "0.9.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#d853091d7de59e18746a78532dc28cfc017079b0"
dependencies = [
"base64 0.22.1",
"bytes",
···
"serde_html_form",
"serde_json",
"sha2",
-
"signature",
"smol_str",
"thiserror 2.0.17",
"tokio",
···
[[package]]
name = "mini-moka"
-
version = "0.11.0"
-
source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d"
dependencies = [
"crossbeam-channel",
"crossbeam-utils",
···
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "resolv-conf"
-
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
[[package]]
name = "rfc6979"
···
"multibase",
"multihash",
"n0-future 0.3.1",
"reqwest",
"rustversion",
"serde",
···
[[package]]
name = "clap"
+
version = "4.5.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
dependencies = [
"clap_builder",
"clap_derive",
···
[[package]]
name = "clap_builder"
+
version = "4.5.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
dependencies = [
"anstream",
"anstyle",
···
[[package]]
name = "jacquard"
+
version = "0.9.3"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"bytes",
"getrandom 0.2.16",
···
"jose-jwk",
"miette",
"regex",
+
"regex-lite",
"reqwest",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-api"
+
version = "0.9.2"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-common"
+
version = "0.9.2"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"base64 0.22.1",
"bon",
···
"p256",
"rand 0.9.2",
"regex",
+
"regex-lite",
"reqwest",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-derive"
+
version = "0.9.3"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
···
[[package]]
name = "jacquard-identity"
+
version = "0.9.2"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-lexicon"
+
version = "0.9.2"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"cid",
"dashmap",
···
[[package]]
name = "jacquard-oauth"
+
version = "0.9.2"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"base64 0.22.1",
"bytes",
···
"serde_html_form",
"serde_json",
"sha2",
"smol_str",
"thiserror 2.0.17",
"tokio",
···
[[package]]
name = "mini-moka"
+
version = "0.10.99"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#324cbb45078fe2f77b60ae2bd7765c5306ec8b5e"
dependencies = [
"crossbeam-channel",
"crossbeam-utils",
···
]
[[package]]
+
name = "regex-lite"
+
version = "0.1.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
+
+
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "resolv-conf"
+
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "rfc6979"
···
"multibase",
"multihash",
"n0-future 0.3.1",
+
"regex",
"reqwest",
"rustversion",
"serde",
+8 -7
cli/Cargo.toml
···
place_wisp = []
[dependencies]
-
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
-
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
-
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
-
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["websocket"] }
-
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
-
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
-
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
···
n0-future = "0.3.1"
chrono = "0.4"
url = "2.5"
···
place_wisp = []
[dependencies]
+
jacquard = { path = "/Users/regent/Developer/jacquard/crates/jacquard", features = ["loopback"] }
+
jacquard-oauth = { path = "/Users/regent/Developer/jacquard/crates/jacquard-oauth" }
+
jacquard-api = { path = "/Users/regent/Developer/jacquard/crates/jacquard-api", features = ["streaming"] }
+
jacquard-common = { path = "/Users/regent/Developer/jacquard/crates/jacquard-common", features = ["websocket"] }
+
jacquard-identity = { path = "/Users/regent/Developer/jacquard/crates/jacquard-identity", features = ["dns"] }
+
jacquard-derive = { path = "/Users/regent/Developer/jacquard/crates/jacquard-derive" }
+
jacquard-lexicon = { path = "/Users/regent/Developer/jacquard/crates/jacquard-lexicon" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
···
n0-future = "0.3.1"
chrono = "0.4"
url = "2.5"
+
regex = "1.11"
+61 -37
cli/src/main.rs
···
mod pull;
mod serve;
mod subfs_utils;
use clap::{Parser, Subcommand};
use jacquard::CowStr;
···
site: Option<String>,
) -> miette::Result<()> {
let (session, auth) =
-
MemoryCredentialSession::authenticated(input, password, None).await?;
println!("Signed in as {}", auth.handle);
let agent: Agent<_> = Agent::from(session);
···
/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
/// Returns (File, reused: bool)
/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
async fn process_file(
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
file_path: &Path,
···
.first_or_octet_stream()
.to_string();
-
// Gzip compress
-
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
-
encoder.write_all(&file_data).into_diagnostic()?;
-
let gzipped = encoder.finish().into_diagnostic()?;
-
// Base64 encode the gzipped data
-
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
-
// Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
-
let file_cid = cid::compute_cid(&base64_bytes);
-
// Check if we have an existing blob with the same CID
let existing_blob = existing_blobs.get(file_path_key);
-
if let Some((existing_blob_ref, existing_cid)) = existing_blob {
if existing_cid == &file_cid {
// CIDs match - reuse existing blob
println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid);
-
return Ok((
-
File::new()
-
.r#type(CowStr::from("file"))
-
.blob(existing_blob_ref.clone())
-
.encoding(CowStr::from("gzip"))
-
.mime_type(CowStr::from(original_mime))
-
.base64(true)
-
.build(),
-
true
-
));
}
}
-
// File is new or changed - upload it
-
println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid);
-
let blob = agent.upload_blob(
-
base64_bytes,
-
MimeType::new_static("application/octet-stream"),
-
).await?;
-
Ok((
-
File::new()
-
.r#type(CowStr::from("file"))
-
.blob(blob)
-
.encoding(CowStr::from("gzip"))
-
.mime_type(CowStr::from(original_mime))
-
.base64(true)
-
.build(),
-
false
-
))
}
/// Convert fs::Directory to subfs::Directory
···
mod pull;
mod serve;
mod subfs_utils;
+
mod redirects;
use clap::{Parser, Subcommand};
use jacquard::CowStr;
···
site: Option<String>,
) -> miette::Result<()> {
let (session, auth) =
+
MemoryCredentialSession::authenticated(input, password, None, None).await?;
println!("Signed in as {}", auth.handle);
let agent: Agent<_> = Agent::from(session);
···
/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
/// Returns (File, reused: bool)
/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
+
///
+
/// Special handling: _redirects files are NOT compressed (uploaded as-is)
async fn process_file(
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
file_path: &Path,
···
.first_or_octet_stream()
.to_string();
+
// Check if this is a _redirects file (don't compress it)
+
let is_redirects_file = file_path.file_name()
+
.and_then(|n| n.to_str())
+
.map(|n| n == "_redirects")
+
.unwrap_or(false);
+
let (upload_bytes, encoding, is_base64) = if is_redirects_file {
+
// Don't compress _redirects - upload as-is
+
(file_data.clone(), None, false)
+
} else {
+
// Gzip compress
+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
+
encoder.write_all(&file_data).into_diagnostic()?;
+
let gzipped = encoder.finish().into_diagnostic()?;
+
// Base64 encode the gzipped data
+
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
+
(base64_bytes, Some("gzip"), true)
+
};
+
+
// Compute CID for this file
+
let file_cid = cid::compute_cid(&upload_bytes);
+
// Check if we have an existing blob with the same CID
let existing_blob = existing_blobs.get(file_path_key);
+
if let Some((existing_blob_ref, existing_cid)) = existing_blob {
if existing_cid == &file_cid {
// CIDs match - reuse existing blob
println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid);
+
let mut file_builder = File::new()
+
.r#type(CowStr::from("file"))
+
.blob(existing_blob_ref.clone())
+
.mime_type(CowStr::from(original_mime));
+
+
if let Some(enc) = encoding {
+
file_builder = file_builder.encoding(CowStr::from(enc));
+
}
+
if is_base64 {
+
file_builder = file_builder.base64(true);
+
}
+
+
return Ok((file_builder.build(), true));
}
}
+
// File is new or changed - upload it
+
let mime_type = if is_redirects_file {
+
MimeType::new_static("text/plain")
+
} else {
+
MimeType::new_static("application/octet-stream")
+
};
+
+
println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, upload_bytes.len(), file_cid);
+
let blob = agent.upload_blob(upload_bytes, mime_type).await?;
+
+
let mut file_builder = File::new()
+
.r#type(CowStr::from("file"))
+
.blob(blob)
+
.mime_type(CowStr::from(original_mime));
+
+
if let Some(enc) = encoding {
+
file_builder = file_builder.encoding(CowStr::from(enc));
+
}
+
if is_base64 {
+
file_builder = file_builder.base64(true);
+
}
+
Ok((file_builder.build(), false))
}
/// Convert fs::Directory to subfs::Directory
+375
cli/src/redirects.rs
···
···
+
use regex::Regex;
+
use std::collections::HashMap;
+
use std::fs;
+
use std::path::Path;
+
+
/// Maximum number of redirect rules to prevent DoS attacks
+
const MAX_REDIRECT_RULES: usize = 1000;
+
+
#[derive(Debug, Clone)]
+
pub struct RedirectRule {
+
#[allow(dead_code)]
+
pub from: String,
+
pub to: String,
+
pub status: u16,
+
#[allow(dead_code)]
+
pub force: bool,
+
pub from_pattern: Regex,
+
pub from_params: Vec<String>,
+
pub query_params: Option<HashMap<String, String>>,
+
}
+
+
#[derive(Debug)]
+
pub struct RedirectMatch {
+
pub target_path: String,
+
pub status: u16,
+
pub force: bool,
+
}
+
+
/// Parse a _redirects file into an array of redirect rules
+
pub fn parse_redirects_file(content: &str) -> Vec<RedirectRule> {
+
let lines = content.lines();
+
let mut rules = Vec::new();
+
+
for (line_num, line_raw) in lines.enumerate() {
+
if line_raw.trim().is_empty() || line_raw.trim().starts_with('#') {
+
continue;
+
}
+
+
// Enforce max rules limit
+
if rules.len() >= MAX_REDIRECT_RULES {
+
eprintln!(
+
"Redirect rules limit reached ({}), ignoring remaining rules",
+
MAX_REDIRECT_RULES
+
);
+
break;
+
}
+
+
match parse_redirect_line(line_raw.trim()) {
+
Ok(Some(rule)) => rules.push(rule),
+
Ok(None) => continue,
+
Err(e) => {
+
eprintln!(
+
"Failed to parse redirect rule on line {}: {} ({})",
+
line_num + 1,
+
line_raw,
+
e
+
);
+
}
+
}
+
}
+
+
rules
+
}
+
+
/// Parse a single redirect rule line
+
/// Format: /from [query_params] /to [status] [conditions]
+
fn parse_redirect_line(line: &str) -> Result<Option<RedirectRule>, String> {
+
let parts: Vec<&str> = line.split_whitespace().collect();
+
+
if parts.len() < 2 {
+
return Ok(None);
+
}
+
+
let mut idx = 0;
+
let from = parts[idx];
+
idx += 1;
+
+
let mut status = 301; // Default status
+
let mut force = false;
+
let mut query_params: HashMap<String, String> = HashMap::new();
+
+
// Parse query parameters that come before the destination path
+
while idx < parts.len() {
+
let part = parts[idx];
+
+
// If it starts with / or http, it's the destination path
+
if part.starts_with('/') || part.starts_with("http://") || part.starts_with("https://") {
+
break;
+
}
+
+
// If it contains = and comes before the destination, it's a query param
+
if part.contains('=') {
+
let split_index = part.find('=').unwrap();
+
let key = &part[..split_index];
+
let value = &part[split_index + 1..];
+
+
if !key.is_empty() && !value.is_empty() {
+
query_params.insert(key.to_string(), value.to_string());
+
}
+
idx += 1;
+
} else {
+
break;
+
}
+
}
+
+
// Next part should be the destination
+
if idx >= parts.len() {
+
return Ok(None);
+
}
+
+
let to = parts[idx];
+
idx += 1;
+
+
// Parse remaining parts for status code
+
for part in parts.iter().skip(idx) {
+
// Check for status code (with optional ! for force)
+
if let Some(stripped) = part.strip_suffix('!') {
+
if let Ok(s) = stripped.parse::<u16>() {
+
force = true;
+
status = s;
+
}
+
} else if let Ok(s) = part.parse::<u16>() {
+
status = s;
+
}
+
// Note: We're ignoring conditional redirects (Country, Language, Cookie, Role) for now
+
// They can be added later if needed
+
}
+
+
// Parse the 'from' pattern
+
let (pattern, params) = convert_path_to_regex(from)?;
+
+
Ok(Some(RedirectRule {
+
from: from.to_string(),
+
to: to.to_string(),
+
status,
+
force,
+
from_pattern: pattern,
+
from_params: params,
+
query_params: if query_params.is_empty() {
+
None
+
} else {
+
Some(query_params)
+
},
+
}))
+
}
+
+
/// Convert a path pattern with placeholders and splats to a regex
+
/// Examples:
+
/// /blog/:year/:month/:day -> captures year, month, day
+
/// /news/* -> captures splat
+
fn convert_path_to_regex(pattern: &str) -> Result<(Regex, Vec<String>), String> {
+
let mut params = Vec::new();
+
let mut regex_str = String::from("^");
+
+
// Split by query string if present
+
let path_part = pattern.split('?').next().unwrap_or(pattern);
+
+
// Escape special regex characters except * and :
+
let mut escaped = String::new();
+
for ch in path_part.chars() {
+
match ch {
+
'.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => {
+
escaped.push('\\');
+
escaped.push(ch);
+
}
+
_ => escaped.push(ch),
+
}
+
}
+
+
// Replace :param with named capture groups
+
let param_regex = Regex::new(r":([a-zA-Z_][a-zA-Z0-9_]*)").map_err(|e| e.to_string())?;
+
let mut last_end = 0;
+
let mut result = String::new();
+
+
for cap in param_regex.captures_iter(&escaped) {
+
let m = cap.get(0).unwrap();
+
result.push_str(&escaped[last_end..m.start()]);
+
result.push_str("([^/?]+)");
+
params.push(cap[1].to_string());
+
last_end = m.end();
+
}
+
result.push_str(&escaped[last_end..]);
+
escaped = result;
+
+
// Replace * with splat capture
+
if escaped.contains('*') {
+
escaped = escaped.replace('*', "(.*)");
+
params.push("splat".to_string());
+
}
+
+
regex_str.push_str(&escaped);
+
+
// Make trailing slash optional
+
if !regex_str.ends_with(".*") {
+
regex_str.push_str("/?");
+
}
+
+
regex_str.push('$');
+
+
let pattern = Regex::new(&regex_str).map_err(|e| e.to_string())?;
+
+
Ok((pattern, params))
+
}
+
+
/// Match a request path against redirect rules
+
pub fn match_redirect_rule(
+
request_path: &str,
+
rules: &[RedirectRule],
+
query_params: Option<&HashMap<String, String>>,
+
) -> Option<RedirectMatch> {
+
// Normalize path: ensure leading slash
+
let normalized_path = if request_path.starts_with('/') {
+
request_path.to_string()
+
} else {
+
format!("/{}", request_path)
+
};
+
+
for rule in rules {
+
// Check query parameter conditions first (if any)
+
if let Some(required_params) = &rule.query_params {
+
if let Some(actual_params) = query_params {
+
let query_matches = required_params.iter().all(|(key, expected_value)| {
+
if let Some(actual_value) = actual_params.get(key) {
+
// If expected value is a placeholder (:name), any value is acceptable
+
if expected_value.starts_with(':') {
+
return true;
+
}
+
// Otherwise it must match exactly
+
actual_value == expected_value
+
} else {
+
false
+
}
+
});
+
+
if !query_matches {
+
continue;
+
}
+
} else {
+
// Rule requires query params but none provided
+
continue;
+
}
+
}
+
+
// Match the path pattern
+
if let Some(captures) = rule.from_pattern.captures(&normalized_path) {
+
let mut target_path = rule.to.clone();
+
+
// Replace captured parameters
+
for (i, param_name) in rule.from_params.iter().enumerate() {
+
if let Some(param_value) = captures.get(i + 1) {
+
let value = param_value.as_str();
+
+
if param_name == "splat" {
+
target_path = target_path.replace(":splat", value);
+
} else {
+
target_path = target_path.replace(&format!(":{}", param_name), value);
+
}
+
}
+
}
+
+
// Handle query parameter replacements
+
if let Some(required_params) = &rule.query_params {
+
if let Some(actual_params) = query_params {
+
for (key, placeholder) in required_params {
+
if placeholder.starts_with(':') {
+
if let Some(actual_value) = actual_params.get(key) {
+
let param_name = &placeholder[1..];
+
target_path = target_path.replace(
+
&format!(":{}", param_name),
+
actual_value,
+
);
+
}
+
}
+
}
+
}
+
}
+
+
// Preserve query string for 200, 301, 302 redirects (unless target already has one)
+
if [200, 301, 302].contains(&rule.status)
+
&& query_params.is_some()
+
&& !target_path.contains('?')
+
{
+
if let Some(params) = query_params {
+
if !params.is_empty() {
+
let query_string: String = params
+
.iter()
+
.map(|(k, v)| format!("{}={}", k, v))
+
.collect::<Vec<_>>()
+
.join("&");
+
target_path = format!("{}?{}", target_path, query_string);
+
}
+
}
+
}
+
+
return Some(RedirectMatch {
+
target_path,
+
status: rule.status,
+
force: rule.force,
+
});
+
}
+
}
+
+
None
+
}
+
+
/// Load redirect rules from a _redirects file
+
pub fn load_redirect_rules(directory: &Path) -> Vec<RedirectRule> {
+
let redirects_path = directory.join("_redirects");
+
+
if !redirects_path.exists() {
+
return Vec::new();
+
}
+
+
match fs::read_to_string(&redirects_path) {
+
Ok(content) => parse_redirects_file(&content),
+
Err(e) => {
+
eprintln!("Failed to load _redirects file: {}", e);
+
Vec::new()
+
}
+
}
+
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
+
#[test]
+
fn test_parse_simple_redirect() {
+
let content = "/old-path /new-path";
+
let rules = parse_redirects_file(content);
+
assert_eq!(rules.len(), 1);
+
assert_eq!(rules[0].from, "/old-path");
+
assert_eq!(rules[0].to, "/new-path");
+
assert_eq!(rules[0].status, 301);
+
assert!(!rules[0].force);
+
}
+
+
#[test]
+
fn test_parse_with_status() {
+
let content = "/temp /target 302";
+
let rules = parse_redirects_file(content);
+
assert_eq!(rules[0].status, 302);
+
}
+
+
#[test]
+
fn test_parse_force_redirect() {
+
let content = "/force /target 301!";
+
let rules = parse_redirects_file(content);
+
assert!(rules[0].force);
+
}
+
+
#[test]
+
fn test_match_exact_path() {
+
let rules = parse_redirects_file("/old-path /new-path");
+
let m = match_redirect_rule("/old-path", &rules, None);
+
assert!(m.is_some());
+
assert_eq!(m.unwrap().target_path, "/new-path");
+
}
+
+
#[test]
+
fn test_match_splat() {
+
let rules = parse_redirects_file("/news/* /blog/:splat");
+
let m = match_redirect_rule("/news/2024/01/15/post", &rules, None);
+
assert!(m.is_some());
+
assert_eq!(m.unwrap().target_path, "/blog/2024/01/15/post");
+
}
+
+
#[test]
+
fn test_match_placeholders() {
+
let rules = parse_redirects_file("/blog/:year/:month/:day /posts/:year-:month-:day");
+
let m = match_redirect_rule("/blog/2024/01/15", &rules, None);
+
assert!(m.is_some());
+
assert_eq!(m.unwrap().target_path, "/posts/2024-01-15");
+
}
+
}
+237 -64
cli/src/serve.rs
···
use crate::pull::pull_site;
-
use axum::Router;
use jacquard::CowStr;
-
use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams};
use jacquard_common::types::string::Did;
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient};
use miette::IntoDiagnostic;
use n0_future::StreamExt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir;
-
use url::Url;
/// Shared state for the server
#[derive(Clone)]
···
rkey: CowStr<'static>,
output_dir: PathBuf,
last_cid: Arc<RwLock<Option<String>>>,
}
/// Serve a site locally with real-time firehose updates
···
let did_str = CowStr::from(did.as_str().to_string());
pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?;
// Create shared state
let state = ServerState {
did: did_str.clone(),
rkey: rkey.clone(),
output_dir: output_dir.clone(),
last_cid: Arc::new(RwLock::new(None)),
};
// Start firehose listener in background
···
}
});
-
// Create HTTP server with gzip compression
let app = Router::new()
-
.fallback_service(
-
ServeDir::new(&output_dir)
-
.precompressed_gzip()
-
)
-
.layer(CompressionLayer::new())
-
.with_state(state);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr)
···
axum::serve(listener, app).await.into_diagnostic()?;
Ok(())
}
/// Watch the firehose for updates to the specific site
fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> {
Box::pin(async move {
-
let jetstream_url = Url::parse("wss://jetstream1.us-east.fire.hose.cam")
-
.into_diagnostic()?;
-
println!("[Firehose] Connecting to Jetstream...");
// Create subscription client
-
let client = TungsteniteSubscriptionClient::from_base_uri(jetstream_url);
-
// Subscribe with no filters (we'll filter manually)
-
// Jetstream doesn't support filtering by collection in the params builder
-
let params = JetstreamParams::new().build();
let stream = client.subscribe(&params).await.into_diagnostic()?;
-
println!("[Firehose] Connected! Watching for updates...");
// Convert to typed message stream
let (_sink, mut messages) = stream.into_stream();
···
match messages.next().await {
Some(Ok(msg)) => {
if let Err(e) = handle_firehose_message(&state, msg).await {
-
eprintln!("[Firehose] Error handling message: {}", e);
}
}
Some(Err(e)) => {
-
eprintln!("[Firehose] Stream error: {}", e);
// Try to reconnect after a delay
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
return Box::pin(watch_firehose(state)).await;
}
None => {
-
println!("[Firehose] Stream ended, reconnecting...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
return Box::pin(watch_firehose(state)).await;
}
···
}
/// Handle a firehose message
-
async fn handle_firehose_message(
state: &ServerState,
-
msg: JetstreamMessage<'_>,
) -> miette::Result<()> {
match msg {
-
JetstreamMessage::Commit {
-
did,
-
commit,
-
..
-
} => {
-
// Check if this is our site
-
if did.as_str() == state.did.as_str()
-
&& commit.collection.as_str() == "place.wisp.fs"
-
&& commit.rkey.as_str() == state.rkey.as_str()
-
{
-
match commit.operation {
-
CommitOperation::Create | CommitOperation::Update => {
-
let new_cid = commit.cid.as_ref().map(|c| c.to_string());
-
-
// Check if CID changed
-
let should_update = {
-
let last_cid = state.last_cid.read().await;
-
new_cid != *last_cid
-
};
-
if should_update {
-
println!("\n[Update] Detected change to site {} (CID: {:?})", state.rkey, new_cid);
-
println!("[Update] Pulling latest version...");
-
// Pull the updated site
-
match pull_site(
-
state.did.clone(),
-
state.rkey.clone(),
-
state.output_dir.clone(),
-
)
-
.await
-
{
-
Ok(_) => {
-
// Update last CID
-
let mut last_cid = state.last_cid.write().await;
-
*last_cid = new_cid;
-
println!("[Update] ✓ Site updated successfully!\n");
-
}
-
Err(e) => {
-
eprintln!("[Update] Failed to pull site: {}", e);
-
}
}
}
-
}
-
CommitOperation::Delete => {
println!("\n[Update] Site {} was deleted", state.rkey);
}
}
}
···
use crate::pull::pull_site;
+
use crate::redirects::{load_redirect_rules, match_redirect_rule, RedirectRule};
+
use axum::{
+
Router,
+
extract::Request,
+
response::{Response, IntoResponse, Redirect},
+
http::{StatusCode, Uri},
+
};
use jacquard::CowStr;
+
use jacquard::api::com_atproto::sync::subscribe_repos::{SubscribeRepos, SubscribeReposMessage};
use jacquard_common::types::string::Did;
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient};
use miette::IntoDiagnostic;
use n0_future::StreamExt;
+
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
+
use tower::Service;
use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir;
/// Shared state for the server
#[derive(Clone)]
···
rkey: CowStr<'static>,
output_dir: PathBuf,
last_cid: Arc<RwLock<Option<String>>>,
+
redirect_rules: Arc<RwLock<Vec<RedirectRule>>>,
}
/// Serve a site locally with real-time firehose updates
···
let did_str = CowStr::from(did.as_str().to_string());
pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?;
+
// Load redirect rules
+
let redirect_rules = load_redirect_rules(&output_dir);
+
if !redirect_rules.is_empty() {
+
println!("Loaded {} redirect rules from _redirects", redirect_rules.len());
+
}
+
// Create shared state
let state = ServerState {
did: did_str.clone(),
rkey: rkey.clone(),
output_dir: output_dir.clone(),
last_cid: Arc::new(RwLock::new(None)),
+
redirect_rules: Arc::new(RwLock::new(redirect_rules)),
};
// Start firehose listener in background
···
}
});
+
// Create HTTP server with gzip compression and redirect handling
+
let serve_dir = ServeDir::new(&output_dir).precompressed_gzip();
+
let app = Router::new()
+
.fallback(move |req: Request| {
+
let state = state.clone();
+
let mut serve_dir = serve_dir.clone();
+
async move {
+
handle_request_with_redirects(req, state, &mut serve_dir).await
+
}
+
})
+
.layer(CompressionLayer::new());
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr)
···
axum::serve(listener, app).await.into_diagnostic()?;
Ok(())
+
}
+
+
/// Handle a request with redirect support
+
async fn handle_request_with_redirects(
+
req: Request,
+
state: ServerState,
+
serve_dir: &mut ServeDir,
+
) -> Response {
+
let uri = req.uri().clone();
+
let path = uri.path();
+
let method = req.method().clone();
+
+
// Parse query parameters
+
let query_params = uri.query().map(|q| {
+
let mut params = HashMap::new();
+
for pair in q.split('&') {
+
if let Some((key, value)) = pair.split_once('=') {
+
params.insert(key.to_string(), value.to_string());
+
}
+
}
+
params
+
});
+
+
// Check for redirect rules
+
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;
+
drop(redirect_rules); // Release the lock
+
+
// If not forced, check if the file exists first
+
if !is_force {
+
// Try to serve the file normally first
+
let test_req = Request::builder()
+
.uri(uri.clone())
+
.method(&method)
+
.body(axum::body::Body::empty())
+
.unwrap();
+
+
match serve_dir.call(test_req).await {
+
Ok(response) if response.status().is_success() => {
+
// File exists and was served successfully, return it
+
return response.into_response();
+
}
+
_ => {
+
// File doesn't exist or error, apply redirect
+
}
+
}
+
}
+
+
// Handle different status codes
+
match redirect_match.status {
+
200 => {
+
// Rewrite: serve the target file but keep the URL the same
+
if let Ok(target_uri) = redirect_match.target_path.parse::<Uri>() {
+
let new_req = Request::builder()
+
.uri(target_uri)
+
.method(&method)
+
.body(axum::body::Body::empty())
+
.unwrap();
+
+
match serve_dir.call(new_req).await {
+
Ok(response) => response.into_response(),
+
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+
}
+
} else {
+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
+
}
+
}
+
301 => {
+
// Permanent redirect
+
Redirect::permanent(&redirect_match.target_path).into_response()
+
}
+
302 => {
+
// Temporary redirect
+
Redirect::temporary(&redirect_match.target_path).into_response()
+
}
+
404 => {
+
// Custom 404 page
+
if let Ok(target_uri) = redirect_match.target_path.parse::<Uri>() {
+
let new_req = Request::builder()
+
.uri(target_uri)
+
.method(&method)
+
.body(axum::body::Body::empty())
+
.unwrap();
+
+
match serve_dir.call(new_req).await {
+
Ok(mut response) => {
+
*response.status_mut() = StatusCode::NOT_FOUND;
+
response.into_response()
+
}
+
Err(_) => StatusCode::NOT_FOUND.into_response(),
+
}
+
} else {
+
StatusCode::NOT_FOUND.into_response()
+
}
+
}
+
_ => {
+
// Unsupported status code, fall through to normal serving
+
match serve_dir.call(req).await {
+
Ok(response) => response.into_response(),
+
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+
}
+
}
+
}
+
} else {
+
drop(redirect_rules);
+
// No redirect match, serve normally
+
match serve_dir.call(req).await {
+
Ok(response) => response.into_response(),
+
Err(_) => StatusCode::NOT_FOUND.into_response(),
+
}
+
}
}
/// Watch the firehose for updates to the specific site
fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> {
Box::pin(async move {
+
use jacquard_identity::PublicResolver;
+
use jacquard::prelude::IdentityResolver;
+
+
// Resolve DID to PDS URL
+
let resolver = PublicResolver::default();
+
let did = Did::new(&state.did).into_diagnostic()?;
+
let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?;
+
+
println!("[PDS] Resolved DID to PDS: {}", pds_url);
+
+
// Convert HTTP(S) URL to WebSocket URL
+
let mut ws_url = pds_url.clone();
+
let scheme = if pds_url.scheme() == "https" { "wss" } else { "ws" };
+
ws_url.set_scheme(scheme)
+
.map_err(|_| miette::miette!("Failed to set WebSocket scheme"))?;
+
println!("[PDS] Connecting to {}...", ws_url);
// Create subscription client
+
let client = TungsteniteSubscriptionClient::from_base_uri(ws_url);
+
// Subscribe to the PDS firehose
+
let params = SubscribeRepos::new().build();
let stream = client.subscribe(&params).await.into_diagnostic()?;
+
println!("[PDS] Connected! Watching for updates...");
// Convert to typed message stream
let (_sink, mut messages) = stream.into_stream();
···
match messages.next().await {
Some(Ok(msg)) => {
if let Err(e) = handle_firehose_message(&state, msg).await {
+
eprintln!("[PDS] Error handling message: {}", e);
}
}
Some(Err(e)) => {
+
eprintln!("[PDS] Stream error: {}", e);
// Try to reconnect after a delay
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
return Box::pin(watch_firehose(state)).await;
}
None => {
+
println!("[PDS] Stream ended, reconnecting...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
return Box::pin(watch_firehose(state)).await;
}
···
}
/// Handle a firehose message
+
async fn handle_firehose_message<'a>(
state: &ServerState,
+
msg: SubscribeReposMessage<'a>,
) -> miette::Result<()> {
match msg {
+
SubscribeReposMessage::Commit(commit_msg) => {
+
// Check if this commit is from our DID
+
if commit_msg.repo.as_str() != state.did.as_str() {
+
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);
+
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 {
+
println!("[Debug] - {} {}", op.action.as_ref(), op.path.as_ref());
+
}
+
}
+
}
+
+
if has_site_update {
+
// Use the commit CID as the version tracker
+
let commit_cid = commit_msg.commit.to_string();
+
+
// Check if this is a new commit
+
let should_update = {
+
let last_cid = state.last_cid.read().await;
+
Some(commit_cid.clone()) != *last_cid
+
};
+
+
if should_update {
+
// Check operation types
+
let has_create_or_update = commit_msg.ops.iter().any(|op| {
+
op.path.as_ref() == target_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"
+
});
+
+
// If there's a create/update, pull the site (even if there's also a delete in the same commit)
+
if has_create_or_update {
+
println!("\n[Update] Detected change to site {} (commit: {})", state.rkey, commit_cid);
+
println!("[Update] Pulling latest version...");
+
+
// Pull the updated site
+
match pull_site(
+
state.did.clone(),
+
state.rkey.clone(),
+
state.output_dir.clone(),
+
)
+
.await
+
{
+
Ok(_) => {
+
// Update last CID
+
let mut last_cid = state.last_cid.write().await;
+
*last_cid = Some(commit_cid);
+
+
// Reload redirect rules
+
let new_redirect_rules = load_redirect_rules(&state.output_dir);
+
let mut redirect_rules = state.redirect_rules.write().await;
+
*redirect_rules = new_redirect_rules;
+
+
println!("[Update] ✓ Site updated successfully!\n");
+
}
+
Err(e) => {
+
eprintln!("[Update] Failed to pull site: {}", e);
}
}
+
} else if has_delete {
+
// Only a delete, no create/update
println!("\n[Update] Site {} was deleted", state.rkey);
+
+
// 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);
}
}
}