initial commit
This commit is contained in:
commit
a2eb0ceb4e
5 changed files with 2610 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
2287
Cargo.lock
generated
Normal file
2287
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal file
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "gardenserver"
|
||||
version = "0.1.2"
|
||||
authors = ["Tomáš Mládek <tmladek@protonmail.ch>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
clap = "2.33.0"
|
||||
|
||||
anyhow = "1.0"
|
||||
env_logger = "0.7.1"
|
||||
log = "0.4"
|
||||
|
||||
actix = "0.9.0"
|
||||
actix-files = "0.2.2"
|
||||
actix-rt = "1.0.0"
|
||||
actix-web = "2.0"
|
||||
actix_derive = "0.3.2"
|
||||
|
||||
pulldown-cmark = "0.8.0"
|
||||
|
||||
tera = "1"
|
||||
|
||||
chrono = "0.4"
|
||||
regex = "1"
|
||||
percent-encoding = "2.1.0"
|
210
src/main.rs
Normal file
210
src/main.rs
Normal file
|
@ -0,0 +1,210 @@
|
|||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{env, fs};
|
||||
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::error::ErrorInternalServerError;
|
||||
use actix_web::{error, get, http, middleware, web, App, Error, HttpResponse, HttpServer};
|
||||
use chrono::{DateTime, Local};
|
||||
use clap::{App as ClapApp, Arg};
|
||||
use log::info;
|
||||
use percent_encoding::utf8_percent_encode;
|
||||
use pulldown_cmark::{html, Options, Parser};
|
||||
use regex::{Captures, Regex};
|
||||
use tera::{Context, Tera};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct State {
|
||||
garden_dir: PathBuf,
|
||||
index_file: Option<String>,
|
||||
title: Option<String>,
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let env = env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info");
|
||||
env_logger::init_from_env(env);
|
||||
|
||||
let app = ClapApp::new("gardenserver")
|
||||
.version(VERSION)
|
||||
.author("Tomáš Mládek <t@mldk.cz>")
|
||||
.arg(Arg::with_name("DIRECTORY").required(true).index(1))
|
||||
.arg(
|
||||
Arg::with_name("BIND")
|
||||
.long("bind")
|
||||
.default_value("127.0.0.1:8642")
|
||||
.help("address and port to bind the Web interface on")
|
||||
.required(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("INDEX_FILE")
|
||||
.takes_value(true)
|
||||
.short("i")
|
||||
.long("index")
|
||||
.help("File to be served at the root."),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("TITLE")
|
||||
.takes_value(true)
|
||||
.short("t")
|
||||
.long("title")
|
||||
.help("Title of this digital garden."),
|
||||
);
|
||||
|
||||
let matches = app.get_matches();
|
||||
|
||||
let directory = Path::new(matches.value_of("DIRECTORY").unwrap());
|
||||
|
||||
info!(
|
||||
"Starting GardenServer {} of {}...",
|
||||
VERSION,
|
||||
directory.display()
|
||||
);
|
||||
|
||||
let tera = Tera::new("templates/**/*.html")?;
|
||||
|
||||
let sys = actix::System::new("gardenserver");
|
||||
|
||||
let bind: SocketAddr = matches
|
||||
.value_of("BIND")
|
||||
.unwrap()
|
||||
.parse()
|
||||
.expect("Incorrect bind format.");
|
||||
info!("Starting server at: {}", &bind);
|
||||
|
||||
let state = State {
|
||||
garden_dir: directory.to_path_buf(),
|
||||
index_file: matches.value_of("INDEX_FILE").map(|s| s.to_string()),
|
||||
title: matches.value_of("TITLE").map(|s| s.to_string()),
|
||||
tera,
|
||||
};
|
||||
|
||||
// Start HTTP server
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(middleware::Logger::default())
|
||||
.data(state.clone())
|
||||
.service(render)
|
||||
})
|
||||
.bind(&bind)?
|
||||
.run();
|
||||
|
||||
Ok(sys.run()?)
|
||||
}
|
||||
|
||||
#[get("{path:.*}")]
|
||||
async fn render(
|
||||
request: web::HttpRequest,
|
||||
state: web::Data<State>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(&state.garden_dir)?
|
||||
.filter_map(|entry| {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
return Some(path.strip_prefix(&state.garden_dir).unwrap().to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(error::ErrorNotFound("Garden is empty."));
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
let location = match state.index_file.as_ref() {
|
||||
Some(index_file) => index_file.clone(),
|
||||
None => files.get(0).unwrap().display().to_string(),
|
||||
};
|
||||
|
||||
return Ok(HttpResponse::Found()
|
||||
.header(http::header::LOCATION, location.as_str())
|
||||
.finish());
|
||||
}
|
||||
|
||||
let full_path = state.garden_dir.join(path.as_str());
|
||||
match (full_path.exists(), full_path.extension()) {
|
||||
(false, None) => {
|
||||
return Ok(HttpResponse::Found()
|
||||
.header(http::header::LOCATION, format!("{}.md", path.to_string()))
|
||||
.finish());
|
||||
}
|
||||
(false, Some(_)) => return Err(error::ErrorNotFound("File not found.")),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !path.ends_with(".md") {
|
||||
Ok(NamedFile::open(full_path)?.into_response(&request)?)
|
||||
} else {
|
||||
let mut file = File::open(full_path.clone())?;
|
||||
let mut file_string = String::new();
|
||||
file.read_to_string(&mut file_string)?;
|
||||
let markdown_source = preprocess(file_string);
|
||||
let parser = Parser::new_ext(markdown_source.as_str(), Options::all());
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
// TODO!
|
||||
let h1_regex = Regex::new(r"<h1>([^>]+)</h1>").unwrap();
|
||||
let page_title = match h1_regex.captures(&html_output) {
|
||||
Some(h1_match) => h1_match.get(1).unwrap().as_str(),
|
||||
_ => full_path
|
||||
.components()
|
||||
.last()
|
||||
.unwrap()
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.unwrap_or("???"),
|
||||
};
|
||||
|
||||
let mtime: Option<DateTime<Local>> = match file.metadata() {
|
||||
Ok(metadata) => metadata.modified().ok().map(|mtime| mtime.into()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut context = Context::new();
|
||||
context.insert(
|
||||
"garden_title",
|
||||
state
|
||||
.title
|
||||
.as_ref()
|
||||
.unwrap_or(&"Digital Garden".to_string()),
|
||||
);
|
||||
context.insert("page_title", page_title);
|
||||
context.insert("files", &files);
|
||||
context.insert("content", &html_output);
|
||||
context.insert(
|
||||
"mtime",
|
||||
&mtime.map_or("???".to_string(), |t| t.format("%c").to_string()),
|
||||
);
|
||||
|
||||
Ok(HttpResponse::Ok().body(
|
||||
state
|
||||
.tera
|
||||
.render("main.html", &context)
|
||||
.map_err(ErrorInternalServerError)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn preprocess(string: String) -> String {
|
||||
let double_brackets = Regex::new(r"\[\[(?P<inner>[\w .]+)\]\]").unwrap();
|
||||
|
||||
double_brackets
|
||||
.replace_all(string.as_str(), |caps: &Captures| {
|
||||
format!(
|
||||
"[{}]({})",
|
||||
&caps[1],
|
||||
utf8_percent_encode(&caps[1], percent_encoding::NON_ALPHANUMERIC)
|
||||
)
|
||||
})
|
||||
.to_string()
|
||||
}
|
84
templates/main.html
Normal file
84
templates/main.html
Normal file
|
@ -0,0 +1,84 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{garden_title}} - {{page_title}}</title>
|
||||
<link href="https://necolas.github.io/normalize.css/8.0.1/normalize.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: 'Merriweather', serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding-left: 2em;
|
||||
border-left: 3px solid lightgray;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
nav, main {
|
||||
padding: 0 2em;
|
||||
}
|
||||
|
||||
nav {
|
||||
min-width: 15%;
|
||||
width: 15%;
|
||||
min-height: 100vh;
|
||||
border-right: 1px solid gray;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
nav li {
|
||||
margin: .5em 0;
|
||||
}
|
||||
|
||||
nav .file {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
main footer {
|
||||
padding: 1em 0;
|
||||
text-align: right;
|
||||
color: gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<h2>{{garden_title}}</h2>
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li class="{% if file is containing(".md") %}page{% else %}file{% endif %}"><a href="/{{file}}">{{file}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
<main>
|
||||
{{content | safe}}
|
||||
|
||||
<footer>
|
||||
Last modified at {{mtime}}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue