diff --git a/src/main.rs b/src/main.rs index 51b6927..b9ee87b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,13 +18,17 @@ use std::sync::Arc; use actix_web::{middleware, web::Data, App, HttpServer}; +use lazy_static::lazy_static; pub mod ctx; pub mod db; +pub mod errors; pub mod federate; pub mod forge; +pub mod pages; pub mod settings; pub mod spider; +pub mod static_assets; #[cfg(test)] mod tests; pub mod utils; @@ -34,22 +38,30 @@ use crate::federate::{get_federate, ArcFederate}; use ctx::Ctx; use db::{sqlite, BoxDB}; use settings::Settings; +use static_assets::FileMap; +pub use crate::pages::routes::PAGES; + +pub const CACHE_AGE: u32 = 60 * 60 * 24 * 30; // one month, I think? pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const PKG_NAME: &str = env!("CARGO_PKG_NAME"); pub const GIT_COMMIT_HASH: &str = env!("GIT_HASH"); pub const DOMAIN: &str = "developer-starchart.forgeflux.org"; pub type ArcCtx = Arc; - pub type WebCtx = Data; pub type WebData = Data; pub type WebFederate = Data; +lazy_static! { + pub static ref FILES: FileMap = FileMap::new(); +} + #[actix_rt::main] async fn main() { let settings = Settings::new().unwrap(); pretty_env_logger::init(); + lazy_static::initialize(&pages::TEMPLATES); let ctx = Ctx::new(settings.clone()).await; let db = sqlite::get_data(Some(settings.clone())).await; diff --git a/src/pages/errors.rs b/src/pages/errors.rs new file mode 100644 index 0000000..f273a0e --- /dev/null +++ b/src/pages/errors.rs @@ -0,0 +1,106 @@ +/* + * ForgeFlux StarChart - A federated software forge spider + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use std::fmt; + +use actix_web::{ + error::ResponseError, + http::{header::ContentType, StatusCode}, + HttpResponse, HttpResponseBuilder, +}; +use derive_more::Display; +use derive_more::Error; +use serde::*; + +use super::TemplateFile; +use crate::errors::ServiceError; + +pub const ERROR_KEY: &str = "error"; + +pub const ERROR_TEMPLATE: TemplateFile = TemplateFile::new("error_comp", "components/error.html"); +pub fn register_templates(t: &mut tera::Tera) { + ERROR_TEMPLATE.register(t).expect(ERROR_TEMPLATE.name); +} + +/// Render template with error context +pub trait CtxError { + fn with_error(&self, e: &ReadableError) -> String; +} + +#[derive(Serialize, Debug, Display, Clone)] +#[display(fmt = "title: {} reason: {}", title, reason)] +pub struct ReadableError { + pub reason: String, + pub title: String, +} + +impl ReadableError { + pub fn new(e: &ServiceError) -> Self { + let reason = format!("{}", e); + let title = format!("{}", e.status_code()); + + Self { reason, title } + } +} + +#[derive(Error, Display)] +#[display(fmt = "{}", readable)] +pub struct PageError { + #[error(not(source))] + template: T, + readable: ReadableError, + #[error(not(source))] + error: ServiceError, +} + +impl fmt::Debug for PageError { + #[cfg(not(tarpaulin_include))] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PageError") + .field("readable", &self.readable) + .finish() + } +} + +impl PageError { + /// create new instance of [PageError] from a template and an error + pub fn new(template: T, error: ServiceError) -> Self { + let readable = ReadableError::new(&error); + Self { + error, + template, + readable, + } + } +} + +#[cfg(not(tarpaulin_include))] +impl ResponseError for PageError { + fn error_response(&self) -> HttpResponse { + HttpResponseBuilder::new(self.status_code()) + .content_type(ContentType::html()) + .body(self.template.with_error(&self.readable)) + } + + fn status_code(&self) -> StatusCode { + self.error.status_code() + } +} + +/// Generic result data structure +#[cfg(not(tarpaulin_include))] +pub type PageResult = std::result::Result>; diff --git a/src/pages/mod.rs b/src/pages/mod.rs new file mode 100644 index 0000000..8589c85 --- /dev/null +++ b/src/pages/mod.rs @@ -0,0 +1,175 @@ +/* + * ForgeFlux StarChart - A federated software forge spider + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use actix_web::*; +use lazy_static::lazy_static; +use rust_embed::RustEmbed; +use serde::*; +use tera::*; + +use crate::settings::Settings; +use crate::static_assets::ASSETS; +use crate::PAGES; +use crate::{GIT_COMMIT_HASH, VERSION}; + +mod errors; +pub mod routes; + +pub struct TemplateFile { + pub name: &'static str, + pub path: &'static str, +} + +impl TemplateFile { + pub const fn new(name: &'static str, path: &'static str) -> Self { + Self { name, path } + } + + pub fn register(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> { + t.add_raw_template(self.name, &Templates::get_template(self).expect(self.name)) + } + + #[cfg(test)] + #[allow(dead_code)] + pub fn register_from_file(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> { + use std::path::Path; + t.add_template_file(Path::new("templates/").join(self.path), Some(self.name)) + } +} + +pub const PAYLOAD_KEY: &str = "payload"; + +pub const BASE: TemplateFile = TemplateFile::new("base", "components/base.html"); +pub const FOOTER: TemplateFile = TemplateFile::new("footer", "components/footer.html"); +pub const PUB_NAV: TemplateFile = TemplateFile::new("pub_nav", "components/nav/pub.html"); +pub const AUTH_NAV: TemplateFile = TemplateFile::new("auth_nav", "components/nav/auth.html"); + +lazy_static! { + pub static ref TEMPLATES: Tera = { + let mut tera = Tera::default(); + for t in [BASE, FOOTER, PUB_NAV, AUTH_NAV].iter() { + t.register(&mut tera).unwrap(); + } + errors::register_templates(&mut tera); + tera.autoescape_on(vec![".html", ".sql"]); + //auth::register_templates(&mut tera); + //gists::register_templates(&mut tera); + tera + }; +} + +#[derive(RustEmbed)] +#[folder = "templates/"] +pub struct Templates; + +impl Templates { + pub fn get_template(t: &TemplateFile) -> Option { + match Self::get(t.path) { + Some(file) => Some(String::from_utf8_lossy(&file.data).into_owned()), + None => None, + } + } +} + +pub fn ctx(s: &Settings) -> Context { + let mut ctx = Context::new(); + let footer = Footer::new(s); + ctx.insert("footer", &footer); + ctx.insert("page", &PAGES); + ctx.insert("assets", &*ASSETS); + ctx +} + +#[derive(Serialize)] +pub struct Footer<'a> { + version: &'a str, + admin_email: &'a str, + source_code: &'a str, + git_hash: &'a str, + settings: &'a Settings, +} + +impl<'a> Footer<'a> { + pub fn new(settings: &'a Settings) -> Self { + Self { + version: VERSION, + source_code: &settings.source_code, + admin_email: &settings.admin_email, + git_hash: &GIT_COMMIT_HASH[..8], + settings, + } + } +} + +pub fn services(cfg: &mut web::ServiceConfig) {} + +#[cfg(test)] +mod tests { + + #[test] + fn templates_work_basic() { + use super::*; + use tera::Tera; + + let mut tera = Tera::default(); + let mut tera2 = Tera::default(); + for t in [ + BASE, FOOTER, PUB_NAV, + AUTH_NAV, + // auth::AUTH_BASE, + // auth::login::LOGIN, + // auth::register::REGISTER, + // errors::ERROR_TEMPLATE, + // gists::GIST_BASE, + // gists::GIST_EXPLORE, + // gists::new::NEW_GIST, + ] + .iter() + { + t.register_from_file(&mut tera2).unwrap(); + t.register(&mut tera).unwrap(); + } + } +} + +#[cfg(test)] +mod http_page_tests { + use actix_web::http::StatusCode; + use actix_web::test; + + use crate::ctx::Ctx; + use crate::db::BoxDB; + use crate::tests::*; + use crate::*; + + use super::PAGES; + + #[actix_rt::test] + async fn sqlite_templates_work() { + let (db, data) = sqlx_sqlite::get_ctx().await; + templates_work(data, db).await; + } + + async fn templates_work(data: Arc, db: BoxDB) { + let app = get_app!(data, db).await; + + for file in [PAGES.auth.login, PAGES.auth.register].iter() { + let resp = get_request!(&app, file); + assert_eq!(resp.status(), StatusCode::OK); + } + } +} diff --git a/src/pages/routes.rs b/src/pages/routes.rs new file mode 100644 index 0000000..d53769e --- /dev/null +++ b/src/pages/routes.rs @@ -0,0 +1,97 @@ +/* + * ForgeFlux StarChart - A federated software forge spider + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use serde::Serialize; + +/// constant [Pages](Pages) instance +pub const PAGES: Pages = Pages::new(); + +#[derive(Serialize)] +/// Top-level routes data structure for V1 AP1 +pub struct Pages { + /// home page + pub home: &'static str, +} + +impl Pages { + /// create new instance of Routes + const fn new() -> Pages { + let home = "/"; + Pages { home } + } +} + +#[derive(Serialize)] +/// Authentication routes +pub struct Auth { + /// logout route + pub logout: &'static str, + /// login route + pub login: &'static str, +} + +impl Auth { + /// create new instance of Authentication route + pub const fn new() -> Auth { + let login = "/login"; + let logout = "/logout"; + Auth { login, logout } + } +} + +//#[cfg(test)] +//mod tests { +// use super::*; +// #[test] +// fn gist_route_substitution_works() { +// const NAME: &str = "bob"; +// const GIST: &str = "foo"; +// const FILE: &str = "README.md"; +// let get_profile = format!("/~{NAME}"); +// let view_gist = format!("/~{NAME}/{GIST}"); +// let post_comment = format!("/~{NAME}/{GIST}/comment"); +// let get_file = format!("/~{NAME}/{GIST}/contents/{FILE}"); +// +// let profile_component = GistProfilePathComponent { username: NAME }; +// +// assert_eq!(get_profile, PAGES.gist.get_profile_route(profile_component)); +// +// let profile_component = PostCommentPath { +// username: NAME.into(), +// gist: GIST.into(), +// }; +// +// assert_eq!(view_gist, PAGES.gist.get_gist_route(&profile_component)); +// +// let post_comment_path = PostCommentPath { +// gist: GIST.into(), +// username: NAME.into(), +// }; +// +// assert_eq!( +// post_comment, +// PAGES.gist.get_post_comment_route(&post_comment_path) +// ); +// +// let file_component = GetFilePath { +// username: NAME.into(), +// gist: GIST.into(), +// file: FILE.into(), +// }; +// assert_eq!(get_file, PAGES.gist.get_file_route(&file_component)); +// } +//} diff --git a/src/settings.rs b/src/settings.rs index d72e18b..980aa3d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -21,11 +21,11 @@ use std::{env, fs}; use config::{Config, ConfigError, Environment, File}; use derive_more::Display; use log::warn; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Server { pub port: u32, pub domain: String, @@ -40,7 +40,7 @@ impl Server { } } -#[derive(Deserialize, Display, Clone, Debug)] +#[derive(Debug, Display, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LogLevel { #[display(fmt = "debug")] @@ -64,7 +64,7 @@ impl LogLevel { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] pub struct Repository { pub root: String, } @@ -83,7 +83,7 @@ impl Repository { } } -#[derive(Deserialize, Display, PartialEq, Clone, Debug)] +#[derive(Debug, Display, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DBType { #[display(fmt = "postgres")] @@ -102,14 +102,14 @@ impl DBType { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Database { pub url: String, pub pool: u32, pub database_type: DBType, } -#[derive(Debug, Validate, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Crawler { pub ttl: u64, pub client_timeout: u64, @@ -117,7 +117,7 @@ pub struct Crawler { pub wait_before_next_api_call: u64, } -#[derive(Debug, Validate, Clone, Deserialize)] +#[derive(Debug, Validate, Clone, PartialEq, Serialize, Deserialize)] pub struct Settings { pub log: LogLevel, pub database: Database,