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.
master
Aravinth Manivannan 2022-02-23 20:34:24 +05:30
parent f5a22a2916
commit 9e15e04ca5
Signed by: realaravinth
GPG Key ID: AD9F0F08E855ED88
9 changed files with 473 additions and 2 deletions

View File

@ -48,7 +48,12 @@ async fn protected_routes_work(data: Arc<Data>, 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;

41
src/pages/gists/mod.rs Normal file
View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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);
}

270
src/pages/gists/new.rs Normal file
View File

@ -0,0 +1,270 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<Context>,
}
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<T: Serialize + ToString> {
pub filename: T,
pub content: T,
}
impl<T: Serialize + ToString> From<FieldNames<T>> for FileInfo {
fn from(f: FieldNames<T>) -> Self {
FileInfo {
filename: f.filename.to_string(),
content: FileType::File(GistContentType::Text(f.content.to_string())),
}
}
}
impl<T: Serialize + ToString> FieldNames<T> {
pub fn new(num: usize) -> FieldNames<String> {
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<FieldNames<&str>>, (Vec<FieldNames<&str>>, 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<GistVisibility> {
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<String> = FieldNames::<String>::new(10);
let f2: FieldNames<String> = FieldNames::<String>::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<String> = FieldNames::<String>::new(1);
let f2: FieldNames<String> = FieldNames::<String>::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<String> = FieldNames::<String>::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<String> = FieldNames::<String>::new(1);
let f2: FieldNames<String> = FieldNames::<String>::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())
))
);
}
}

51
src/pages/gists/tests.rs Normal file
View File

@ -0,0 +1,51 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<Data>, 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);
}

View File

@ -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()
{

View File

@ -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;
}

View File

@ -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 %}

View File

@ -0,0 +1,4 @@
{% extends 'gistbase' %}
{% block gist_main %}
<h1>Place holder</h1>
{% endblock %}

View File

@ -0,0 +1,75 @@
{% extends 'gistbase' %}
{% block gist_main %}
<form class="gist__new" action="/new" method="POST" accept-charset="utf-8">
{% include "error_comp" %}
<label class="form__label" for="login">
Gist description
<input
class="form__input"
name="description"
autofocus
id="description"
type="text"
{% if payload.description %}
value={{ payload.description }}
{% endif %}
/>
</label>
{% if payload.files %}
{% for file in payload.files %}
<legend class="gist__file">
<label class="form__label" for="login">
File name with extension
<input
required
class="form__input"
name="filename"
autofocus
id="filename"
type="text"
value={{ file.filename }}
/>
</label>
<label class="form__label" for="login">
<textarea
required
class="gist__file-content"
name="content"
autofocus
id="content"
type="text"
value={{ file.content }}
>
</textarea>
</label>
</legend>
{% endfor %}
{% else %}
<legend class="gist__file">
<label class="form__label" for="login">
File name with extension
<input
class="form__input"
name="filename"
autofocus
id="filename"
type="text"
/>
</label>
<label class="form__label" for="login">
<textarea
required
class="gist__file-content"
name="content"
autofocus
id="content"
type="text"
>
</textarea>
</label>
</legend>
{% endif %}
</form>
{% endblock %}