use anyhow::{Context, Result, anyhow, bail};
use clap::Args;
use glob::Pattern;
use smol::{
fs::{File, metadata, read_dir},
future::zip,
io::{AsyncBufReadExt, BufReader},
stream::StreamExt,
};
use std::{
collections::{BTreeSet, VecDeque},
ffi::OsStr,
path::{Path as FsPath, PathBuf},
str::FromStr,
};
use ufotofu::{
channels::new_sssr, prelude::*, producer::compat::reader::reader_to_bulk_producer,
queues::new_unbounded_elastic,
};
use willow25::{prelude::*, storage::*};
use crate::util::*;
#[derive(Args)]
pub struct PublishArgs {
pub src: PathBuf,
#[arg(short, long)]
pub domain: Option<String>,
#[arg(short, long)]
pub secret: Option<String>,
#[arg(short, long)]
pub collection: Option<PathBuf>,
}
pub async fn publish(args: &PublishArgs) -> Result<()> {
let dir = &args
.src
.canonicalize()
.context("could not resolve filesystem path to publish")?;
let dir_announce = domain_style(
&dir.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or(dir.to_string_lossy()),
);
println!("Publishing {}...", dir_announce);
let (subspace, domain) = get_domain(args.domain.as_deref(), "publish to")?;
warn_deprecated_domain_encoding(&domain, &subspace).await;
let secret = get_secret(args.secret.as_deref(), &domain)?;
if secret.corresponding_subspace_id() != subspace {
bail!("invalid secret for the requested domain, publishing cancelled");
}
let namespace = NamespaceId::from_bytes(&SNEAKERWEB_NAMESPACE_ID_BYTES);
let write_cap = WriteCapability::new_communal(namespace.clone(), subspace.clone());
let sneakerweb_fs_path = sneakerweb_dir(args.collection.as_ref()).await.context(
"could not retrieve the filesystem path to sneakerweb configuration and storage",
)?;
let mut store = PersistentStore::new(&sneakerweb_fs_path)
.await
.context("could not open sneakerweb storage")?;
let now = Timestamp::now().context("could not establish a current timestamp")?;
store
.create_entry(
&namespace,
&subspace,
&Path::new(),
now,
&mut [].into_producer(),
0,
&write_cap,
&secret,
)
.await
.map_err(|_| anyhow!("failed to clean the target domain"))?;
yay("domain cleaned and ready for publication").await;
let mut publish_filters = BTreeSet::new();
let global_nopublish_path = sneakerweb_fs_path.join("nopublish");
if metadata(global_nopublish_path).await.is_ok() {
let global_nopublish = File::open(sneakerweb_fs_path.join("nopublish"))
.await
.context("a 'nopublish' file was found in the sneakerweb config directory, but it could not be opened")?;
update_filters(&mut publish_filters, global_nopublish, dir)
.await.context("failed to add global publishing filters defined in the 'nopublish' file in the sneakerweb config directory")?
}
let (sender, receiver) = new_sssr(new_unbounded_elastic());
let ((found_index, found_sneakerweb), ()) = zip(
gather(dir, sender, &mut publish_filters),
ingest(
dir, receiver, &mut store, &namespace, &subspace, &write_cap, &secret,
),
)
.await;
if !found_index {
hmm(&format!(
"no {} file was found in the root of the published site, so it may not be viewable in a web browser",
emph_style("index.html")
))
.await;
}
if !found_sneakerweb {
hmm(&format!(
"no {} file was found in the root of the published site, so no site preview will be viewable.",
emph_style("sneakerweb.html")
))
.await;
}
store
.flush()
.await
.map_err(|_| anyhow!("failed to flush updates to sneakerweb storage"))
}
async fn gather<C: Consumer<Item = PathBuf, Final = ()>>(
root: &PathBuf,
mut filepath_consumer: C,
filters: &mut BTreeSet<Pattern>,
) -> (bool, bool) {
filters.insert(
Pattern::from_str("**/.nopublish")
.expect("the pattern matching all .nopublish files should be valid"),
);
let dir_str = root.to_string_lossy();
let mut found_index = false;
let mut found_sneakerweb = false;
let mut to_read = VecDeque::new();
to_read.push_back(root.clone());
while let Some(this_path) = to_read.pop_front() {
if filters.iter().any(|filter| filter.matches_path(&this_path)) {
continue;
}
let nopublish = this_path.join(".nopublish");
if metadata(&nopublish).await.is_ok() {
let Ok(file) = File::open(&nopublish).await else {
oh_no(&format!(
"found a '.nopublish' file at {}, but could not open it. Skipping this directory for security.",
emph_style(&dir_str)
))
.await;
continue;
};
let Ok(()) = update_filters(filters, file, &this_path).await else {
oh_no(&format!(
"failed to update publication filters from '.nopublish' file at {}. Skipping this directory for security.",
emph_style(&dir_str)
))
.await;
continue;
};
}
let relative_path = this_path
.strip_prefix(root)
.expect("dir is a prefix of path")
.to_path_buf();
let Some(path_str) = relative_path.to_str() else {
oh_no(&format!(
"path {} is not supported (only unicode paths are supported)",
relative_path.to_string_lossy()
))
.await;
continue;
};
let Ok(metadata) = metadata(&this_path).await else {
oh_no(&format!(
"could not read metadata for path {}",
emph_style(path_str)
))
.await;
continue;
};
if metadata.is_dir() || metadata.is_symlink() {
let Ok(mut entries) = read_dir(&this_path).await else {
oh_no(&format!(
"could not read from directory {}",
emph_style(path_str)
))
.await;
continue;
};
while let Some(possible_entry) = entries.next().await {
match possible_entry {
Ok(entry) => to_read.push_back(entry.path()),
Err(err) => oh_no(&format!("{}", err)).await,
}
}
}
if metadata.is_file() {
match relative_path.to_str() {
Some("index.html") => found_index = true,
Some("sneakerweb.html") => found_sneakerweb = true,
_ => {}
}
let Ok(()) = filepath_consumer.consume_item(this_path).await else {
oh_no(&format!(
"encountered an error telling sneakerweb storage about {}",
emph_style(path_str)
))
.await;
return (found_index, found_sneakerweb);
};
}
}
if filepath_consumer.consume_final(()).await.is_err() {
oh_no("encountered an error ending the file discovery process").await;
}
(found_index, found_sneakerweb)
}
async fn ingest<P: Producer<Item = PathBuf>>(
root: &PathBuf,
mut filepath_producer: P,
store: &mut PersistentStore,
namespace: &NamespaceId,
subspace: &SubspaceId,
write_capability: &WriteCapability,
secret: &SubspaceSecret,
) {
while let Ok(filepath) = filepath_producer.produce_item().await {
let relative_path = filepath
.strip_prefix(root)
.expect("root is a prefix of path since path was found from root");
let Some(path_str) = relative_path.to_str() else {
oh_no(&format!(
"could not interpret path {} (only unicode paths are supported)",
emph_style(&relative_path.to_string_lossy())
))
.await;
continue;
};
let Ok(entry_metadata) = metadata(&filepath).await else {
oh_no(&format!(
"could not read metadata for file {}",
emph_style(path_str)
))
.await;
continue;
};
let Ok(file) = File::open(&filepath).await else {
oh_no(&format!("could not open file {}", emph_style(path_str))).await;
continue;
};
let mut file_producer = reader_to_bulk_producer(file, new_unbounded_elastic());
let path_slices: Vec<Vec<u8>> = relative_path
.iter()
.map(|component| component.to_str().map(|str| percent_encode(str.as_bytes())))
.collect::<Option<Vec<Vec<u8>>>>()
.expect("already verified unicode path");
let total_length = path_slices.iter().map(|v| v.len()).sum();
let mut path_iter = path_slices.iter().map(AsRef::as_ref);
let Ok(store_path) = Path::from_slices_iter(total_length, &mut path_iter) else {
oh_no(&format!(
"filesystem path {} is not a valid sneakerweb storage path",
path_str
))
.await;
continue;
};
let Ok(now) = Timestamp::now() else {
oh_no("could not establish a current timestamp").await;
continue;
};
match store
.create_entry(
namespace,
subspace,
&store_path,
now,
&mut file_producer,
entry_metadata.len(),
write_capability,
secret,
)
.await
{
Ok(Some(_)) => {
yay(&format!("created an entry for {}", emph_style(path_str))).await;
}
Ok(None) => {
hmm(&format!(
"entry for {} was overwritten by a more recent entry",
emph_style(path_str)
))
.await
}
Err(err) => match err {
CreateEntryError::StoreError(_) => {
oh_no(&format!(
"sneakerweb storage could not accept {}",
emph_style(path_str)
))
.await;
}
CreateEntryError::AuthorisationTokenError => unreachable!(
"already checked that the provided secret authorises the provided domain"
),
},
}
}
}
async fn update_filters(filters: &mut BTreeSet<Pattern>, file: File, from: &FsPath) -> Result<()> {
let mut lines = BufReader::new(file).lines().enumerate();
while let Some((line_number, line)) = lines.next().await {
let filter = from.join(line?);
let pattern = Pattern::from_str(filter.to_str().context(format!(
"could not interpret path {} (only unicode paths are supported",
filter.to_string_lossy()
))?)
.context(format!(
"filter {} on line {} could not be parsed",
filter.to_string_lossy(),
line_number
))?;
filters.insert(pattern);
}
Ok(())
}
fn percent_encode(path: &[u8]) -> Vec<u8> {
let mut encoded = Vec::with_capacity(2 * path.len());
for byte in path {
if allowed_in_path(*byte) {
encoded.push(*byte);
} else {
encoded.extend(format!("%{:x}", byte).as_bytes());
}
}
encoded
}
fn allowed_in_path(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.' || byte == b'_' || byte == b'~'
}