From 63636d00ca56ee37f9cb9db3fece81d615e21a1a Mon Sep 17 00:00:00 2001 From: Yves Fischer Date: Mon, 26 Nov 2018 12:33:37 +0100 Subject: Refactor html views --- src/auth_handler/handler_info.rs | 30 ------- src/auth_handler/handler_login.rs | 142 ----------------------------- src/auth_handler/mod.rs | 170 ----------------------------------- src/auth_handler/urls.rs | 18 ---- src/cookie_store.rs | 2 +- src/main.rs | 15 +++- src/request_handler/handler_login.rs | 71 +++++++++++++++ src/request_handler/mod.rs | 170 +++++++++++++++++++++++++++++++++++ src/request_handler/views.rs | 138 ++++++++++++++++++++++++++++ 9 files changed, 391 insertions(+), 365 deletions(-) delete mode 100644 src/auth_handler/handler_info.rs delete mode 100644 src/auth_handler/handler_login.rs delete mode 100644 src/auth_handler/mod.rs delete mode 100644 src/auth_handler/urls.rs create mode 100644 src/request_handler/handler_login.rs create mode 100644 src/request_handler/mod.rs create mode 100644 src/request_handler/views.rs (limited to 'src') diff --git a/src/auth_handler/handler_info.rs b/src/auth_handler/handler_info.rs deleted file mode 100644 index 0aeaa09..0000000 --- a/src/auth_handler/handler_info.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::io; - -use tokio::prelude::*; - -use http::{Request, Response, StatusCode}; -use bytes::Bytes; - -use ::ApplicationState; -use super::AuthHandler; - -pub(in super) fn respond<'a>(auth_handler: &AuthHandler, state: &ApplicationState, req: &Request, - path_rest: &'a str) -> Response { - let body = html! { - : horrorshow::helper::doctype::HTML; - html { - head { - title: "Hello world!"; - } - body { - h1(id = "heading") { - : "Hello! This is "; - : "And path rest is: "; - : path_rest; - : "... ok :)"; - } - } - } - }; - Response::builder().body(body.to_string()).unwrap() -} \ No newline at end of file diff --git a/src/auth_handler/handler_login.rs b/src/auth_handler/handler_login.rs deleted file mode 100644 index 83d5214..0000000 --- a/src/auth_handler/handler_login.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::io; -use std::borrow::Cow; - -use tokio::prelude::*; - -use http::{Request, Response, StatusCode, Method}; -use http::header::{SET_COOKIE, COOKIE}; -use url::form_urlencoded; - -use ::ApplicationState; -use ::totp; -use super::*; - - -pub(in super) fn GET<'a>(header_infos: &HeaderExtract, state: &ApplicationState, path_rest: &'a str) - -> Response { - let body = if is_logged_in(&header_infos.cookies, &state.cookie_store) { - format!("{}", html! { - : horrorshow::helper::doctype::HTML; - html { - head { - title: "TOTP Login"; - } - body { - h1(id = "heading") { - : "Currently logged in" - } - } - } - }) - } else { - format!("{}", html! { - : horrorshow::helper::doctype::HTML; - html { - head { - title: "TOTP Login"; - } - body { - h1(id = "heading") { - : "Login" - } - form(method="POST") { - label(for="token") { - : "Enter TOTP token" - } - input(name="token",id="token",type="text"); - input(name="redirect", type="hidden", value=path_rest); - input(name="send",type="submit",value="Submit"); - } - } - } - }) - }; - make_response(StatusCode::OK, format!("{}", body)) -} - -fn test_secrets(secrets: &Vec<&str>, token: &String) -> bool { - secrets.iter() - .any(|secret| { - match totp::verify(secret, token) { - Ok(true) => true, - Ok(false) => false, - Err(e) => { - error!("Error from totp::verify: {}", e); - false - } - } - }) -} - -pub(in super) fn POST<'a>(header_infos: &HeaderExtract, state: &ApplicationState, req: &Request) - -> Response { - let mut token = None; - let mut redirect = None; - for (key, val) in form_urlencoded::parse(req.body()) { - if key == "token" { - token = Some(val.into_owned()) - } else if key == "redirect" { - redirect = Some(val.into_owned()) - } - } - if token.is_none() { - return error_handler_internal("missing argument 'token'".to_string()); - } - let redirect = redirect.unwrap_or(Default::default()); - - if header_infos.totp_secrets.is_empty() { - return error_handler_internal("no secrets configured".to_string()) - } - - let mut ret = Response::builder(); - let body = if test_secrets(&header_infos.totp_secrets, &token.unwrap()) { - let cookie_value = state.cookie_store.create_authenticated_cookie(); - let cookie = CookieBuilder::new(COOKIE_NAME, cookie_value.to_string()) - .http_only(true) - .path("/") - .max_age(state.cookie_max_age) - .finish(); - ret.header(SET_COOKIE, cookie.to_string()); - warn!("Authenticated user with cookie {}", cookie); - format!("{}", html! { - : horrorshow::helper::doctype::HTML; - html { - head { - title: "TOTP Successful"; - meta(http-equiv="refresh", content=format!("3; URL={}", redirect)) - } - body { - h1(id = "heading") { - : "Login succesful" - } - a(href="login") { - : "Try again... redirecting to "; - } - span { - : format!("{}", redirect) - } - } - } - }) - } else { - format!("{}", html! { - : horrorshow::helper::doctype::HTML; - html { - head { - title: "TOTP Login failed"; - meta(http-equiv="refresh", content="1") - } - body { - h1(id = "heading") { - : "Login failed" - } - a(href="login") { - : "Try again... " - } - } - } - }) - }; - - ret.body(body).unwrap() -} \ No newline at end of file diff --git a/src/auth_handler/mod.rs b/src/auth_handler/mod.rs deleted file mode 100644 index b4e02d1..0000000 --- a/src/auth_handler/mod.rs +++ /dev/null @@ -1,170 +0,0 @@ -#![allow(warnings)] - -use std::cell::Cell; -use std::collections::HashMap; -use std::io; -use std::marker::Sync; -use std::str; -use std::str::FromStr; -use std::sync::{Arc, RwLock, Mutex, MutexGuard}; -use std::cell::RefCell; - -use time::{Tm, Duration}; -use http::{Request, Response, StatusCode, Method}; -use tokio::prelude::*; -use horrorshow; -use cookie::{Cookie,CookieBuilder}; -use bytes::Bytes; - -use router; -use cookie_store::CookieStore; -use cookie_store::to_cookie; -use http_server::HttpHandler; - -mod urls; -mod handler_login; -mod handler_info; - -pub(in auth_handler) struct HeaderExtract<'a> { - totp_secrets: Vec<&'a str>, - cookies: Vec>, -} - -pub struct HeaderMissing { - name: &'static str, -} - -static HTTP_HEADER_AUTHORIZATION: &'static str = r"Authorization"; -static HTTP_HEADER_X_ORIGINAL_URL: &'static str = r"X-Original-Url"; -static HTTP_HEADER_WWW_AUTHENTICATE: &'static str = r"WWW-Authenticate"; -static HTTP_HEADER_X_TOTP_SECRET: &'static str = r"X-Totp-Secret"; -static COOKIE_NAME: &'static str = r"totp_cookie"; - -#[derive(Clone)] -pub struct AuthHandler { - routing_table: router::RoutingTable, -} - -pub(in auth_handler) fn make_response(code: StatusCode, body: String) -> Response { - Response::builder().status(code).body(body).unwrap() -} - -pub(in auth_handler) fn error_handler_internal(body: String) -> Response { - Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(body).unwrap() -} - -impl HttpHandler for AuthHandler { - fn respond(&self, state: &super::ApplicationState, req: Request) -> Response { - match self.routing_table.match_path(req.uri().path()) { - Ok((urls::Route::Info, rest)) => handler_info::respond(self, state, &req, rest), - Ok((urls::Route::Login, rest)) => self.login(state, &req, rest), - Ok((urls::Route::Logout, rest)) => self.logout(state, &req, rest), - Ok((urls::Route::Check, rest)) => self.check(state, &req, rest), - Err(error) => match error { - router::NoMatchingRoute => - make_response(StatusCode::NOT_FOUND, "Resource not found".to_string()), - } - } - } -} - -pub fn is_logged_in(cookies: &Vec, cookie_store: &CookieStore) -> bool { - for cookie in cookies { - if cookie.name() == COOKIE_NAME { - let cookie_value = to_cookie(cookie.value()); - if cookie_value.is_some() && cookie_store.is_cookie_authenticated(&cookie_value.unwrap()) { - return true; - } - } - } - false -} - - -impl AuthHandler { - pub fn make() -> AuthHandler { - AuthHandler { routing_table: urls::create_routing_table() } - } - - fn login<'a>(&self, state: &super::ApplicationState, req: &Request, path_rest: &'a str, - ) -> Response { - let header_infos = match Self::parse_header_infos(req) { - Ok(infos) => infos, - Err(message) => return error_handler_internal(message), - }; - match *req.method() { - Method::GET => handler_login::GET(&header_infos, state, path_rest), - Method::POST => handler_login::POST(&header_infos, state, req), - _ => error_handler_internal("Wrong method".to_string()), - } - } - - fn logout<'a>(&self, state: &super::ApplicationState, req: &Request, path_rest: &'a str, - ) -> Response { - let header_infos = match Self::parse_header_infos(req) { - Ok(infos) => infos, - Err(message) => return error_handler_internal(message), - }; - - let body = format!("Rest: {}", path_rest); - Response::builder().body(body.to_string()).unwrap() - } - - - fn check<'a>(&self, state: &super::ApplicationState, req: &Request, path_rest: &'a str) -> Response { - let header_infos = match Self::parse_header_infos(req) { - Ok(infos) => infos, - Err(message) => return error_handler_internal(message), - }; - if is_logged_in(&header_infos.cookies, &state.cookie_store) { - make_response(StatusCode::OK, "".to_string()) - } else { - make_response(StatusCode::UNAUTHORIZED, "Cookie expired".to_string()) - } - } - - - fn parse_header_infos(req: &Request) -> Result { - let mut totp_secrets = Vec::new(); - for header_value in req.headers().get_all(HTTP_HEADER_X_TOTP_SECRET) { - let value = header_value.to_str().or(Err("Failed to read totp-secret header value"))?; - totp_secrets.push(value); - } - - let mut cookies = Vec::new(); - for header_value in req.headers().get_all(::http::header::COOKIE) { - let value = header_value.to_str().or(Err("Failed to read cookie value"))?; - let cookie = Cookie::parse(value).or(Err("Failed to parse cookie value"))?; - cookies.push(cookie); - } - - Ok(HeaderExtract { totp_secrets, cookies }) - } -} - -#[cfg(test)] -mod test1 { - // use super::*; - use test::Bencher; - // use horrorshow::prelude::*; - use horrorshow::helper::doctype; - - #[bench] - fn bench_1(_: &mut Bencher) { - let _ = format!("{}", html! { -: doctype::HTML; -html { -head { -title: "Hello world!"; -} -body { -// attributes -h1(id = "heading") { -// Insert escaped text -: "Hello! This is " -} -} -} -}); - } -} diff --git a/src/auth_handler/urls.rs b/src/auth_handler/urls.rs deleted file mode 100644 index fa7c76f..0000000 --- a/src/auth_handler/urls.rs +++ /dev/null @@ -1,18 +0,0 @@ -use ::router; - -#[derive(Clone, Copy)] -pub(in auth_handler) enum Route { - Login, - Logout, - Info, - Check -} - -pub(in auth_handler) fn create_routing_table() -> router::RoutingTable { - let mut r = router::RoutingTable::new(); - r.insert("/info", Route::Info); - r.insert("/login", Route::Login); - r.insert("/logout", Route::Logout); - r.insert("/check", Route::Check); - r -} \ No newline at end of file diff --git a/src/cookie_store.rs b/src/cookie_store.rs index a77bb14..66586b6 100644 --- a/src/cookie_store.rs +++ b/src/cookie_store.rs @@ -112,7 +112,7 @@ impl CookieStore { let reader = &self.reader; let value = reader.get_and(key, |v| v[0]); - warn!("Reading {} -> {:?}", key.to_string(), value); + debug!("Reading {} -> {:?}", key.to_string(), value); if value.is_none() { false } else if value.unwrap() < Self::now_unix_epoch() { diff --git a/src/main.rs b/src/main.rs index 2d5218f..a025e69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ use futures::{Future, Stream}; use tokio_threadpool::Builder; use tokio_executor::enter; -mod auth_handler; +mod request_handler; mod cookie_store; mod http_server; mod router; @@ -49,6 +49,7 @@ use cookie_store::CookieStore; pub struct ApplicationState { cookie_store: CookieStore, cookie_max_age: Duration, + debug: bool, } #[derive(Debug, StructOpt)] @@ -64,8 +65,14 @@ fn main() { let opt = Opt::from_args(); simple_logger::init_with_level(if opt.debug { Debug } else { Warn }) .unwrap_or_else(|_| panic!("Failed to initialize logger")); + debug!("If you read this message then we're running debug (-d) mode."); + debug!("Debug mode is not safe for public accesible instances"); - let state = ApplicationState { cookie_store: CookieStore::new(), cookie_max_age: Duration::days(1) }; + let state = ApplicationState { + cookie_store: CookieStore::new(), + cookie_max_age: Duration::days(1), + debug: opt.debug, + }; let server_shutdown_condvar = Arc::new(atomic::AtomicBool::new(false)); @@ -82,7 +89,7 @@ fn main() { }) }; - let auth_handler = auth_handler::AuthHandler::make(); + let request_handler = request_handler::RequestHandler::make(); let runtime = Builder::new() .name_prefix("httpd-") .after_start(|| { @@ -91,7 +98,7 @@ fn main() { }) .build(); - let program = http_server::serve(opt.addr, state, auth_handler); + let program = http_server::serve(opt.addr, state, request_handler); runtime.spawn(program); let ctrl_c_block = tokio_signal::ctrl_c() diff --git a/src/request_handler/handler_login.rs b/src/request_handler/handler_login.rs new file mode 100644 index 0000000..bfa7016 --- /dev/null +++ b/src/request_handler/handler_login.rs @@ -0,0 +1,71 @@ +use std::io; +use std::borrow::Cow; + +use tokio::prelude::*; + +use http::{Request, Response, StatusCode, Method}; +use http::header::{SET_COOKIE, COOKIE}; +use url::form_urlencoded; + +use ::ApplicationState; +use ::totp; +use super::*; + +pub(in super) fn GET<'a>(header_infos: &HeaderExtract, state: &ApplicationState, path_rest: &'a str) + -> Response { + if is_logged_in(&header_infos.cookies, &state.cookie_store) { + make_response(StatusCode::OK, views::login_is_logged_in()) + } else { + make_response(StatusCode::OK, views::login_login_form(path_rest)) + } +} + +fn test_secrets(secrets: &Vec<&str>, token: &String) -> bool { + secrets.iter() + .any(|secret| { + match totp::verify(secret, token) { + Ok(true) => true, + Ok(false) => false, + Err(e) => { + error!("Error from totp::verify: {}", e); + false + } + } + }) +} + +pub(in super) fn POST<'a>(header_infos: &HeaderExtract, state: &ApplicationState, req: &Request) + -> Response { + let mut token = None; + let mut redirect = None; + for (key, val) in form_urlencoded::parse(req.body()) { + if key == "token" { + token = Some(val.into_owned()) + } else if key == "redirect" { + redirect = Some(val.into_owned()) + } + } + if token.is_none() { + return error_handler_internal("missing argument 'token'".to_string()); + } + let redirect = redirect.unwrap_or(Default::default()); + + if header_infos.totp_secrets.is_empty() { + return error_handler_internal("no secrets configured".to_string()) + } + + let mut ret = Response::builder(); + if test_secrets(&header_infos.totp_secrets, &token.unwrap()) { + let cookie_value = state.cookie_store.create_authenticated_cookie(); + let cookie = CookieBuilder::new(COOKIE_NAME, cookie_value.to_string()) + .http_only(true) + .path("/") + .max_age(state.cookie_max_age) + .finish(); + ret.header(SET_COOKIE, cookie.to_string()); + warn!("Authenticated user with cookie {}", cookie); + ret.body(views::login_auth_success(&redirect)).unwrap() + } else { + ret.body(views::login_auth_fail()).unwrap() + } +} \ No newline at end of file diff --git a/src/request_handler/mod.rs b/src/request_handler/mod.rs new file mode 100644 index 0000000..4a01af6 --- /dev/null +++ b/src/request_handler/mod.rs @@ -0,0 +1,170 @@ +#![allow(warnings)] + +use std::cell::Cell; +use std::collections::HashMap; +use std::io; +use std::marker::Sync; +use std::str; +use std::str::FromStr; +use std::sync::{Arc, RwLock, Mutex, MutexGuard}; +use std::cell::RefCell; + + +use time; +use http::{Request, Response, StatusCode, Method}; +use tokio::prelude::*; +use horrorshow; +use cookie::{Cookie, CookieBuilder}; +use bytes::Bytes; + +use router; +use cookie_store::CookieStore; +use cookie_store::to_cookie; +use http_server::HttpHandler; + +mod handler_login; +mod views; + +#[derive(Clone, Copy)] +enum Route { + Login, + Logout, + Info, + Check, +} + +fn create_routing_table() -> router::RoutingTable { + let mut r = router::RoutingTable::new(); + r.insert("/info", Route::Info); + r.insert("/login", Route::Login); + r.insert("/logout", Route::Logout); + r.insert("/check", Route::Check); + r +} + +struct HeaderExtract<'a> { + totp_secrets: Vec<&'a str>, + cookies: Vec>, +} + +static HTTP_HEADER_X_TOTP_SECRET: &'static str = r"X-Totp-Secret"; +static COOKIE_NAME: &'static str = r"totp_cookie"; + +#[derive(Clone)] +pub struct RequestHandler { + routing_table: router::RoutingTable, +} + +pub(in request_handler) fn make_response(code: StatusCode, body: String) -> Response { + Response::builder().status(code).body(body).unwrap() +} + +pub(in request_handler) fn error_handler_internal(body: String) -> Response { + Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(body).unwrap() +} + +impl HttpHandler for RequestHandler { + fn respond(&self, state: &super::ApplicationState, req: Request) -> Response { + match self.routing_table.match_path(req.uri().path()) { + Ok((Route::Info, rest)) => info(self, state, &req, rest), + Ok((Route::Login, rest)) => login(state, &req, rest), + Ok((Route::Logout, rest)) => logout(state, &req, rest), + Ok((Route::Check, rest)) => check(state, &req, rest), + Err(error) => match error { + router::NoMatchingRoute => + make_response(StatusCode::NOT_FOUND, "Resource not found".to_string()), + } + } + } +} + +impl RequestHandler { + pub fn make() -> RequestHandler { + RequestHandler { routing_table: create_routing_table() } + } +} + +pub(in request_handler) fn is_logged_in(cookies: &Vec, cookie_store: &CookieStore) -> bool { + for cookie in cookies { + if cookie.name() == COOKIE_NAME { + let cookie_value = to_cookie(cookie.value()); + if cookie_value.is_some() && cookie_store.is_cookie_authenticated(&cookie_value.unwrap()) { + return true; + } + } + } + false +} + +fn info<'a>(request_handler: &RequestHandler, state: &super::ApplicationState, + req: &Request, path_rest: &'a str) -> Response { + let ftime = |ts| -> String { + let ts = time::Timespec::new(ts, 0); + let tm = time::at_utc(ts); + time::strftime("%c", &tm).unwrap_or("".to_string()) + }; + let view = if state.debug { + let valid_cookies: Vec<(String, String)> = state.cookie_store.reader + .map_into(|k, v| + (k.to_string(), ftime(v[0] as i64))); + views::info_debug(path_rest, valid_cookies) + } else { + views::info(path_rest) + }; + Response::builder().body(view).unwrap() +} + +fn login<'a>(state: &super::ApplicationState, req: &Request, path_rest: &'a str, +) -> Response { + let header_infos = match parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + match *req.method() { + Method::GET => handler_login::GET(&header_infos, state, path_rest), + Method::POST => handler_login::POST(&header_infos, state, req), + _ => error_handler_internal("Wrong method".to_string()), + } +} + +fn logout<'a>(state: &super::ApplicationState, req: &Request, path_rest: &'a str, +) -> Response { + let header_infos = match parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + + let body = format!("Rest: {}", path_rest); + Response::builder().body(body.to_string()).unwrap() +} + + +fn check<'a>(state: &super::ApplicationState, req: &Request, path_rest: &'a str) -> Response { + let header_infos = match parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + if is_logged_in(&header_infos.cookies, &state.cookie_store) { + make_response(StatusCode::OK, "".to_string()) + } else { + make_response(StatusCode::UNAUTHORIZED, "Cookie expired".to_string()) + } +} + + +fn parse_header_infos(req: &Request) -> Result { + let mut totp_secrets = Vec::new(); + for header_value in req.headers().get_all(HTTP_HEADER_X_TOTP_SECRET) { + let value = header_value.to_str().or(Err("Failed to read totp-secret header value"))?; + totp_secrets.push(value); + } + + let mut cookies = Vec::new(); + for header_value in req.headers().get_all(::http::header::COOKIE) { + let value = header_value.to_str().or(Err("Failed to read cookie value"))?; + let cookie = Cookie::parse(value).or(Err("Failed to parse cookie value"))?; + cookies.push(cookie); + } + + Ok(HeaderExtract { totp_secrets, cookies }) +} diff --git a/src/request_handler/views.rs b/src/request_handler/views.rs new file mode 100644 index 0000000..ee020eb --- /dev/null +++ b/src/request_handler/views.rs @@ -0,0 +1,138 @@ +use horrorshow::Template; + +pub(in super) fn info_debug<'a>(path_rest: &'a str, cookies: Vec<(String, String)>) -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "Hello world!"; + } + body { + h1(id = "heading") { + : "Hello! This is "; + : "And path rest is: "; + : path_rest; + : "... ok :)"; + } + h2: "Valid cookies are:"; + table(border="1") { + thead { + th: "Cookie value"; + th: "Valid until"; + } + tbody { + @ for (name, valid_until) in cookies { + tr { + td: name; + td: valid_until; + } + } + } + } + } + } + }).into_string().unwrap() +} + +pub(in super) fn info<'a>(path_rest: &'a str) -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "Hello world!"; + } + body { + h1(id = "heading") { + : "Hello! This is "; + : "And path rest is: "; + : path_rest; + : "... ok :)"; + } + } + } + }).into_string().unwrap() +} + +pub(in super) fn login_is_logged_in() -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login"; + } + body { + h1(id = "heading") { + : "Currently logged in" + } + } + } + }).into_string().unwrap() +} + +pub(in super) fn login_login_form<'a>(redirect: &'a str) -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login"; + } + body { + h1(id = "heading") { + : "Login" + } + form(method="POST") { + label(for="token") { + : "Enter TOTP token" + } + input(name="token",id="token",type="text"); + input(name="redirect", type="hidden", value=redirect); + input(name="send",type="submit",value="Submit"); + } + } + } + }).into_string().unwrap() +} + +pub(in super) fn login_auth_success(redirect: &String) -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Successful"; + meta(http-equiv="refresh", content=format!("3; URL={}", redirect)) + } + body { + h1(id = "heading") { + : "Login succesful" + } + a(href=redirect) { + : "Try again... redirecting to "; + } + span { + : format!("{}", redirect) + } + } + } + }).into_string().unwrap() +} + + +pub(in super) fn login_auth_fail() -> String { + (html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login failed"; + meta(http-equiv="refresh", content="1") + } + body { + h1(id = "heading") { + : "Login failed" + } + a(href="login") { + : "Try again... " + } + } + } + }).into_string().unwrap() +} \ No newline at end of file -- cgit v1.2.1