diff --git a/src/api/v1/tests/protected.rs b/src/api/v1/tests/protected.rs index 39a5575..feab5f4 100644 --- a/src/api/v1/tests/protected.rs +++ b/src/api/v1/tests/protected.rs @@ -48,7 +48,12 @@ async fn protected_routes_work(data: Arc, db: BoxDB) { "/api/v1/account/delete", ]; - let get_protected_urls = [V1_API_ROUTES.auth.logout, PAGES.auth.logout, PAGES.home]; + let get_protected_urls = [ + V1_API_ROUTES.auth.logout, + PAGES.auth.logout, + PAGES.home, + PAGES.gist.new, + ]; let _ = data.delete_user(db, NAME, PASSWORD).await; diff --git a/src/pages/gists/mod.rs b/src/pages/gists/mod.rs new file mode 100644 index 0000000..f5e5dbb --- /dev/null +++ b/src/pages/gists/mod.rs @@ -0,0 +1,41 @@ +/* + * 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::*; + +pub use super::{ + auth_ctx, context, errors::*, get_auth_middleware, Footer, TemplateFile, PAGES, PAYLOAD_KEY, + TEMPLATES, +}; + +pub mod new; +#[cfg(test)] +mod tests; + +pub const GIST_BASE: TemplateFile = TemplateFile::new("gistbase", "pages/gists/base.html"); +pub const GIST_EXPLORE: TemplateFile = + TemplateFile::new("gist_explore", "pages/gists/explore.html"); + +pub fn register_templates(t: &mut tera::Tera) { + for template in [GIST_BASE, GIST_EXPLORE, new::NEW_GIST].iter() { + template.register(t).expect(template.name); + } + new::register_templates(t); +} + +pub fn services(cfg: &mut web::ServiceConfig) { + new::services(cfg); +} diff --git a/src/pages/gists/new.rs b/src/pages/gists/new.rs new file mode 100644 index 0000000..f014f61 --- /dev/null +++ b/src/pages/gists/new.rs @@ -0,0 +1,270 @@ +/* + * 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::cell::RefCell; + +use actix_identity::Identity; +use actix_web::http::header::ContentType; +use serde::*; +use tera::Context; + +use db_core::GistVisibility; + +use crate::data::api::v1::gists::{ + ContentType as GistContentType, CreateGist, FileInfo, FileType, GistID, +}; +use crate::errors::*; +use crate::pages::routes::PostCommentPath; +use crate::settings::Settings; +use crate::AppData; + +pub use super::*; + +pub const NEW_GIST: TemplateFile = TemplateFile::new("newgist", "pages/gists/new/index.html"); + +pub fn register_templates(t: &mut tera::Tera) { + NEW_GIST.register(t).expect(NEW_GIST.name); +} + +pub fn services(cfg: &mut web::ServiceConfig) { + cfg.service(new); +} + +pub struct NewGist { + ctx: RefCell, +} + +impl CtxError for NewGist { + fn with_error(&self, e: &ReadableError) -> String { + self.ctx.borrow_mut().insert(ERROR_KEY, e); + self.render() + } +} + +impl NewGist { + pub fn new(username: &str, settings: &Settings, payload: Option<&[FieldNames<&str>]>) -> Self { + let ctx = RefCell::new(auth_ctx(username, settings)); + if let Some(payload) = payload { + ctx.borrow_mut().insert(PAYLOAD_KEY, &payload); + } + Self { ctx } + } + + pub fn render(&self) -> String { + TEMPLATES.render(NEW_GIST.name, &self.ctx.borrow()).unwrap() + } + + pub fn page(username: &str, s: &Settings) -> String { + let p = Self::new(username, s, None); + p.render() + } +} + +#[my_codegen::get(path = "PAGES.gist.new", wrap = "super::get_auth_middleware()")] +async fn new(data: AppData, id: Identity) -> impl Responder { + let username = id.identity().unwrap(); + let page = NewGist::page(&username, &data.settings); + let html = ContentType::html(); + HttpResponse::Ok().content_type(html).body(page) +} + +const CONTENT_FIELD_NAME_PREFIX: &str = "content__"; +const FILENAME_FIELD_NAME_PREFIX: &str = "filename__"; + +#[derive(Serialize, PartialEq, Debug, Clone)] +pub struct FieldNames { + pub filename: T, + pub content: T, +} + +impl From> for FileInfo { + fn from(f: FieldNames) -> Self { + FileInfo { + filename: f.filename.to_string(), + content: FileType::File(GistContentType::Text(f.content.to_string())), + } + } +} + +impl FieldNames { + pub fn new(num: usize) -> FieldNames { + let filename = format!("{}{}", FILENAME_FIELD_NAME_PREFIX, num); + let content = format!("{}{}", CONTENT_FIELD_NAME_PREFIX, num); + FieldNames { filename, content } + } + + #[allow(clippy::type_complexity)] + pub fn from_serde_json( + json: &serde_json::Value, + ) -> std::result::Result>, (Vec>, ServiceError)> { + let mut count = 1; + let mut fields = Self::new(count); + let mut resp = Vec::default(); + while json.get(&fields.content).is_some() || json.get(&fields.filename).is_some() { + let content = json.get(&fields.content); + if content.is_none() { + return Err(( + resp, + ServiceError::BadRequest(format!("content for {} file is empty", count)), + )); + } + let content = content.unwrap().as_str().unwrap(); + let filename = json.get(&fields.filename); + if filename.is_none() { + return Err(( + resp, + ServiceError::BadRequest(format!("filename for {} field is empty", count)), + )); + } + let filename = filename.unwrap().as_str().unwrap(); + resp.push(FieldNames { filename, content }); + count += 1; + fields = Self::new(count); + } + if resp.is_empty() { + Err((Vec::default(), ServiceError::GistEmpty)) + } else { + Ok(resp) + } + } +} + +fn get_visibility(payload: &serde_json::Value) -> ServiceResult { + let visibility = payload.get("visibility"); + if let Some(visibility) = visibility { + use std::str::FromStr; + if let Some(visibility) = visibility.as_str() { + return Ok(GistVisibility::from_str(visibility)?); + } + } + Err(ServiceError::BadRequest("unknown visibility value".into())) +} + +fn get_description(payload: &serde_json::Value) -> Option<&str> { + let description = payload.get("description"); + if let Some(description) = description { + if let Some(description) = description.as_str() { + if !description.is_empty() { + return Some(description); + } + } + } + None +} +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn fiefieldname_generation_worksldname_generation_works() { + use super::*; + assert_ne!(CONTENT_FIELD_NAME_PREFIX, FILENAME_FIELD_NAME_PREFIX); + let f: FieldNames = FieldNames::::new(10); + let f2: FieldNames = FieldNames::::new(11); + assert_ne!(f2.content, f.content); + assert_ne!(f2.filename, f.filename); + } + + #[test] + fn new_gist_json_extraction_works() { + use super::*; + + let f1: FieldNames = FieldNames::::new(1); + let f2: FieldNames = FieldNames::::new(2); + let f1_content = "file 1 content"; + let f1_name = "1.md"; + let f2_content = "file 2 content"; + let f2_name = "1.md"; + let visibility = GistVisibility::Public; + let description = "some description"; + let ideal = json!({ + "description": description, + f1.filename: f1_name, + f1.content: f1_content, + f2.filename: f2_name, + f2.content: f2_content, + "visibility": visibility.to_str(), + }); + + let from_json_fieldnames = FieldNames::<&str>::from_serde_json(&ideal).unwrap(); + assert_eq!(from_json_fieldnames.len(), 2); + for f in from_json_fieldnames.iter() { + if f.content.contains(f1_content) { + assert_eq!(f.content, f1_content); + assert_eq!(f.filename, f1_name); + } else { + assert_eq!(f.content, f2_content); + assert_eq!(f.filename, f2_name); + } + } + assert_eq!(get_visibility(&ideal).unwrap(), visibility); + assert_eq!(get_description(&ideal), Some(description)); + + // empty description + let empty = serde_json::Value::default(); + assert_eq!(get_description(&empty), None); + // empty fieldnames + let empty_gist_err = FieldNames::<&str>::from_serde_json(&empty); + assert!(empty_gist_err.is_err()); + assert_eq!( + empty_gist_err.err(), + Some((Vec::default(), ServiceError::GistEmpty)) + ); + assert_eq!( + get_visibility(&empty).err(), + Some(ServiceError::BadRequest("unknown visibility value".into())) + ); + + // partially empty fields + let f1: FieldNames = FieldNames::::new(1); + let partially_empty_files = json!({ + "description": "", + f1.filename: f1_name, + }); + // description is empty sting + assert_eq!(get_description(&empty), None); + let empty_gist_err = FieldNames::<&str>::from_serde_json(&partially_empty_files); + assert!(empty_gist_err.is_err()); + assert_eq!( + empty_gist_err.err(), + Some(( + Vec::default(), + ServiceError::BadRequest("content for 1 file is empty".into()) + )) + ); + + // some partially empty fields + let f1: FieldNames = FieldNames::::new(1); + let f2: FieldNames = FieldNames::::new(2); + let some_partially_empty_files = json!({ + f1.filename: f1_name, + f1.content: f1_content, + f2.filename: f2_name, + }); + let some_empty_gist_err = FieldNames::<&str>::from_serde_json(&some_partially_empty_files); + assert!(some_empty_gist_err.is_err()); + assert_eq!( + some_empty_gist_err.err(), + Some(( + vec![FieldNames { + filename: f1_name, + content: f1_content, + }], + ServiceError::BadRequest("content for 2 file is empty".into()) + )) + ); + } +} diff --git a/src/pages/gists/tests.rs b/src/pages/gists/tests.rs new file mode 100644 index 0000000..f1240f0 --- /dev/null +++ b/src/pages/gists/tests.rs @@ -0,0 +1,51 @@ +/* + * 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::http::StatusCode; +use actix_web::test; + +use super::*; + +use crate::data::Data; +use crate::tests::*; +use crate::*; + +#[actix_rt::test] +async fn postgres_gists_work() { + let (db, data) = sqlx_postgres::get_data().await; + gists_new_route_works(data.clone(), db.clone()).await; +} + +#[actix_rt::test] +async fn sqlite_gists_work() { + let (db, data) = sqlx_sqlite::get_data().await; + gists_new_route_works(data.clone(), db.clone()).await; +} + +async fn gists_new_route_works(data: Arc, db: BoxDB) { + const NAME: &str = "newgisttestuserexists"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "newgisttestuserexists@a.com2"; + let db = &db; + + let _ = data.delete_user(db, NAME, PASSWORD).await; + + let (_, signin_resp) = data.register_and_signin(db, NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data, db).await; + let new_gist = get_request!(&app, PAGES.gist.new, cookies.clone()); + assert_eq!(new_gist.status(), StatusCode::OK); +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index de090c2..4948086 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -26,6 +26,7 @@ use crate::{GIT_COMMIT_HASH, VERSION}; pub mod auth; pub mod errors; +pub mod gists; pub mod routes; pub use routes::get_auth_middleware; @@ -69,6 +70,7 @@ lazy_static! { errors::register_templates(&mut tera); tera.autoescape_on(vec![".html", ".sql"]); auth::register_templates(&mut tera); + gists::register_templates(&mut tera); tera }; } @@ -130,6 +132,7 @@ impl<'a> Footer<'a> { pub fn services(cfg: &mut web::ServiceConfig) { auth::services(cfg); + gists::services(cfg); } #[cfg(test)] @@ -150,7 +153,10 @@ mod tests { auth::AUTH_BASE, auth::login::LOGIN, auth::register::REGISTER, - errors::ERROR_TEMPLATE + errors::ERROR_TEMPLATE, + gists::GIST_BASE, + gists::GIST_EXPLORE, + gists::new::NEW_GIST, ] .iter() { diff --git a/static/cache/css/main.css b/static/cache/css/main.css index 00446ee..0fa3ba3 100644 --- a/static/cache/css/main.css +++ b/static/cache/css/main.css @@ -214,3 +214,15 @@ footer { width: 100%; margin: 10px 0; } + +.gist__new { + width: 80%; +} + +.gist__file-content { + display: block; + width: 100%; + margin: 10px 0; + padding: 5px 0; + height: 350px; +} diff --git a/templates/pages/gists/base.html b/templates/pages/gists/base.html new file mode 100644 index 0000000..a0efc09 --- /dev/null +++ b/templates/pages/gists/base.html @@ -0,0 +1,7 @@ +{% extends 'base' %} +{% block title %}{% block title_name %} {% endblock %} | GitPad{% endblock %} + +{% block nav %} {% include "auth_nav" %} {% endblock %} +{% block main %} + {% block gist_main %} {% endblock %} +{% endblock %} diff --git a/templates/pages/gists/explore.html b/templates/pages/gists/explore.html new file mode 100644 index 0000000..eb2226b --- /dev/null +++ b/templates/pages/gists/explore.html @@ -0,0 +1,4 @@ +{% extends 'gistbase' %} +{% block gist_main %} +

Place holder

+{% endblock %} diff --git a/templates/pages/gists/new/index.html b/templates/pages/gists/new/index.html new file mode 100644 index 0000000..62d9ec9 --- /dev/null +++ b/templates/pages/gists/new/index.html @@ -0,0 +1,75 @@ +{% extends 'gistbase' %} +{% block gist_main %} +
+ {% include "error_comp" %} + + {% if payload.files %} + {% for file in payload.files %} + + + + + {% endfor %} + {% else %} + + + + + + {% endif %} +
+{% endblock %}