extern crate base64;
mod manifest;
mod sync;
mod upload;
pub use manifest::AssetManifest;
pub use sync::sync;
pub use upload::upload_files;
use std::ffi::OsString;
use std::fs;
use std::hash::Hasher;
use std::path::Path;
use failure::format_err;
use ignore::overrides::{Override, OverrideBuilder};
use ignore::{Walk, WalkBuilder};
use indicatif::{ProgressBar, ProgressStyle};
use twox_hash::XxHash64;
use cloudflare::endpoints::workerskv::write_bulk::KeyValuePair;
use crate::kv::namespace::{upsert, UpsertedNamespace};
use crate::settings::global_user::GlobalUser;
use crate::settings::toml::{KvNamespace, Target};
use crate::terminal::message;
pub const KEY_MAX_SIZE: usize = 512;
pub const VALUE_MAX_SIZE: u64 = 10 * 1024 * 1024;
pub fn add_namespace(
user: &GlobalUser,
target: &mut Target,
preview: bool,
) -> Result<KvNamespace, failure::Error> {
let title = if preview {
format!("__{}-{}", target.name, "workers_sites_assets_preview")
} else {
format!("__{}-{}", target.name, "workers_sites_assets")
};
let site_namespace = match upsert(target, &user, title)? {
UpsertedNamespace::Created(namespace) => {
let msg = format!("Created namespace for Workers Site \"{}\"", namespace.title);
message::working(&msg);
namespace
}
UpsertedNamespace::Reused(namespace) => {
let msg = format!("Using namespace for Workers Site \"{}\"", namespace.title);
message::working(&msg);
namespace
}
};
let site_namespace = KvNamespace {
binding: "__STATIC_CONTENT".to_string(),
id: site_namespace.id,
};
target.add_kv_namespace(site_namespace.clone());
Ok(site_namespace)
}
pub fn directory_keys_values(
target: &Target,
directory: &Path,
) -> Result<(Vec<KeyValuePair>, AssetManifest), failure::Error> {
match &fs::metadata(directory) {
Ok(file_type) if file_type.is_dir() => {
let mut upload_vec: Vec<KeyValuePair> = Vec::new();
let mut asset_manifest = AssetManifest::new();
let dir_walker = get_dir_iterator(target, directory)?;
let spinner_style =
ProgressStyle::default_spinner().template("{spinner} Preparing {msg}...");
let spinner = ProgressBar::new_spinner().with_style(spinner_style);
for entry in dir_walker {
spinner.tick();
let entry = entry.unwrap();
let path = entry.path();
if path.is_file() {
spinner.set_message(&format!("{}", path.display()));
validate_file_size(&path)?;
let value = std::fs::read(path)?;
let b64_value = base64::encode(&value);
let (url_safe_path, key) =
generate_path_and_key(path, directory, Some(b64_value.clone()))?;
validate_key_size(&key)?;
upload_vec.push(KeyValuePair {
key: key.clone(),
value: b64_value,
expiration: None,
expiration_ttl: None,
base64: Some(true),
});
asset_manifest.insert(url_safe_path, key);
}
}
Ok((upload_vec, asset_manifest))
}
Ok(_file_type) => {
Err(format_err!("Check your wrangler.toml; the `bucket` attribute for [site] should point to a directory."))
}
Err(e) => Err(format_err!("{}", e)),
}
}
fn validate_file_size(path: &Path) -> Result<(), failure::Error> {
let metadata = fs::metadata(path)?;
let file_len = metadata.len();
if file_len > VALUE_MAX_SIZE {
failure::bail!(
"File `{}` of {} bytes exceeds the maximum value size limit of {} bytes",
path.display(),
file_len,
VALUE_MAX_SIZE
);
}
Ok(())
}
fn validate_key_size(key: &str) -> Result<(), failure::Error> {
if key.len() > KEY_MAX_SIZE {
failure::bail!(
"Path `{}` of {} bytes exceeds the maximum key size limit of {} bytes",
key,
key.len(),
KEY_MAX_SIZE
);
}
Ok(())
}
const REQUIRED_IGNORE_FILES: &[&str] = &["node_modules"];
const NODE_MODULES: &str = "node_modules";
fn get_dir_iterator(target: &Target, directory: &Path) -> Result<Walk, failure::Error> {
if let Some(name) = directory.file_name() {
if name == NODE_MODULES {
failure::bail!("Your directory of files to upload cannot be named node_modules.");
}
};
let ignore = build_ignore(target, directory)?;
Ok(WalkBuilder::new(directory)
.git_ignore(false)
.overrides(ignore)
.build())
}
fn build_ignore(target: &Target, directory: &Path) -> Result<Override, failure::Error> {
let mut required_override = OverrideBuilder::new(directory);
for ignored in REQUIRED_IGNORE_FILES {
required_override.add(&format!("!{}", ignored))?;
log::info!("Ignoring {}", ignored);
}
if let Some(site) = &target.site {
if let Some(included) = &site.include {
for i in included {
required_override.add(&i)?;
log::info!("Including {}", i);
}
} else if let Some(excluded) = &site.exclude {
for e in excluded {
required_override.add(&format!("!{}", e))?;
log::info!("Ignoring {}", e);
}
}
}
let exclude = required_override.build()?;
Ok(exclude)
}
fn generate_url_safe_path(path: &Path) -> Result<String, failure::Error> {
let mut path_with_forward_slash = OsString::new();
for (i, component) in path.components().enumerate() {
if i > 0 {
path_with_forward_slash.push("/");
}
path_with_forward_slash.push(component);
}
let path = path_with_forward_slash
.to_str()
.unwrap_or_else(|| panic!("found a non-UTF-8 path, {:?}", path_with_forward_slash));
Ok(path.to_string())
}
pub fn generate_path_and_key(
path: &Path,
directory: &Path,
value: Option<String>,
) -> Result<(String, String), failure::Error> {
let relative_path = path.strip_prefix(directory).unwrap();
let url_safe_path = generate_url_safe_path(relative_path)?;
let path_with_hash = if let Some(value) = value {
let digest = get_digest(value);
let digest = digest[0..10].to_string();
generate_path_with_hash(relative_path, digest)?
} else {
url_safe_path.to_owned()
};
Ok((url_safe_path, path_with_hash))
}
fn get_digest(value: String) -> String {
let value = value.as_bytes();
let mut hasher = XxHash64::default();
hasher.write(value);
let digest = hasher.finish();
format!("{:x}", digest)
}
fn generate_path_with_hash(path: &Path, hashed_value: String) -> Result<String, failure::Error> {
if let Some(file_stem) = path.file_stem() {
let mut file_name = file_stem.to_os_string();
let extension = path.extension();
file_name.push(".");
file_name.push(hashed_value);
if let Some(ext) = extension {
file_name.push(".");
file_name.push(ext);
}
let new_path = path.with_file_name(file_name);
Ok(generate_url_safe_path(&new_path)?)
} else {
failure::bail!("no file_stem for path {}", path.display())
}
}
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::settings::toml::{Site, Target, TargetType};
fn make_target(site: Site) -> Target {
Target {
account_id: "".to_string(),
kv_namespaces: Vec::new(),
name: "".to_string(),
target_type: TargetType::JavaScript,
webpack_config: None,
site: Some(site),
vars: None,
}
}
#[test]
fn it_can_ignore_node_modules() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
let target = make_target(site);
let test_dir = "test1";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir_all(format!("{}/node_modules", test_dir)).unwrap();
let test_pathname = format!("{}/node_modules/ignore_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(!files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_ignore_hidden() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
let target = make_target(site);
let test_dir = "test2";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let test_pathname = format!("{}/.ignore_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(!files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_allow_unfiltered_files() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
let target = make_target(site);
let test_dir = "test3";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let test_pathname = format!("{}/notice_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_filter_by_include() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
site.include = Some(vec!["this_isnt_here.txt".to_string()]);
let target = make_target(site);
let test_dir = "test4";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let test_pathname = format!("{}/ignore_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(!files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_filter_by_exclude() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
site.exclude = Some(vec!["ignore_me.txt".to_string()]);
let target = make_target(site);
let test_dir = "test5";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let test_pathname = format!("{}/ignore_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(!files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_prioritize_include_over_exclude() {
let mut site = Site::default();
site.bucket = PathBuf::from("fake");
site.include = Some(vec!["notice_me.txt".to_string()]);
site.exclude = Some(vec!["notice_me.txt".to_string()]);
let target = make_target(site);
let test_dir = "test6";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let test_pathname = format!("{}/notice_me.txt", test_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(test_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(files.contains(&test_path));
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_can_include_gitignore_entries() {
let mut site = Site::default();
site.bucket = PathBuf::from("public");
let target = make_target(site);
let test_dir = "test7";
if fs::metadata(test_dir).is_ok() {
fs::remove_dir_all(test_dir).unwrap();
}
fs::create_dir(test_dir).unwrap();
let gitignore_pathname = format!("{}/.gitignore", test_dir);
let gitignore_path = PathBuf::from(&gitignore_pathname);
let mut gitignore = fs::File::create(&gitignore_path).unwrap();
writeln!(gitignore, "public/").unwrap();
let upload_dir = format!("{}/public", test_dir);
fs::create_dir(&upload_dir).unwrap();
let test_pathname = format!("{}/notice_me.txt", &upload_dir);
let test_path = PathBuf::from(&test_pathname);
fs::File::create(&test_path).unwrap();
let files: Vec<_> = get_dir_iterator(&target, Path::new(&upload_dir))
.unwrap()
.map(|entry| entry.unwrap().path().to_owned())
.collect();
assert!(files.contains(&test_path));
drop(gitignore);
fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn it_inserts_hash_before_extension() {
let value = "<h1>Hello World!</h1>";
let hashed_value = get_digest(String::from(value));
let path = PathBuf::from("path").join("to").join("asset.html");
let actual_path_with_hash =
generate_path_with_hash(&path, hashed_value.to_owned()).unwrap();
let expected_path_with_hash = format!("path/to/asset.{}.html", hashed_value);
assert_eq!(actual_path_with_hash, expected_path_with_hash);
}
#[test]
fn it_inserts_hash_without_extension() {
let value = "<h1>Hello World!</h1>";
let hashed_value = get_digest(String::from(value));
let path = PathBuf::from("path").join("to").join("asset");
let actual_path_with_hash =
generate_path_with_hash(&path, hashed_value.to_owned()).unwrap();
let expected_path_with_hash = format!("path/to/asset.{}", hashed_value);
assert_eq!(actual_path_with_hash, expected_path_with_hash);
}
#[test]
fn it_generates_a_url_safe_hash() {
let os_path = Path::new("some_stuff/invalid file&name.chars");
let actual_url_safe_path = generate_url_safe_path(os_path).unwrap();
let expected_url_safe_path = "some_stuff/invalid file&name.chars";
assert_eq!(actual_url_safe_path, expected_url_safe_path);
}
#[test]
fn it_removes_bucket_dir_prefix() {
let path = Path::new("./build/path/to/asset.ext");
let directory = Path::new("./build");
let value = Some("<h1>Hello World!</h1>".to_string());
let (path, key) = generate_path_and_key(path, directory, value).unwrap();
assert!(!path.contains("directory"));
assert!(!key.contains("directory"));
}
#[test]
fn it_combines_url_safe_and_hash_properly() {
let path = Path::new("./build/path/to/asset.ext");
let directory = Path::new("./build");
let value = Some("<h1>Hello World!</h1>".to_string());
let (path, key) = generate_path_and_key(path, directory, value).unwrap();
let expected_path = "path/to/asset.ext".to_string();
let expected_key_regex = Regex::new(r"^path/to/asset\.[0-9a-f]{10}\.ext").unwrap();
assert_eq!(path, expected_path);
assert!(expected_key_regex.is_match(&key));
}
}