diff --git a/src/deploy.rs b/src/deploy.rs new file mode 100644 index 0000000..71a1792 --- /dev/null +++ b/src/deploy.rs @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2021 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::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use crate::SETTINGS; + +pub mod routes { + pub struct Deploy { + pub update: &'static str, + } + + impl Deploy { + pub const fn new() -> Self { + Self { + update: "/api/v1/update", + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DeployEvent { + pub secret: String, + pub branch: String, +} + +#[my_codegen::post(path = "crate::V1_API_ROUTES.deploy.update")] +async fn update(payload: web::Json) -> impl Responder { + let mut found = false; + for page in SETTINGS.pages.iter() { + if page.secret == payload.secret { + page.fetch_upstream(&page.branch); + found = true; + } + } + + if found { + HttpResponse::Ok() + } else { + HttpResponse::NotFound() + } +} + +pub fn services(cfg: &mut web::ServiceConfig) { + cfg.service(update); +} + +#[cfg(test)] +mod tests { + use actix_web::{http::StatusCode, test, App}; + + use crate::services; + use crate::*; + + use super::*; + + #[actix_rt::test] + async fn deploy_update_works() { + let app = test::init_service(App::new().configure(services)).await; + + let page = SETTINGS.pages.get(0); + let page = page.unwrap(); + + let mut payload = DeployEvent { + secret: page.secret.clone(), + branch: page.branch.clone(), + }; + + let resp = test::call_service( + &app, + test::TestRequest::post() + .uri(V1_API_ROUTES.deploy.update) + .set_json(&payload) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + payload.secret = page.branch.clone(); + + let resp = test::call_service( + &app, + test::TestRequest::post() + .uri(V1_API_ROUTES.deploy.update) + .set_json(&payload) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..453ba65 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2021 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::env; + +use actix_web::{ + error::InternalError, http::StatusCode, middleware as actix_middleware, web::JsonConfig, App, + HttpServer, +}; +use lazy_static::lazy_static; +use log::info; + +mod deploy; +mod meta; +mod page; +mod routes; +mod settings; + +pub use routes::ROUTES as V1_API_ROUTES; +pub use settings::Settings; + +lazy_static! { + pub static ref SETTINGS: Settings = Settings::new().unwrap(); +} + +pub const CACHE_AGE: u32 = 604800; + +pub const GIT_COMMIT_HASH: &str = env!("GIT_HASH"); +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const PKG_NAME: &str = env!("CARGO_PKG_NAME"); +pub const PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); +pub const PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE"); + +#[cfg(not(tarpaulin_include))] +#[actix_web::main] +async fn main() -> std::io::Result<()> { + env::set_var("RUST_LOG", "info"); + + pretty_env_logger::init(); + + info!( + "{}: {}.\nFor more information, see: {}\nBuild info:\nVersion: {} commit: {}", + PKG_NAME, PKG_DESCRIPTION, PKG_HOMEPAGE, VERSION, GIT_COMMIT_HASH + ); + + println!("Starting server on: http://{}", SETTINGS.server.get_ip()); + + HttpServer::new(move || { + App::new() + .wrap(actix_middleware::Logger::default()) + .wrap(actix_middleware::Compress::default()) + .app_data(get_json_err()) + .wrap( + actix_middleware::DefaultHeaders::new() + .header("Permissions-Policy", "interest-cohort=()"), + ) + // .wrap(get_survey_session()) + // .wrap(get_identity_service()) + .wrap(actix_middleware::NormalizePath::new( + actix_middleware::TrailingSlash::Trim, + )) + .configure(services) + // .app_data(data.clone()) + }) + .bind(SETTINGS.server.get_ip()) + .unwrap() + .run() + .await +} + +#[cfg(not(tarpaulin_include))] +pub fn get_json_err() -> JsonConfig { + JsonConfig::default().error_handler(|err, _| { + //debug!("JSON deserialization error: {:?}", &err); + InternalError::new(err, StatusCode::BAD_REQUEST).into() + }) +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + routes::services(cfg); +} diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..8d25b78 --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2021 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::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use crate::{GIT_COMMIT_HASH, VERSION}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BuildDetails { + pub version: &'static str, + pub git_commit_hash: &'static str, +} + +pub mod routes { + pub struct Meta { + pub build_details: &'static str, + pub health: &'static str, + } + + impl Meta { + pub const fn new() -> Self { + Self { + build_details: "/api/v1/meta/build", + health: "/api/v1/meta/health", + } + } + } +} + +/// emmits build details of the bninary +#[my_codegen::get(path = "crate::V1_API_ROUTES.meta.build_details")] +async fn build_details() -> impl Responder { + let build = BuildDetails { + version: VERSION, + git_commit_hash: GIT_COMMIT_HASH, + }; + HttpResponse::Ok().json(build) +} + +pub fn services(cfg: &mut web::ServiceConfig) { + cfg.service(build_details); +} + +#[cfg(test)] +mod tests { + use actix_web::{http::StatusCode, test, App}; + + use crate::services; + use crate::*; + + #[actix_rt::test] + async fn build_details_works() { + let app = test::init_service(App::new().configure(services)).await; + + let resp = test::call_service( + &app, + test::TestRequest::get() + .uri(V1_API_ROUTES.meta.build_details) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } +} diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..d1e639d --- /dev/null +++ b/src/page.rs @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2021 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 git2::{build::CheckoutBuilder, BranchType, Direction, ObjectType, Repository}; +use log::info; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Page { + pub secret: String, + pub repo: String, + pub path: String, + pub branch: String, +} + +impl Page { + pub fn create_repo(&self) -> Repository { + let repo = Repository::open(&self.path); + + let repo = if repo.is_err() { + info!("Cloning repository {} at {}", self.repo, self.path); + Repository::clone(&self.repo, &self.path).unwrap() + } else { + repo.unwrap() + }; + // let branch = repo.find_branch(&self.branch, BranchType::Local).unwrap(); + + //repo.branches(BranchType::Local).unwrap().find(|b| b.unwrap().na + { + let repo = Repository::open(&self.path).unwrap(); + self._fetch_upstream(&repo, &self.branch); + let branch = repo + .find_branch(&format!("origin/{}", &self.branch), BranchType::Remote) + .unwrap(); + + let mut checkout_options = CheckoutBuilder::new(); + checkout_options.force(); + + let tree = branch.get().peel(ObjectType::Tree).unwrap(); + + repo.checkout_tree(&tree, Some(&mut checkout_options)) + .unwrap(); + // repo.set_head(&format!("refs/heads/{}", &self.branch)) + // .unwrap(); + + repo.set_head(branch.get().name().unwrap()).unwrap(); + // } + } + repo + } + + fn _fetch_upstream(&self, repo: &Repository, branch: &str) { + let mut remote = repo.find_remote("origin").unwrap(); + remote.connect(Direction::Fetch).unwrap(); + info!("Updating repository {}", self.repo); + remote.fetch(&[branch], None, None).unwrap(); + remote.disconnect().unwrap(); + } + + pub fn fetch_upstream(&self, branch: &str) { + let repo = self.create_repo(); + self._fetch_upstream(&repo, branch); + } +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..1a452b2 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2021 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::web; + +use crate::deploy::routes::Deploy; +use crate::meta::routes::Meta; + +pub const ROUTES: Routes = Routes::new(); + +pub struct Routes { + pub meta: Meta, + pub deploy: Deploy, +} + +impl Routes { + pub const fn new() -> Self { + Self { + meta: Meta::new(), + deploy: Deploy::new(), + } + } +} + +pub fn services(cfg: &mut web::ServiceConfig) { + crate::meta::services(cfg); + crate::deploy::services(cfg); +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..6c975f8 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2021 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::env; +use std::path::Path; + +use config::{Config, ConfigError, Environment, File}; +use log::warn; +use serde::Deserialize; +use url::Url; + +use crate::page::Page; + +#[derive(Debug, Clone, Deserialize)] +pub struct Server { + pub port: u32, + pub domain: String, + pub ip: String, + pub proxy_has_tls: bool, +} + +impl Server { + #[cfg(not(tarpaulin_include))] + pub fn get_ip(&self) -> String { + format!("{}:{}", self.ip, self.port) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Settings { + pub debug: bool, + // pub database: Database, + pub server: Server, + pub source_code: String, + pub pages: Vec, +} + +#[cfg(not(tarpaulin_include))] +impl Settings { + pub fn new() -> Result { + let mut s = Config::new(); + + // setting default values + #[cfg(test)] + s.set_default("database.pool", 2.to_string()) + .expect("Couldn't get the number of CPUs"); + + const CURRENT_DIR: &str = "./config/default.toml"; + const ETC: &str = "/etc/static-pages/config.toml"; + + if let Ok(path) = env::var("ATHENA_CONFIG") { + s.merge(File::with_name(&path))?; + } else if Path::new(CURRENT_DIR).exists() { + // merging default config from file + s.merge(File::with_name(CURRENT_DIR))?; + } else if Path::new(ETC).exists() { + s.merge(File::with_name(ETC))?; + } else { + log::warn!("configuration file not found"); + } + + s.merge(Environment::with_prefix("PAGES").separator("__"))?; + + check_url(&s); + + match env::var("PORT") { + Ok(val) => { + s.set("server.port", val).unwrap(); + } + Err(e) => warn!("couldn't interpret PORT: {}", e), + } + + let settings: Settings = s.try_into()?; + + for (index, page) in settings.pages.iter().enumerate() { + Url::parse(&page.repo).unwrap(); + let path = Path::new(&page.path); + if path.exists() && path.is_file() { + panic!("Path is a file, should be a directory: {:?}", page); + } + + if !path.exists() { + std::fs::create_dir_all(&path).unwrap(); + } + for (index2, page2) in settings.pages.iter().enumerate() { + if index2 == index { + continue; + } + if page.secret == page2.secret || page.repo == page2.repo || page.path == page2.path + { + panic!("duplicate page onfiguration {:?} and {:?}", page, page2); + } + } + page.fetch_upstream(&page.branch); + } + + Ok(settings) + } +} + +#[cfg(not(tarpaulin_include))] +fn check_url(s: &Config) { + let url = s + .get::("source_code") + .expect("Couldn't access source_code"); + + Url::parse(&url).expect("Please enter a URL for source_code in settings"); +}