Merge pull request 'Add crate logging' (#17) from logging into main

Reviewed-on: #17
This commit is contained in:
Anthony J. Martinez 2021-04-17 12:12:38 +00:00
commit 23d30c9171
13 changed files with 282 additions and 192 deletions

2
Cargo.lock generated
View File

@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "arse"
version = "0.2.0-alpha.1"
version = "0.3.0-alpha.1"
dependencies = [
"argon2",
"clap",

View File

@ -1,6 +1,6 @@
[package]
name = "arse"
version = "0.2.0-alpha.1"
version = "0.3.0-alpha.1"
authors = ["Anthony Martinez"]
edition = "2018"
license = "MIT OR Apache-2.0"
@ -19,7 +19,7 @@ clap = "2.33.3"
data-encoding = "2.3.2"
glob = "0.3.0"
hyper = "0.14"
log = "0.4.14"
log = "0.4"
pulldown-cmark = { version = "0.8", default-features = false, features = ["simd"] }
rand = "0.8"
routerify = "2.0.0"

View File

@ -6,11 +6,13 @@ and flexible base for serving sites using:
* [Tera](https://tera.netlify.app/) for templates
* [pulldown-cmark](https://crates.io/crates/pulldown-cmark) for CommonMark rendering
* [routerify](https://crates.io/crates/routerify) to serve the site
* [simplecss](https://simplecss.org) for default styling
## Binary
* Run an existing site given the path to its config TOML: `arse run config.toml`
* Create and run a new site from user input: `arse new`
* Logging verbosity can be increased with `-v` or `-vv`, the default level is `INFO`.
## Library
@ -18,6 +20,15 @@ and flexible base for serving sites using:
Documentation can be found [here](https://docs.rs/arse/).
## Path to 1.0
- [x] Dynamic route handling
- [x] Provide meaningful logging of library and binary activites at appropriate levels
- [ ] Documentation of full public API
- [ ] Support custom Tera templates
- [ ] Context-specific Errors and handling
- [ ] Administration portal for site management
### License
Licensed under either of

View File

@ -1,41 +1,56 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
/// TODO Document
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use log::{debug, error};
/**
TODO Document
*/
pub fn generate_secret(len: usize) -> Result<String, Box<dyn std::error::Error>> {
use rand::{thread_rng, Rng,
distributions::Alphanumeric};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
if len < 32 {
Err(From::from("Random passwords shorter than 32ch are useless"))
error!("Attempting to use password < 32ch.");
Err(From::from("Random passwords shorter than 32ch are useless"))
} else {
let pass: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect();
Ok(pass)
let pass: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect();
debug!("New secret generated");
Ok(pass)
}
}
/// TODO Document
/**
TODO Document
*/
pub fn generate_argon2_phc(secret: &str) -> Result<String, Box<dyn std::error::Error>> {
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::rngs::OsRng;
use argon2::{Argon2, password_hash::{SaltString, PasswordHasher}};
let secret = secret.as_bytes();
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let argon2_phc: Result<String, Box<dyn std::error::Error>>;
if let Ok(phc) = argon2.hash_password_simple(secret, salt.as_ref()) {
argon2_phc = Ok(phc.to_string());
debug!("Created Argon2 PHC string");
argon2_phc = Ok(phc.to_string());
} else {
argon2_phc = Err(From::from("Failed to hash password"));
error!("Failed to create Argon2 PHC");
argon2_phc = Err(From::from("Failed to hash password"));
}
argon2_phc
@ -49,23 +64,23 @@ mod tests {
#[test]
fn check_secret_len() {
const SECLEN: usize = 32;
let secret = generate_secret(SECLEN).unwrap();
assert_eq!(SECLEN, secret.len())
const SECLEN: usize = 32;
let secret = generate_secret(SECLEN).unwrap();
assert_eq!(SECLEN, secret.len())
}
#[test]
fn check_short_secret() {
const SECLEN: usize = 12;
let secret = generate_secret(SECLEN);
assert!(secret.is_err())
const SECLEN: usize = 12;
let secret = generate_secret(SECLEN);
assert!(secret.is_err())
}
#[test]
fn check_argon2_hasher() {
const SECLEN: usize = 32;
let secret = generate_secret(SECLEN).unwrap();
let phc = generate_argon2_phc(&secret);
assert!(phc.is_ok())
const SECLEN: usize = 32;
let secret = generate_secret(SECLEN).unwrap();
let phc = generate_argon2_phc(&secret);
assert!(phc.is_ok())
}
}

View File

@ -1,15 +1,18 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use std::net::SocketAddr;
use std::sync::Arc;
use arse::*;
use log::{info, error};
use hyper::Server;
use routerify::RouterService;
@ -17,16 +20,22 @@ use routerify::RouterService;
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = config::load()?;
let app = Arc::new(config);
info!("Configuration loaded");
// TODO: Configure logging
let router = routes::router(app.clone());
info!("Route handlers loaded");
let service = RouterService::new(router).unwrap();
info!("Service loaded");
let addr: SocketAddr = "0.0.0.0:9090".parse().unwrap();
info!("Creating server on: {}", &addr);
let server = Server::bind(&addr).serve(service);
info!("Running server on: {}", &addr);
if let Err(err) = server.await {
eprintln!("Server error: {}", err)
error!("Server error: {}", err)
}
Ok(())

View File

@ -1,60 +1,75 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use std::fs::OpenOptions;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use glob::glob;
use log::{debug, trace};
#[cfg(target_family = "unix")]
pub fn str_to_ro_file<P: AsRef<Path>>(content: &str, dest: P) -> Result<(), Box<dyn std::error::Error>> {
debug!("Writing protected file: {}", &dest.as_ref().display());
use std::os::unix::fs::OpenOptionsExt;
let mut options = OpenOptions::new();
options.create(true);
options.write(true);
options.mode(0o600);
trace!("Opening '{}' to write", &dest.as_ref().display());
let mut ro_file = options.open(dest)?;
ro_file.write_all(content.as_bytes())?;
if !content.ends_with("\n") {
ro_file.write(b"\n")?;
if !content.ends_with('\n') {
ro_file.write_all(b"\n")?;
}
trace!("Content written to destination");
Ok(())
}
#[cfg(target_family = "windows")]
pub fn str_to_ro_file<P: AsRef<Path>>(content: &str, dest: P) -> Result<(), Box<dyn std::error::Error>> {
debug!("Writing protected file: {}", &dest.as_ref().display());
trace!("Opening '{}' to write", &dest.as_ref().display());
let mut ro_file = File::create(dest)?;
ro_file.write_all(content.as_bytes())?;
let metadata = secret_file.metadata()?;
let mut perms = metadata.permissions();
if !content.ends_with("\n") {
ro_file.write(b"\n")?;
if !content.ends_with('\n') {
ro_file.write_all(b"\n")?;
}
trace!("Content written to destination");
trace!("Setting read-only on destination file");
perms.set_readonly(true);
Ok(())
}
pub fn path_matches(pat: &str) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
debug!("Building topic content vector from {}", &pat);
let mut path_vec: Vec<PathBuf> = Vec::new();
trace!("Globbing {}", &pat);
let entries = glob(pat)?;
for entry in entries.filter_map(Result::ok) {
trace!("Adding '{}' to topic content vector", &entry.display());
path_vec.push(entry);
}
trace!("Reversing topic content vector for LIFO site rendering");
path_vec.reverse();
Ok(path_vec)
}
pub fn slugify(topic: &str) -> String {
let topic = topic
.to_ascii_lowercase()
.replace(char::is_whitespace, "-");
topic
debug!("Creating slugified topic string from {}", &topic);
topic.to_ascii_lowercase().replace(char::is_whitespace, "-")
}

View File

@ -1,54 +1,80 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use std::fs::create_dir_all;
use std::{io::BufRead, usize};
use std::path::Path;
use clap::{App, AppSettings, Arg, ArgMatches, SubCommand};
use clap::{App, AppSettings, Arg, ArgMatches, SubCommand, crate_authors, crate_description, crate_version};
use log::{debug, error, info, trace};
use simplelog::{SimpleLogger, ConfigBuilder};
use serde::{Serialize, Deserialize};
use toml;
use super::auth;
use super::common;
fn args() -> App<'static, 'static> {
App::new("Caty's Blog")
.version("1.0")
.author("Anthony Martinez")
App::new("A Rust Site Engine")
.version(crate_version!())
.author(crate_authors!())
.about(crate_description!())
.setting(AppSettings::ArgRequiredElseHelp)
.arg(Arg::with_name("verbosity")
.short("v")
.multiple(true)
.help("Sets the log level. Default: INFO. -v = DEBUG, -vv = TRACE"))
.subcommand(SubCommand::with_name("run")
.about("Run the blog server")
.about("Run the site server")
.arg(Arg::with_name("config")
.help("Provides the path to the server configuration file.")
.required(true)
.takes_value(true)
.index(1)))
.subcommand(SubCommand::with_name("new")
.about("Generates a base directory structure and configuration file for a new blog")
.about("Generates a base directory structure and configuration file for a new site")
)
}
/// TODO Document this public function
/// And Include an Example of its Use
pub fn load() -> Result<AppConfig, Box<dyn std::error::Error>> {
let matches = args().get_matches();
let config: Result<AppConfig, Box<dyn std::error::Error>>;
let matches = args().get_matches();
// Create a Config with ISO timestamps
let log_config = ConfigBuilder::new()
.set_time_format_str("%+")
.build();
// After this block locking is configured at the specified level
match matches.occurrences_of("verbosity") {
0 => SimpleLogger::init(log::LevelFilter::Info, log_config)?,
1 => SimpleLogger::init(log::LevelFilter::Debug, log_config)?,
_ => SimpleLogger::init(log::LevelFilter::Trace, log_config)?,
}
info!("Logging started");
debug!("Processing subcommands");
if matches.is_present("run") {
trace!("Application called with `run` subcommand - loading config from disk");
config = runner_config(matches);
} else if matches.is_present("new") {
trace!("Application called with `new` subcommand - creating config from user input");
let reader = std::io::stdin();
let mut reader = reader.lock();
let current_path = std::env::current_dir()?;
config = AppConfig::generate(current_path, &mut reader);
} else {
let msg = format!("Unable to load configuration");
let msg = "Unable to load configuration".to_owned();
error!("{}", &msg);
config = Err(From::from(msg));
}
@ -58,10 +84,11 @@ pub fn load() -> Result<AppConfig, Box<dyn std::error::Error>> {
fn runner_config(m: ArgMatches) -> Result<AppConfig, Box<dyn std::error::Error>> {
if let Some(run) = m.subcommand_matches("run") {
let value = run.value_of("config").unwrap();
let config = AppConfig::new(value)?;
let config = AppConfig::from_path(value)?;
Ok(config)
} else {
let msg = format!("Failed to read arguments for 'run' subcommand");
let msg = "Failed to read arguments for 'run' subcommand".to_owned();
error!("{}", &msg);
Err(From::from(msg))
}
}
@ -77,7 +104,8 @@ fn get_input<R: BufRead>(prompt: &str, reader: &mut R) -> Result<String, Box<dyn
}
fn csv_to_vec(csv: &str) -> Vec<String> {
let val_vec: Vec<String> = csv.split(",")
debug!("Creating Vec<String> from csv topics: {}", &csv);
let val_vec: Vec<String> = csv.split(',')
.map(|s| s
.trim_start_matches(char::is_whitespace)
.trim_end_matches(char::is_whitespace)
@ -89,21 +117,22 @@ fn csv_to_vec(csv: &str) -> Vec<String> {
/// TODO Document
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Blog {
pub struct Site {
pub name: String,
pub author: String,
pub topics: Vec<String>,
}
impl Blog {
pub fn new_from_input<R: BufRead>(reader: &mut R) -> Result<Blog, Box<dyn std::error::Error>> {
let name = get_input("Please enter a name for the blog: ", reader)?;
let author = get_input("Please enter the blog author's name: ", reader)?;
let topics = get_input("Please enter comma-separated blog topics: ", reader)?;
impl Site {
pub fn new_from_input<R: BufRead>(reader: &mut R) -> Result<Site, Box<dyn std::error::Error>> {
let name = get_input("Please enter a name for the site: ", reader)?;
let author = get_input("Please enter the site author's name: ", reader)?;
let topics = get_input("Please enter comma-separated site topics: ", reader)?;
let topics = csv_to_vec(&topics);
let blog = Blog { name, author, topics };
let site = Site { name, author, topics };
Ok(blog)
trace!("Site: {:?}", site);
Ok(site)
}
}
@ -117,9 +146,10 @@ pub struct Credentials {
impl Credentials {
pub fn new_from_input<P: AsRef<Path>, R: BufRead>(dir: P, reader: &mut R) -> Result<Credentials, Box<dyn std::error::Error>> {
let user = get_input("Please enter an username for the blog admin: ", reader)?;
let user = get_input("Please enter an username for the site admin: ", reader)?;
const PASSWORD_LEN: usize = 32;
let password = auth::generate_secret(PASSWORD_LEN)?;
info!("Site admin password generated");
let password_file = dir.as_ref().join("admin.pass");
common::str_to_ro_file(&password, password_file)?;
let password = auth::generate_argon2_phc(&password)?;
@ -128,6 +158,7 @@ impl Credentials {
let token = auth::generate_secret(TOKEN_LEN)?;
let token = token.as_bytes();
let token = auth::BASE32_NOPAD.encode(token);
info!("Site admin TOTP token generated");
let token_file = dir.as_ref().join("admin.totp");
common::str_to_ro_file(&token, token_file)?;
@ -146,49 +177,45 @@ pub struct DocPaths {
impl DocPaths {
pub fn new<P: AsRef<Path>>(dir: P) -> Result<DocPaths, Box<dyn std::error::Error>> {
debug!("Creating site DocPaths");
let dir = dir.as_ref().display();
let templates = format!("{}/blog/templates", dir);
let webroot = format!("{}/blog/webroot", dir);
let templates = format!("{}/site/templates", dir);
let webroot = format!("{}/site/webroot", dir);
let docpaths = DocPaths { templates, webroot };
trace!("Site DocPaths: {:?}", docpaths);
Ok(docpaths)
}
}
/// TODO Document
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct LogConfig {
level: String
}
/// TODO Document
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct AppConfig {
pub blog: Blog,
pub site: Site,
pub creds: Credentials,
pub logging: LogConfig,
pub docpaths: DocPaths,
}
impl AppConfig {
pub fn new<T>(config: T) -> Result<AppConfig, Box<dyn std::error::Error>>
where T: AsRef<Path> {
pub fn from_path<T: AsRef<Path>>(config: T) -> Result<AppConfig, Box<dyn std::error::Error>> {
debug!("Loading site configuration from {}", &config.as_ref().display());
let config = std::fs::read_to_string(config)?;
trace!("Parsing configuration TOML");
let app_config: AppConfig = toml::from_str(&config)?;
Ok(app_config)
}
pub fn generate<P: AsRef<Path>, R: BufRead>(dir: P, reader: &mut R) -> Result<AppConfig, Box<dyn std::error::Error>> {
debug!("Generating new site configuration");
let docpaths = DocPaths::new(&dir)?;
let blog = Blog::new_from_input(reader)?;
let site = Site::new_from_input(reader)?;
let creds = Credentials::new_from_input(&dir, reader)?;
let level = format!("INFO");
let logging = LogConfig { level };
let config = AppConfig {
blog,
site,
creds,
logging,
docpaths,
};
@ -199,12 +226,13 @@ impl AppConfig {
}
fn create_paths(&self) -> Result<(), Box<dyn std::error::Error>> {
debug!("Creating site filesystem tree");
create_dir_all(&self.docpaths.templates)?;
create_dir_all(format!("{}/static/ext", &self.docpaths.webroot))?;
create_dir_all(format!("{}/main/ext", &self.docpaths.webroot))?;
create_dir_all(format!("{}/main/posts", &self.docpaths.webroot))?;
for topic in &self.blog.topics {
for topic in &self.site.topics {
let topic = common::slugify(&topic);
create_dir_all(format!("{}/{}/ext", &self.docpaths.webroot, &topic))?;
@ -214,6 +242,7 @@ impl AppConfig {
}
fn write<P: AsRef<Path>>(&self, dir: P) -> Result<(), Box<dyn std::error::Error>> {
debug!("Writing site configuration to disk");
let config = toml::to_string_pretty(&self)?;
let conf_path = &dir.as_ref().join("config.toml");
common::str_to_ro_file(&config, &conf_path)?;
@ -224,11 +253,10 @@ impl AppConfig {
#[cfg(test)]
mod tests {
use super::*;
use tempfile;
#[test]
fn build_run_config() {
let arg_vec = vec!["caty-blog", "run", "./test_files/test-config.toml"];
let arg_vec = vec!["arse", "run", "./test_files/test-config.toml"];
let matches = args().get_matches_from(arg_vec);
let config = runner_config(matches);
assert!(config.is_ok());
@ -238,7 +266,7 @@ mod tests {
fn build_config_from_input() {
let dir = tempfile::tempdir().unwrap();
// Setup all target fields
let mut src: &[u8] = b"Blog Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let config = AppConfig::generate(&dir, &mut src);
assert!(config.is_ok());
@ -246,21 +274,21 @@ mod tests {
let config_path = &tmp_dir.join("config.toml");
let admin = &tmp_dir.join("admin.pass");
let token = &tmp_dir.join("admin.totp");
let blog = &tmp_dir.join("blog");
let templates = &tmp_dir.join("blog/templates");
let webroot = &tmp_dir.join("blog/webroot");
let static_ext = &tmp_dir.join("blog/webroot/static/ext");
let main_ext = &tmp_dir.join("blog/webroot/main/ext");
let main_posts = &tmp_dir.join("blog/webroot/main/posts");
let one_ext = &tmp_dir.join("blog/webroot/one/ext");
let one_posts = &tmp_dir.join("blog/webroot/one/posts");
let two_ext = &tmp_dir.join("blog/webroot/two/ext");
let two_posts = &tmp_dir.join("blog/webroot/two/posts");
let three_ext = &tmp_dir.join("blog/webroot/three/ext");
let three_posts = &tmp_dir.join("blog/webroot/three/posts");
let and_more_ext = &tmp_dir.join("blog/webroot/and-more/ext");
let and_more_posts = &tmp_dir.join("blog/webroot/and-more/posts");
let core = vec![config_path, admin, token, blog, templates,
let site = &tmp_dir.join("site");
let templates = &tmp_dir.join("site/templates");
let webroot = &tmp_dir.join("site/webroot");
let static_ext = &tmp_dir.join("site/webroot/static/ext");
let main_ext = &tmp_dir.join("site/webroot/main/ext");
let main_posts = &tmp_dir.join("site/webroot/main/posts");
let one_ext = &tmp_dir.join("site/webroot/one/ext");
let one_posts = &tmp_dir.join("site/webroot/one/posts");
let two_ext = &tmp_dir.join("site/webroot/two/ext");
let two_posts = &tmp_dir.join("site/webroot/two/posts");
let three_ext = &tmp_dir.join("site/webroot/three/ext");
let three_posts = &tmp_dir.join("site/webroot/three/posts");
let and_more_ext = &tmp_dir.join("site/webroot/and-more/ext");
let and_more_posts = &tmp_dir.join("site/webroot/and-more/posts");
let core = vec![config_path, admin, token, site, templates,
webroot, static_ext, main_ext, main_posts,
one_ext, one_posts, two_ext, two_posts,
three_ext, three_posts, and_more_ext, and_more_posts];
@ -271,8 +299,11 @@ mod tests {
#[test]
fn handle_csv_topics() {
let reference_topics: Vec<String> = vec![format!("One"), format!("Two"), format!("Three"), format!("And More")];
let topics = format!("One, Two, Three, And More");
let reference_topics: Vec<String> = vec!["One".to_owned(),
"Two".to_owned(),
"Three".to_owned(),
"And More".to_owned()];
let topics = "One, Two, Three, And More".to_owned();
assert_eq!(reference_topics, csv_to_vec(&topics))
}

View File

@ -1,10 +1,12 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
pub mod auth;
pub mod config;

View File

@ -1,9 +0,0 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
// Placeholder for logging setup

View File

@ -1,10 +1,12 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -12,11 +14,13 @@ use std::sync::Arc;
use super::config::AppConfig;
use super::common;
use log::{debug, trace};
use pulldown_cmark::{Parser, html};
use tera::{Tera, Context};
mod default;
#[derive(Debug)]
pub struct Engine {
pub app: Arc<AppConfig>,
pub topic_slug: String,
@ -26,8 +30,9 @@ pub struct Engine {
impl Engine {
pub fn new(a: Arc<AppConfig>, ts: &str, tmpl: &str, inst: Tera) -> Engine {
trace!("Loading rendering engine");
Engine {
app: a.clone(),
app: a,
topic_slug: ts.to_owned(),
template: tmpl.to_owned(),
instance: inst
@ -35,24 +40,28 @@ impl Engine {
}
}
pub fn load_default_template() -> Result<Tera, Box<dyn std::error::Error>> {
pub(crate) fn load_default_template() -> Result<Tera, Box<dyn std::error::Error>> {
trace!("Loading default rendering template");
let mut tera = Tera::default();
tera.add_raw_template("default.tmpl", default::TEMPLATE)?;
Ok(tera)
}
pub fn render_topic(engine: Engine) -> Result<String, Box<dyn std::error::Error>> {
let blog = &engine.app.blog;
pub(crate) fn render_topic(engine: Engine) -> Result<String, Box<dyn std::error::Error>> {
debug!("Rendering topic: '{}'", &engine.topic_slug);
let site = &engine.app.site;
let topic_data = load_topic(&engine)?;
let mut context = Context::new();
context.insert("blog", blog);
context.insert("site", site);
context.insert("posts", &topic_data);
let output = engine.instance.render(&engine.template, &context)?;
trace!("Rendered content for topic: {}\n{}", &engine.topic_slug, output);
Ok(output)
}
fn load_topic(engine: &Engine) -> Result<Vec<String>, Box<dyn std::error::Error>> {
trace!("Loading topic content for '{}'", &engine.topic_slug);
let topic_path = Path::new(&engine.app.docpaths.webroot).join(&engine.topic_slug).join("posts");
let pat = format!("{}/*.md", topic_path.display());
let paths = common::path_matches(&pat)?;
@ -60,8 +69,10 @@ fn load_topic(engine: &Engine) -> Result<Vec<String>, Box<dyn std::error::Error>
}
fn read_to_html(paths: Vec<PathBuf>) -> Result<Vec<String>, Box<dyn std::error::Error>> {
debug!("Rendering Markdown to HTML");
let mut contents: Vec<String> = Vec::new();
for path in paths {
trace!("Rendering {} to HTML", &path.display());
let buf = std::fs::read_to_string(path)?;
let parser = Parser::new(&buf);
let mut html_output = String::new();
@ -77,7 +88,6 @@ mod tests {
use super::*;
use std::fs::File;
use std::io::prelude::*;
use tempfile;
#[test]
fn check_default_template() {
@ -88,7 +98,7 @@ mod tests {
#[test]
fn check_render_topic() {
let dir = tempfile::tempdir().unwrap();
let mut src: &[u8] = b"Blog Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let instance = load_default_template().unwrap();
let config = AppConfig::generate(&dir, &mut src).unwrap();
let config = Arc::new(config);
@ -105,10 +115,10 @@ Very cool, but maybe not super useful
Super Wow!
"#;
let mut f = File::create(&dir.path().join("blog/webroot/one/posts/post1.md")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/one/posts/post1.md")).unwrap();
f.write_all(&post.as_bytes()).unwrap();
let mut f = File::create(&dir.path().join("blog/webroot/one/posts/post2.md")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/one/posts/post2.md")).unwrap();
f.write_all(&post2.as_bytes()).unwrap();
let page = render_topic(engine).unwrap();

View File

@ -1,27 +1,29 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
pub const TEMPLATE: &str = r#"
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
pub(crate) const TEMPLATE: &str = r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<title>{{ blog.name }}</title>
<title>{{ site.name }}</title>
</head>
<body>
<header>
<center>
<h1>{{ blog.name }}</h1>
<h1>{{ site.name }}</h1>
<nav>
<a href="./">Home</a>
{%- for topic in blog.topics %}
{%- for topic in site.topics %}
<a href="./{{ topic | slugify }}">{{ topic }}</a>
{%- endfor -%}
</nav>
@ -37,7 +39,7 @@ pub const TEMPLATE: &str = r#"
{% endif %}
</main>
<footer>
<p>&#169; {{ blog.author }}</p>
<p>&#169; {{ site.author }}</p>
</footer>
</body>
</html>

View File

@ -1,10 +1,12 @@
// A Rust Site Engine
// Copyright 2020-2021 Anthony Martinez
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.
/*
A Rust Site Engine
Copyright 2020-2021 Anthony Martinez
Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. This file may not be
copied, modified, or distributed except according to those terms.
*/
use std::convert::Infallible;
use std::fs::File;
@ -12,7 +14,8 @@ use std::io::prelude::*;
use std::path::Path;
use std::sync::Arc;
use hyper::{Body, Request, Response};//, StatusCode};
use hyper::{Body, Request, Response};
use log::{debug, info};
use routerify::{prelude::*, Router};
use super::config::AppConfig;
@ -20,8 +23,9 @@ use super::render;
pub fn router(app: Arc<AppConfig>) -> Router<Body, Infallible> {
debug!("Building site router");
Router::builder()
.data(app.clone())
.data(app)
.get("/", index_handler)
.get("/:topic", topic_handler)
.get("/:topic/ext/:fname", topic_assets)
@ -32,6 +36,7 @@ pub fn router(app: Arc<AppConfig>) -> Router<Body, Infallible> {
/// Handler for "/"
async fn index_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
info!("Handling request to '/'");
let app = req.data::<Arc<AppConfig>>().unwrap();
topic_posts(app.clone(), "main".to_owned()).await
}
@ -40,13 +45,14 @@ async fn index_handler(req: Request<Body>) -> Result<Response<Body>, Infallible>
async fn topic_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let app = req.data::<Arc<AppConfig>>().unwrap();
let topic = req.param("topic").unwrap();
info!("Handling request to '/{}'", &topic);
topic_posts(app.clone(), topic.to_owned()).await
}
/// Called by topic_handler to dynamically generate topic pages
async fn topic_posts(app: Arc<AppConfig>, topic: String) -> Result<Response<Body>, Infallible> {
let instance = render::load_default_template().unwrap();
let engine = render::Engine::new(app.clone(), &topic, "default.tmpl", instance);
let engine = render::Engine::new(app, &topic, "default.tmpl", instance);
let output = render::render_topic(engine).unwrap();
Ok(Response::new(Body::from(output)))
}
@ -55,6 +61,7 @@ async fn topic_posts(app: Arc<AppConfig>, topic: String) -> Result<Response<Body
async fn static_assets(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let app = req.data::<Arc<AppConfig>>().unwrap();
let resource = req.param("fname").unwrap();
info!("Handling static asset: '/static/{}'", &resource);
let static_path = Path::new(&app.docpaths.webroot).join("static").join(resource);
let mut f = File::open(static_path).unwrap();
let mut buf = Vec::new();
@ -67,6 +74,7 @@ async fn topic_assets(req: Request<Body>) -> Result<Response<Body>, Infallible>
let app = req.data::<Arc<AppConfig>>().unwrap();
let topic = req.param("topic").unwrap();
let resource = req.param("fname").unwrap();
info!("Handling static asset: '/{}/ext/{}'", &topic, &resource);
let topic_asset_path = Path::new(&app.docpaths.webroot).join(topic).join("ext").join(resource);
let mut f = File::open(topic_asset_path).unwrap();
let mut buf = Vec::new();
@ -85,14 +93,13 @@ mod tests {
use crate::config::AppConfig;
use hyper::{Body, Request, StatusCode, Server, Client};
use routerify::RouterService;
use tempfile;
use tokio::sync::oneshot::channel;
#[tokio::test]
async fn check_all_handlers() {
let dir = tempfile::tempdir().unwrap();
let mut src: &[u8] = b"Blog Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n";
let app = AppConfig::generate(&dir, &mut src).unwrap();
let app = Arc::new(app);
@ -101,7 +108,7 @@ mod tests {
Main Important Test
"#;
let mut f = File::create(&dir.path().join("blog/webroot/main/posts/index.md")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/main/posts/index.md")).unwrap();
f.write_all(&index_page.as_bytes()).unwrap();
let topic_page = r#"
@ -109,17 +116,17 @@ Main Important Test
One Important Test
"#;
let mut f = File::create(&dir.path().join("blog/webroot/one/posts/index.md")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/one/posts/index.md")).unwrap();
f.write_all(&topic_page.as_bytes()).unwrap();
let topic_asset = b"One Static File\n";
let mut f = File::create(&dir.path().join("blog/webroot/one/ext/one-static")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/one/ext/one-static")).unwrap();
f.write_all(topic_asset).unwrap();
let static_asset = b"Static File\n";
let mut f = File::create(&dir.path().join("blog/webroot/static/main-static")).unwrap();
let mut f = File::create(&dir.path().join("site/webroot/static/main-static")).unwrap();
f.write_all(static_asset).unwrap();
let router = router(app.clone());

View File

@ -1,4 +1,4 @@
[blog]
[site]
name = "My Awesome Blog!"
author = "Neo"
topics = ["one", "two", "three"]
@ -8,9 +8,6 @@ user = "admin"
password = "123456"
token = "my very secret token"
[logging]
level = "INFO"
[docpaths]
templates = "/var/www/blog/templates"
webroot = "/var/www/blog/webroot"
templates = "/var/www/site/templates"
webroot = "/var/www/site/webroot"