From 9e15e04ca5005ca7ba2508419beeef8202003aa3 Mon Sep 17 00:00:00 2001 From: realaravinth Date: Wed, 23 Feb 2022 20:34:24 +0530 Subject: [PATCH] feat: new gist page and extractors to handle new gist form payload SUMMARY REST API for gist creation uses JSON for payload format, which supports array. Form doesn't so gist file field names(filename and content) follow {KNOWN_PREFIX}{index} semantics, which can be parsed while processing request. --- src/api/v1/tests/protected.rs | 7 +- src/pages/gists/mod.rs | 41 ++++ src/pages/gists/new.rs | 270 +++++++++++++++++++++++++++ src/pages/gists/tests.rs | 51 +++++ src/pages/mod.rs | 8 +- static/cache/css/main.css | 12 ++ templates/pages/gists/base.html | 7 + templates/pages/gists/explore.html | 4 + templates/pages/gists/new/index.html | 75 ++++++++ 9 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 src/pages/gists/mod.rs create mode 100644 src/pages/gists/new.rs create mode 100644 src/pages/gists/tests.rs create mode 100644 templates/pages/gists/base.html create mode 100644 templates/pages/gists/explore.html create mode 100644 templates/pages/gists/new/index.html 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 %}