diff --git a/.gitignore b/.gitignore index f500892940542a8edf87c12a7c2502885f175003..db6a2a96fc1e7fae3d5a2f274b3e1f7536da7b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules target .env public/**/*.js -public/**/view/* \ No newline at end of file +public/**/view/* +public/standard/test_sitemap.xml +public/standard/sitemap.xml \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c51b74f465564fd6274c1a98ddf84326592ac6b1..cfa0e817e6be045736e1bbea98e9c36866e019e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,6 +583,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "chrono_utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f69ed74e2117892a1a4e05d31f6612178e8e827bfbd83bbf8ca8c1bcfbda710" +dependencies = [ + "chrono", +] + [[package]] name = "cipher" version = "0.3.0" @@ -1284,6 +1293,7 @@ dependencies = [ "rustls 0.18.1", "serde", "serde_json", + "sitemap", "time 0.2.27", "tokio", "wither", @@ -2178,6 +2188,18 @@ dependencies = [ "libc", ] +[[package]] +name = "sitemap" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c697e1d88854f66d7d805e8b532e341661a4b8f49fab419be8e82b0cafdeb4d" +dependencies = [ + "chrono", + "chrono_utils", + "url", + "xml-rs", +] + [[package]] name = "slab" version = "0.4.4" @@ -2956,3 +2978,9 @@ dependencies = [ "winapi 0.2.8", "winapi-build", ] + +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" diff --git a/Cargo.toml b/Cargo.toml index 5a85d62a2b545ea8fa1186d258d0fe4c057da7a3..739a0298ba08e292c595723f136a32d05746bfe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ dotenv = "0.15" time = "0.2.7" regex = "1.5" tokio = { version = "0.2", features = ["full"] } +sitemap = "0.4.1" diff --git a/Makefile b/Makefile index ff3edcf9c4fdd3b8859ea796ebdb98ee05a43e94..b9e0d073ee7c5a04af2f6a7aef7e7dc820b0285e 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ reload-api: docker-compose restart kuadrado_server test: - RESOURCES_DIR="./" cargo test -- --test-threads=1 + RESOURCES_DIR="./" CONTEXT=testing cargo test -- --test-threads=1 doc: cargo doc --no-deps diff --git a/README.md b/README.md index eb2bf92c2181e67e25e48ed3a16bc710a7fc49c6..f5fccb633e723b1d6385ee093309fb5a9cc93e5b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ _Env vars may be defined in a .env file at the root of the project_ - `CRYPT_KEY`: Any random ascii string that will be used to encrypt data like emails and passwords. +## Tests +Create a public/standard/test_sitemap.xml file with a sitemap structure in order to pass the tests. + ## Admin panel - create a Play Button **Syntax** diff --git a/admin-frontend/src/article.js b/admin-frontend/src/article.js index c2cf4fb71a61cfe57ae1a23c1f6bfa4735790b3a..181e8ecfd972500d00ccc0400a76813c82ecdb3d 100644 --- a/admin-frontend/src/article.js +++ b/admin-frontend/src/article.js @@ -13,6 +13,7 @@ class Article { this.body = ""; this.locale = ""; this.display_priority_index = 1; + this.with_static_view = true; this.metadata = { description: "", }; diff --git a/admin-frontend/src/components/create-article-form.js b/admin-frontend/src/components/create-article-form.js index b728b5ac8f2f97725cab0788307e6ea8bc068d07..efda81086b643a19155a715e9d8f27fc91324b5f 100644 --- a/admin-frontend/src/components/create-article-form.js +++ b/admin-frontend/src/components/create-article-form.js @@ -26,6 +26,10 @@ class CreateArticleForm { this.state.output.metadata = metadata; } + handle_change_bool_checkbox(field, e) { + this.state.output[field] = e.target.checked; + } + handle_text_input(field, e) { this.state.output[field] = e.target.value; } @@ -335,6 +339,15 @@ class CreateArticleForm { value: this.state.output.title, oninput: this.handle_text_input.bind(this, "title") }, + { + tag: "div", contents: [ + { + tag: "input", type: "checkbox", checked: this.state.output.with_static_view, + onchange: this.handle_change_bool_checkbox.bind(this, "with_static_view") + }, + { tag: "label", contents: "Create static view" }, + ] + }, { tag: "input", type: "text", style_rules: { diff --git a/mongo/scripts/migration.js b/mongo/scripts/migration.js index 5e8e966680332b5e3f91e285eb9e854ae6ee74d8..3db6e73551411471d9e72bae3e3865205c9becf9 100644 --- a/mongo/scripts/migration.js +++ b/mongo/scripts/migration.js @@ -8,6 +8,11 @@ db.auth(adminname, adminpwd); articles = db.getCollection("articles"); articles.update({}, - { $set: { "metadata": { "description": "" } } }, + { + $set: { + "metadata": { "description": "" }, + "with_static_view": false + } + }, { upsert: false, multi: true } ); \ No newline at end of file diff --git a/src/model/article.rs b/src/model/article.rs index 0b45eb0a64d711437e57ada64ee93accae774d5d..874ccfb868ca717ccbbf7462f6a9deb0e82d8448 100644 --- a/src/model/article.rs +++ b/src/model/article.rs @@ -35,6 +35,7 @@ pub struct Article { pub category: String, pub locale: String, pub display_priority_index: i8, + pub with_static_view: bool, pub metadata: ArticleMetadata, } @@ -61,6 +62,7 @@ impl Article { category: "testing".to_string(), locale: "fr".to_string(), display_priority_index: 1, + with_static_view: true, metadata: ArticleMetadata { description: "A test article".to_string(), view_uri: None, diff --git a/src/service/articles.rs b/src/service/articles.rs index 7fa896c12d463f908efb8706aa9b416c93eeba77..6133cf292bf228dbf7dd111a14be83a424812b3c 100644 --- a/src/service/articles.rs +++ b/src/service/articles.rs @@ -43,20 +43,28 @@ fn slugify(s: &String) -> String { } fn create_article_static_view_metadata(app_state: &AppState, art: &mut Article) { - let article_slug = slugify(&art.title); - art.metadata.slug = Some(article_slug.to_owned()); - art.metadata.view_uri = Some(format!( - "{}://{}/{}/{}/", - &app_state.env.server_protocol, &app_state.env.server_host, &art.category, &article_slug, - )); - art.metadata.static_resource_path = Some( - app_state - .env - .public_dir - .join(&art.category) - .join("view") - .join(&article_slug), - ); + if art.with_static_view { + let article_slug = slugify(&art.title); + art.metadata.slug = Some(article_slug.to_owned()); + art.metadata.view_uri = Some(format!( + "{}://{}/{}/view/{}/{}/", + &app_state.env.server_protocol, + &app_state.env.server_host, + &art.category, + &art.locale, + &article_slug, + )); + + art.metadata.static_resource_path = Some( + app_state + .env + .public_dir + .join(&art.category) + .join("view") + .join(&art.locale) + .join(&article_slug), + ); + } } async fn generate_article_view(app_state: &AppState, id: &ObjectId) -> Result<(), String> { @@ -67,44 +75,10 @@ async fn generate_article_view(app_state: &AppState, id: &ObjectId) -> Result<() } let art = art.unwrap(); - - if art.metadata.view_uri.is_none() { - return Err(format!("Article {} doesn't have view uri", art.title)); - } - - let art_img_def = String::new(); - let mut art_image_uri = art.images.iter().next().unwrap_or(&art_img_def).to_owned(); - - if !art_image_uri.is_empty() { - art_image_uri = format!("/assets/images/{}", art_image_uri); + if art.with_static_view { + return create_static_view(&app_state, &art); } - - let slug = art.metadata.slug.unwrap(); - - let html = format!( - " -<html lang='{}'> -<head> - <meta charset='UTF-8'> - <meta http-equiv='X-UA-Compatible' content='IE=edge'> - <meta name='viewport' content='width=device-width, initial-scale=1.0'> - <meta name='author' content='Kuadrado Software' /> - <meta name='image' content='{}'/> - <meta name='description' content='{}'> - <link rel='icon' type='image/svg+xml' href='/favicon.svg' /> - <title>{}</title> - <link href='/style/style.css' rel='stylesheet' /> -</head> -<body> - <div>{}<div> -</body> -<script type='text/javascript' src='/games/view.js'></script> -</html> - ", - art.locale, art_image_uri, art.metadata.description, art.title, art.body, - ); - - create_static_view(&app_state, art.category, slug, html) + Ok(()) } Err(e) => { return Err(format!( @@ -178,10 +152,16 @@ pub async fn update_article( let mut article_data = article_data.into_inner(); article_data.date = Some(DateTime(Utc::now())); - if let Err(e) = delete_static_view(&article_data.metadata.static_resource_path) { - return HttpResponse::InternalServerError() - .body(format!("Error removing previous static view : {}", e)); - }; + if article_data.with_static_view { + if let Err(e) = delete_static_view( + &app_state, + &article_data.metadata.static_resource_path, + &article_data.metadata.view_uri, + ) { + return HttpResponse::InternalServerError() + .body(format!("Error removing previous static view : {}", e)); + }; + } create_article_static_view_metadata(&app_state, &mut article_data); @@ -223,13 +203,20 @@ pub async fn delete_article( }; let articles = get_collection(&app_state); + match articles.find_one(doc! {"_id": &article_id}, None).await { Ok(article) => match article { Some(art) => { - if let Err(e) = delete_static_view(&art.metadata.static_resource_path) { - return HttpResponse::InternalServerError() - .body(format!("Error removing article static view {}", e)); - }; + if art.with_static_view { + if let Err(e) = delete_static_view( + &app_state, + &art.metadata.static_resource_path, + &art.metadata.view_uri, + ) { + return HttpResponse::InternalServerError() + .body(format!("Error removing article static view {}", e)); + }; + } match articles.delete_one(doc! {"_id": &article_id}, None).await { Ok(_) => HttpResponse::Accepted().body("Article was deleted"), @@ -339,12 +326,14 @@ mod test_articles { web::Bytes, App, }; + use std::fs::remove_dir_all; async fn insert_test_article( app_state: &AppState, test_article: Article, ) -> Result<(ObjectId, String), String> { let title = test_article.title.to_owned(); + match get_collection(&app_state) .insert_one(test_article, None) .await @@ -370,6 +359,17 @@ mod test_articles { } } + fn clear_testing_static_view_dir(app_state: &AppState) -> Result<(), String> { + let path = app_state.env.public_dir.join("testing/view"); + if path.exists() && path.is_dir() { + let res = remove_dir_all(path); + if let Err(e) = res { + return Err(format!("Error removing testing stativ views {}", e)); + } + } + Ok(()) + } + async fn get_authenticated_admin(app_state: &AppState) -> Administrator { Administrator::authenticated( app_state, @@ -425,7 +425,17 @@ mod test_articles { .unwrap(); assert!(find_inserted.is_some()); - assert_eq!(find_inserted.unwrap().title, article.title); + let find_inserted = find_inserted.unwrap(); + assert_eq!(find_inserted.title, article.title); + + // Static view tests + let static_view_path = find_inserted.metadata.static_resource_path; + assert!(static_view_path.is_some()); + let static_view_path = static_view_path.unwrap(); + assert!(static_view_path.exists()); + + let cleared = clear_testing_static_view_dir(&app_state); + assert!(cleared.is_ok()); get_collection(&app_state) .delete_one(doc! {"title": article.title}, None) @@ -518,7 +528,17 @@ mod test_articles { .unwrap(); assert!(find_inserted.is_some()); - assert_eq!(find_inserted.unwrap().title, "changed title"); + let find_inserted = find_inserted.unwrap(); + assert_eq!(find_inserted.title, "changed title"); + + // Static view tests + let static_view_path = find_inserted.metadata.static_resource_path; + assert!(static_view_path.is_some()); + let static_view_path = static_view_path.unwrap(); + assert!(static_view_path.exists()); + + let cleared = clear_testing_static_view_dir(&app_state); + assert!(cleared.is_ok()); let del_count = delete_test_article(&app_state, &article_id).await.unwrap(); assert_eq!(del_count, 1); diff --git a/src/static_view.rs b/src/static_view.rs index e2e32949e6b1aa312180a6433728987c9c575f64..3d3436c2ba9807e2c396f91a049cbab56c714265 100644 --- a/src/static_view.rs +++ b/src/static_view.rs @@ -1,15 +1,27 @@ -use crate::app_state::AppState; -use std::fs::{create_dir, create_dir_all, remove_dir, remove_file, File}; -use std::io::Write; -use std::path::PathBuf; +use crate::{app_state::AppState, model::Article}; +use chrono::{Datelike, FixedOffset, TimeZone, Utc}; +use sitemap::{ + reader::{SiteMapEntity, SiteMapReader}, + structs::UrlEntry, + writer::SiteMapWriter, +}; +use std::{ + fs::{create_dir, create_dir_all, remove_dir_all, remove_file, rename, File}, + io::Write, + path::PathBuf, +}; -pub fn create_static_view( - app_state: &AppState, - category: String, - filename: String, - html: String, -) -> Result<(), String> { - let view_path = app_state.env.public_dir.join(&category).join("view"); +enum UpdateSitemapMode { + CreateUrl, + DeleteUrl, +} + +pub fn create_static_view(app_state: &AppState, article: &Article) -> Result<(), String> { + let view_path = app_state + .env + .public_dir + .join(&article.category) + .join("view"); if !view_path.exists() { if let Err(e) = create_dir(&view_path) { @@ -17,46 +29,184 @@ pub fn create_static_view( } } - let d_path = app_state - .env - .public_dir - .join(&category) - .join("view") - .join(&filename); + let d_path = article.metadata.static_resource_path.as_ref().unwrap(); if let Err(e) = create_dir_all(&d_path) { return Err(format!("Error creating directory {:?} : {}", d_path, e)); } + let art_img_def = String::new(); + + let mut art_image_uri = article + .images + .iter() + .next() + .unwrap_or(&art_img_def) + .to_owned(); + + if !art_image_uri.is_empty() { + art_image_uri = format!("/assets/images/{}", art_image_uri); + } + + let html = format!( + " +<html lang='{}'> +<head> + <meta charset='UTF-8'> + <meta http-equiv='X-UA-Compatible' content='IE=edge'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <meta name='author' content='Kuadrado Software' /> + <meta name='image' content='{}'/> + <meta name='description' content='{}'> + <link rel='icon' type='image/svg+xml' href='/favicon.svg' /> + <title>{}</title> + <link href='/style/style.css' rel='stylesheet' /> +</head> +<body> + <div>{}<div> +</body> +<script type='text/javascript' src='/games/view.js'></script> +</html> + ", + article.locale, art_image_uri, article.metadata.description, article.title, article.body, + ); + let f_path = d_path.join("index.html"); + match File::create(&f_path) { Ok(mut f) => { if let Err(e) = f.write_all(html.as_bytes()) { return Err(format!("Error writing to {:?} : {}", f_path, e)); } + + if let Err(e) = update_sitemap( + app_state, + &article.metadata.view_uri, + UpdateSitemapMode::CreateUrl, + ) { + return Err(e); + }; + Ok(()) } Err(e) => Err(format!("Error creating {:?} : {}", f_path, e)), } } -pub fn delete_static_view(path: &Option<PathBuf>) -> Result<(), String> { +pub fn delete_static_view( + app_state: &AppState, + path: &Option<PathBuf>, + uri: &Option<String>, +) -> Result<(), String> { if let Some(path) = path { if path.exists() { - if let Err(e) = remove_file(path) { - return Err(format!("Error deleting static view at {:?} : {}", path, e)); - }; - let parent = path.parent().unwrap(); - if let Err(e) = remove_dir(parent) { + if let Err(e) = remove_dir_all(parent) { return Err(format!( - "Error deleting static view directory at {:?} : {}", + "Error deleting static view at {:?} : {}", parent, e )); } } } + if let Err(e) = update_sitemap(app_state, uri, UpdateSitemapMode::DeleteUrl) { + return Err(e); + }; + + Ok(()) +} + +fn update_sitemap( + app_state: &AppState, + uri: &Option<String>, + mode: UpdateSitemapMode, +) -> Result<(), String> { + if uri.is_none() { + return Ok(()); + } + + let sitemap_name = match std::env::var("CONTEXT") { + Ok(value) => { + if value.eq("testing") { + String::from("test_sitemap.xml") + } else { + String::from("sitemap.xml") + } + } + Err(_) => String::from("sitemap.xml"), + }; + + let standard_dir_pth = app_state.env.public_dir.join("standard"); + let uri = uri.as_ref().unwrap().to_owned(); + + let sitemap = + File::open(standard_dir_pth.join(&sitemap_name)).expect("Couldn't open file sitemap.xml"); + + let mut urls = Vec::new(); + + for entity in SiteMapReader::new(sitemap) { + if let SiteMapEntity::Url(url_entry) = entity { + urls.push(url_entry.loc.get_url().unwrap().to_string()); + } + } + + let updated_sitemap = File::create(standard_dir_pth.join("tmp_sitemap.xml")) + .expect("Couldn't create temporary sitemap"); + + let writer = SiteMapWriter::new(updated_sitemap); + let mut url_writer = writer + .start_urlset() + .expect("Unable to write sitemap urlset"); + + match mode { + UpdateSitemapMode::CreateUrl => { + urls.push(uri); + } + UpdateSitemapMode::DeleteUrl => { + let mut updated_urls = Vec::new(); + for u in urls { + if !u.eq(&uri) { + updated_urls.push(u); + } + } + urls = updated_urls; + } + } + + let now = Utc::today().naive_utc(); + + for u in urls { + url_writer + .url( + UrlEntry::builder() + .loc(u) + .lastmod( + FixedOffset::west(0) + .ymd(now.year(), now.month(), now.day()) + .and_hms(0, 0, 0), + ) + .build() + .unwrap(), + ) + .expect("Unable to write url"); + } + + url_writer + .end() + .expect("Unable to write sitemap closing tags"); + + if let Err(e) = remove_file(standard_dir_pth.join(&sitemap_name)) { + return Err(format!("Error updating sitemap.xml {}", e)); + }; + + if let Err(e) = rename( + standard_dir_pth.join("tmp_sitemap.xml"), + standard_dir_pth.join(&sitemap_name), + ) { + return Err(format!("Error updating sitemap.xml {}", e)); + }; + Ok(()) }