diff --git a/Cargo.lock b/Cargo.lock index 873731d12418a70493769103ca8607748a0861d9..c51b74f465564fd6274c1a98ddf84326592ac6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,9 +342,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" dependencies = [ "memchr", ] @@ -1280,6 +1280,7 @@ dependencies = [ "futures", "magic-crypt", "rand 0.8.4", + "regex", "rustls 0.18.1", "serde", "serde_json", @@ -1381,9 +1382,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.3.4" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "mime" @@ -1872,9 +1873,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.4.6" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26af418b574bd56588335b3a3659a65725d4e636eb1016c2f9e3b38c7cc759" +checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" dependencies = [ "aho-corasick", "memchr", @@ -1883,9 +1884,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.25" +version = "0.6.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" [[package]] name = "reqwest" diff --git a/Cargo.toml b/Cargo.toml index 263edf058c92f7c0470ddc047ec57de062376369..5a85d62a2b545ea8fa1186d258d0fe4c057da7a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ chrono = "0.4" rand = "0.8" dotenv = "0.15" time = "0.2.7" +regex = "1.5" tokio = { version = "0.2", features = ["full"] } diff --git a/admin-frontend/src/article.js b/admin-frontend/src/article.js index c583c3d94684bc947ebcf11577d71afd9d32cb75..c2cf4fb71a61cfe57ae1a23c1f6bfa4735790b3a 100644 --- a/admin-frontend/src/article.js +++ b/admin-frontend/src/article.js @@ -13,6 +13,9 @@ class Article { this.body = ""; this.locale = ""; this.display_priority_index = 1; + this.metadata = { + description: "", + }; } } diff --git a/admin-frontend/src/components/create-article-form.js b/admin-frontend/src/components/create-article-form.js index 7f5b5b2755df4023b9e097474b17e1581436285c..b728b5ac8f2f97725cab0788307e6ea8bc068d07 100644 --- a/admin-frontend/src/components/create-article-form.js +++ b/admin-frontend/src/components/create-article-form.js @@ -19,6 +19,13 @@ class CreateArticleForm { this.refresh(); } + handle_change_metadata(field, e) { + const metadata = Object.assign(this.state.output.metadata, { + [field]: e.target.value, + }); + this.state.output.metadata = metadata; + } + handle_text_input(field, e) { this.state.output[field] = e.target.value; } @@ -347,6 +354,12 @@ class CreateArticleForm { placeholder: "Article body", oninput: this.handle_text_input.bind(this, "body") }, + { + tag: "input", type: "text", + value: this.state.output.metadata.description, + placeholder: "description", + oninput: this.handle_change_metadata.bind(this, "description"), + }, this.render_details_inputs(), this.render_images_inputs(), { tag: "input", type: "submit" } diff --git a/mongo/scripts/migration.js b/mongo/scripts/migration.js index 33329ed1e729ddefed4f3fea43d260257c0672c5..5e8e966680332b5e3f91e285eb9e854ae6ee74d8 100644 --- a/mongo/scripts/migration.js +++ b/mongo/scripts/migration.js @@ -8,6 +8,6 @@ db.auth(adminname, adminpwd); articles = db.getCollection("articles"); articles.update({}, - { $set: { "display_priority_index": NumberInt(1) } }, + { $set: { "metadata": { "description": "" } } }, { upsert: false, multi: true } ); \ No newline at end of file diff --git a/public/games/index.html b/public/games/index.html index ebb6c4170d42a24ef67f4b6edd2e9e19a71ef210..4b82f26a4140e0f9cb4ee9828ebbeb73ac5595d9 100644 --- a/public/games/index.html +++ b/public/games/index.html @@ -4,20 +4,22 @@ <head> <meta charset="utf-8" /> <title>Kuadrado Software | Jeux</title> - <meta name="description" content="Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement"/> + <meta name="description" + content="Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement" /> <meta name="author" content="Kuadrado Software" /> - <meta name="image" content="https://kuadrado-software.fr/assets/images/game_controller.png"/> + <meta name="image" content="https://kuadrado-software.fr/assets/images/game_controller.png" /> <!-- Open Graph Protocol meta data --> - <meta property="og:title" content="Kuadrado Software | Jeux"/> - <meta property="og:description" content="Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement"/> + <meta property="og:title" content="Kuadrado Software | Jeux" /> + <meta property="og:description" + content="Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement" /> <meta property="og:type" content="website" /> - <meta property="og:url" content="https://kuadrado-software.fr/games"/> - <meta property="og:image" content="https://kuadrado-software.fr/assets/images/game_controller.png"/> -<meta property="og:image" content="https://kuadrado-software.fr/assets/images/game_studio_banner.png"/> -<meta property="og:image" content="https://kuadrado-software.fr/assets/images/popularization_banner.png"/> - <meta property="twitter:image" content="https://kuadrado-software.fr/assets/images/game_controller.png"/> - <meta property="og:locale" content="fr_FR"/> + <meta property="og:url" content="https://kuadrado-software.fr/games" /> + <meta property="og:image" content="https://kuadrado-software.fr/assets/images/game_controller.png" /> + <meta property="og:image" content="https://kuadrado-software.fr/assets/images/game_studio_banner.png" /> + <meta property="og:image" content="https://kuadrado-software.fr/assets/images/popularization_banner.png" /> + <meta property="twitter:image" content="https://kuadrado-software.fr/assets/images/game_controller.png" /> + <meta property="og:locale" content="fr_FR" /> <meta property="og:site_name" content="Kuadrado Software" /> <!-- English translation not ready yet --> @@ -26,7 +28,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link href="/style/style.css" rel="stylesheet" /> - <script type="application/ld+json">{"@context":"https://schema.org","type":"WebPage","description":"Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement","image":["https://kuadrado-software.fr/assets/images/game_controller.svg","https://kuadrado-software.fr/assets/images/game_controller.png"],"keywords":"gamedev, pixelart, jeux vidéo, création, video games, indépendants, indie gamedev","name":"Kuadrado Software - Jeux","url":"https://kuadrado-software.fr/games"}</script> + <script + type="application/ld+json">{"@context":"https://schema.org","type":"WebPage","description":"Création de jeux vidéos indépendants. Jeux web, PC et projets en cours de développement","image":["https://kuadrado-software.fr/assets/images/game_controller.svg","https://kuadrado-software.fr/assets/images/game_controller.png"],"keywords":"gamedev, pixelart, jeux vidéo, création, video games, indépendants, indie gamedev","name":"Kuadrado Software - Jeux","url":"https://kuadrado-software.fr/games"}</script> </head> <!-- The vocab attribute defines the standard vocabulary used for RDFa standard. The DOM may contain properties such as "typeof" and "property" accordinly to the schema.org vocabulary --> diff --git a/src/env.rs b/src/env.rs index c0b0b9900271ef347e75ee8c412f0cd25517627e..cb98d2875072cc08ae3a2b82c1d64b725a272654 100644 --- a/src/env.rs +++ b/src/env.rs @@ -9,6 +9,7 @@ pub struct Env { pub db_name: String, pub db_port: String, pub server_host: String, + pub server_protocol: String, pub crypt_key: String, pub default_admin_username: String, pub default_admin_password: String, @@ -50,6 +51,7 @@ impl Env { db_name: env::var("DATABASE_NAME").expect("DATABASE_NAME is not defined."), db_port: env::var("DB_PORT").expect("DB_PORT is not defined."), server_host: env::var("SERVER_HOST").expect("SERVER_HOST is not defined"), + server_protocol: env::var("SERVER_PROTOCOL").expect("SERVER_PROTOCOL is not defined"), crypt_key: env::var("CRYPT_KEY").expect("CRYPT_KEY is not defined."), default_admin_username: env::var("DEFAULT_ADMIN_USERNAME") .expect("DEFAULT_ADMIN_USERNAME is not defined"), @@ -72,6 +74,7 @@ impl Env { db_name: env::var("DATABASE_NAME").expect("DATABASE_NAME is not defined."), db_port: env::var("DB_PORT").expect("DB_PORT is not defined."), server_host: env::var("SERVER_HOST").expect("SERVER_HOST is not defined"), + server_protocol: env::var("SERVER_PROTOCOL").expect("SERVER_PROTOCOL is not defined"), crypt_key: env::var("CRYPT_KEY").expect("CRYPT_KEY is not defined."), default_admin_username: env::var("DEFAULT_ADMIN_USERNAME") .expect("DEFAULT_ADMIN_USERNAME is not defined"), diff --git a/src/model/article.rs b/src/model/article.rs index f080e53a5582557d5ffad2eba65807637b9c8ff8..0b45eb0a64d711437e57ada64ee93accae774d5d 100644 --- a/src/model/article.rs +++ b/src/model/article.rs @@ -1,6 +1,7 @@ #[cfg(test)] use chrono::Utc; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use wither::{ bson::{doc, oid::ObjectId, DateTime}, prelude::Model, @@ -12,6 +13,14 @@ pub struct ArticleDetail { pub value: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ArticleMetadata { + pub description: String, + pub view_uri: Option<String>, + pub static_resource_path: Option<PathBuf>, + pub slug: Option<String>, +} + #[derive(Debug, Serialize, Deserialize, Model, Clone)] #[model(index(keys = r#"doc!{"title": 1}"#, options = r#"doc!{"unique": true}"#))] pub struct Article { @@ -26,6 +35,7 @@ pub struct Article { pub category: String, pub locale: String, pub display_priority_index: i8, + pub metadata: ArticleMetadata, } impl Article { @@ -51,6 +61,12 @@ impl Article { category: "testing".to_string(), locale: "fr".to_string(), display_priority_index: 1, + metadata: ArticleMetadata { + description: "A test article".to_string(), + view_uri: None, + static_resource_path: None, + slug: None, + }, } } } diff --git a/src/service/articles.rs b/src/service/articles.rs index c38caf10a5bffdc0701bf97478a3940792b70931..7fa896c12d463f908efb8706aa9b416c93eeba77 100644 --- a/src/service/articles.rs +++ b/src/service/articles.rs @@ -1,5 +1,7 @@ use crate::{ - middleware::AuthenticatedAdminMiddleware, model::Article, static_view::create_static_view, + middleware::AuthenticatedAdminMiddleware, + model::Article, + static_view::{create_static_view, delete_static_view}, AppState, }; use actix_web::{ @@ -9,6 +11,7 @@ use actix_web::{ }; use chrono::Utc; use futures::stream::StreamExt; +use regex::Regex; use serde::{Deserialize, Serialize}; use wither::{ bson::{doc, oid::ObjectId, Bson, DateTime}, @@ -25,6 +28,37 @@ fn get_collection(app_state: &AppState) -> Collection<Article> { app_state.db.collection_with_type::<Article>("articles") } +fn slugify(s: &String) -> String { + let s = s.to_lowercase(); + // Delete every character that is not a letter a number or a space + let re = Regex::new(r"[^a-z\d\s]").unwrap(); + let slug = re.replace_all(&s, ""); + + let slug = slug.trim().to_string(); + // Deduplicate whitespaces and replace them by hyhens + let re = Regex::new(r"[\s]+").unwrap(); + let slug = re.replace_all(&slug, "-"); + + slug.to_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), + ); +} + async fn generate_article_view(app_state: &AppState, id: &ObjectId) -> Result<(), String> { match Article::find_one(&app_state.db, doc! {"_id":&id}, None).await { Ok(art) => { @@ -33,6 +67,20 @@ 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); + } + + let slug = art.metadata.slug.unwrap(); + let html = format!( " <html lang='{}'> @@ -40,20 +88,30 @@ async fn generate_article_view(app_state: &AppState, id: &ObjectId) -> Result<() <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.subtitle, art.title, art.body, + art.locale, art_image_uri, art.metadata.description, art.title, art.body, ); - create_static_view(&app_state, art.category, art.title, html) + create_static_view(&app_state, art.category, slug, html) + } + Err(e) => { + return Err(format!( + "Database error searching for article with id {} : {}", + id, e + )) } - Err(e) => return Err(format!("ERR {}", e)), } } @@ -71,6 +129,8 @@ pub async fn post_article( let mut article_data = article_data.into_inner(); article_data.date = Some(DateTime(Utc::now())); + create_article_static_view_metadata(&app_state, &mut article_data); + match get_collection(&app_state) .insert_one(article_data, None) .await @@ -118,11 +178,27 @@ 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)); + }; + + create_article_static_view_metadata(&app_state, &mut article_data); + match get_collection(&app_state) .find_one_and_replace(doc! {"_id": &article_id}, article_data, None) .await { - Ok(res) => HttpResponse::Ok().json(res.unwrap()), + Ok(res) => match res { + Some(art) => { + if let Err(e) = generate_article_view(&app_state, &art.clone().id.unwrap()).await { + return HttpResponse::InternalServerError() + .body(format!("Error generating article static view {}", e)); + }; + HttpResponse::Ok().json(art) + } + None => HttpResponse::InternalServerError().finish(), + }, Err(_) => HttpResponse::InternalServerError().finish(), } } @@ -146,11 +222,23 @@ pub async fn delete_article( } }; - match get_collection(&app_state) - .find_one_and_delete(doc! {"_id": &article_id}, None) - .await - { - Ok(_) => HttpResponse::Accepted().body("Article was deleted"), + 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)); + }; + + match articles.delete_one(doc! {"_id": &article_id}, None).await { + Ok(_) => HttpResponse::Accepted().body("Article was deleted"), + Err(e) => HttpResponse::InternalServerError() + .body(format!("Error deleting article {}", e)), + } + } + None => HttpResponse::NotFound().body("Article was not found"), + }, Err(e) => HttpResponse::InternalServerError().body(&format!("{:?}", e)), } } diff --git a/src/static_view.rs b/src/static_view.rs index 75cc486764fb43c626cf301e81fd58c498ce3dc9..e2e32949e6b1aa312180a6433728987c9c575f64 100644 --- a/src/static_view.rs +++ b/src/static_view.rs @@ -1,6 +1,7 @@ use crate::app_state::AppState; -use std::fs::{create_dir, create_dir_all, File}; +use std::fs::{create_dir, create_dir_all, remove_dir, remove_file, File}; use std::io::Write; +use std::path::PathBuf; pub fn create_static_view( app_state: &AppState, @@ -38,3 +39,24 @@ pub fn create_static_view( Err(e) => Err(format!("Error creating {:?} : {}", f_path, e)), } } + +pub fn delete_static_view(path: &Option<PathBuf>) -> 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) { + return Err(format!( + "Error deleting static view directory at {:?} : {}", + parent, e + )); + } + } + } + + Ok(()) +}