diff --git a/Cargo.lock b/Cargo.lock index 22914e9..ddfaa57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,21 @@ dependencies = [ "syn 2.0.82", ] +[[package]] +name = "actix-web-prom" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56a34f1825c3ae06567a9d632466809bbf34963c86002e8921b64f32d48d289d" +dependencies = [ + "actix-web", + "futures-core", + "log", + "pin-project-lite", + "prometheus", + "regex", + "strfmt", +] + [[package]] name = "actix_derive" version = "0.6.2" @@ -1169,6 +1184,7 @@ dependencies = [ "actix-rt", "actix-session", "actix-web", + "actix-web-prom", "askama", "askama_actix", "chrono", @@ -3292,6 +3308,20 @@ dependencies = [ "yansi", ] +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "thiserror", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4650,6 +4680,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strfmt" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65" + [[package]] name = "stringprep" version = "0.1.5" diff --git a/cooked/Cargo.toml b/cooked/Cargo.toml index eebdb14..59ba1cd 100644 --- a/cooked/Cargo.toml +++ b/cooked/Cargo.toml @@ -38,6 +38,7 @@ futures-core = "0.3.31" futures-util = "0.3.31" encoding_rs = "0.8.35" itertools = "0.13.0" +actix-web-prom = "0.9.0" [build-dependencies] diff --git a/cooked/src/main.rs b/cooked/src/main.rs index 21c60bd..6242ad5 100644 --- a/cooked/src/main.rs +++ b/cooked/src/main.rs @@ -7,6 +7,7 @@ use actix_session::{storage::RedisSessionStore, SessionMiddleware}; use actix_web::cookie::Key; use actix_web::{error, HttpResponse}; use actix_web::{web::Data, App, HttpServer}; +use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder}; use sea_orm::Database; use serde_qs as qs; use serde_qs::actix::QsQueryConfig; @@ -116,12 +117,19 @@ async fn main() { Search(search_addr) }; + let prometheus = PrometheusMetricsBuilder::new("api") + .endpoint("/metrics") + .build() + .unwrap(); + // Transform to data let search = Data::new(search); let admins = Data::new(admins); let db = Data::new(db); let redis = Data::new(redis); + crate::routes::db_cleanup(db.clone()); + let qs_config = QsQueryConfig::default() .error_handler(|err, _req| { // <- create custom error response @@ -143,6 +151,7 @@ async fn main() { .cookie_name(SESSION_KEY.to_string()) .build(), ) + .wrap(prometheus.clone()) .app_data(qs_config.clone()) .app_data(admins.clone()) .app_data(db.clone()) diff --git a/cooked/src/routes.rs b/cooked/src/routes.rs index 01b09f6..254eae1 100644 --- a/cooked/src/routes.rs +++ b/cooked/src/routes.rs @@ -1210,45 +1210,137 @@ async fn styles_css() -> HttpResponse { .body(include_str!("../assets/styles.css")) } -async fn tmp_cleanup() { - let d = tokio::time::Duration::from_secs(10); - loop { - tokio::time::sleep(d).await; - let _ = std::fs::create_dir_all("/tmp/assets/recipies/images"); - // tracing::info!("Starting files cleanup"); +fn tmp_cleanup() { + actix_rt::spawn(async { + let d = tokio::time::Duration::from_secs(10); + loop { + tokio::time::sleep(d).await; + let _ = std::fs::create_dir_all("/tmp/assets/recipies/images"); - let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else { - tracing::info!("Files cleanup failed. No tmp images dir"); - continue; - }; - while let Ok(Some(entry)) = dir.next_entry().await { - let Ok(meta) = entry.metadata().await else { + let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else { + tracing::info!("Files cleanup failed. No tmp images dir"); continue; }; - let Ok(ts) = meta.modified() else { - continue; - }; - let valid_until = ts + std::time::Duration::from_days(1); - let now = std::time::SystemTime::now(); - if valid_until < now { - let _ = tokio::fs::remove_file(entry.path()).await; + while let Ok(Some(entry)) = dir.next_entry().await { + let Ok(meta) = entry.metadata().await else { + continue; + }; + let Ok(ts) = meta.modified() else { + continue; + }; + let valid_until = ts + std::time::Duration::from_days(1); + let now = std::time::SystemTime::now(); + if valid_until < now { + let _ = tokio::fs::remove_file(entry.path()).await; + } } + // tracing::info!("Files cleanup done"); } - // tracing::info!("Files cleanup done"); - } + }); +} + +pub fn db_cleanup(db: Data) { + actix_rt::spawn(async move { + let d = tokio::time::Duration::from_secs(10); + loop { + let ids = Ingredients::find() + .join( + JoinType::Join, + entities::ingredients::Relation::RecipeIngredients.def(), + ) + .join( + JoinType::Join, + entities::recipe_ingredients::Relation::Recipies.def(), + ) + .select_only() + .column(entities::ingredients::Column::Id) + .column(entities::ingredients::Column::Name) + .column_as( + entities::recipe_ingredients::Column::RecipeId.count(), + "recipe_count", + ) + .order_by_asc(entities::ingredients::Column::Name) + .group_by(entities::ingredients::Column::Id) + .into_model::() + .all(&**db) + .await + .inspect_err(|e| tracing::error!("Failed to load ingredients: {e}")) + .unwrap_or_default() + .into_iter() + .filter(|record| matches!(record.recipe_count, None | Some(0))) + .map(|record| record.id) + .collect::>(); + let _ = Ingredients::delete_many() + .filter(entities::ingredients::Column::Id.is_in(ids)) + .exec(&**db) + .await + .inspect_err(|e| { + tracing::error!("Failed clean up ingredients without recipies: {e}") + }); + + let ids = Tags::find() + .join(JoinType::Join, entities::tags::Relation::RecipeTags.def()) + .join( + JoinType::Join, + entities::recipe_tags::Relation::Recipies.def(), + ) + .select_only() + .column(entities::tags::Column::Id) + .column(entities::tags::Column::Name) + .column_as( + entities::recipe_tags::Column::RecipeId.count(), + "recipe_count", + ) + .order_by_asc(entities::tags::Column::Name) + .group_by(entities::tags::Column::Id) + .into_model::() + .all(&**db) + .await + .inspect_err(|e| tracing::error!("Failed to load ingredients: {e}")) + .unwrap_or_default() + .into_iter() + .filter(|record| matches!(record.recipe_count, None | Some(0))) + .map(|record| record.id) + .collect::>(); + let _ = Tags::delete_many() + .filter(entities::ingredients::Column::Id.is_in(ids)) + .exec(&**db) + .await + .inspect_err(|e| tracing::error!("Failed clean up tags without recipies: {e}")); + tokio::time::sleep(d).await; + } + }); +} + +#[get("/app-ads.txt")] +pub async fn no_app_ads() -> HttpResponse { + HttpResponse::Ok().body("") +} + +#[get("/ads.txt")] +pub async fn no_ads() -> HttpResponse { + HttpResponse::Ok().body("") +} +#[get("/sellers.json")] +pub async fn no_sellers() -> HttpResponse { + HttpResponse::Ok() + .append_header(("Content-Type", "application/json")) + .body("{}") +} +#[get("/health")] +pub async fn health() -> HttpResponse { + HttpResponse::Ok().finish() } pub fn configure(config: &mut actix_web::web::ServiceConfig) { - actix_rt::spawn(tmp_cleanup()); + tmp_cleanup(); + config .service(styles_css) .service(render_sign_in) .service(sign_in) - .service(index_html) - .service(show) .service(search_page) .service(search_results) - .service(recipe_image_update) .service(update_recipe) .service(edit_recipe) .service(recipe_form) @@ -1258,5 +1350,12 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) { .service(ingredients_list) .service(by_tag) .service(tags_list) + .service(recipe_image_update) + .service(show) + .service(index_html) + .service(no_app_ads) + .service(no_ads) + .service(no_sellers) + .service(health) .service(Files::new("/assets", "./assets")); } diff --git a/cooked/src/types.rs b/cooked/src/types.rs index e92e4d5..f08e574 100644 --- a/cooked/src/types.rs +++ b/cooked/src/types.rs @@ -8,7 +8,7 @@ pub type User = String; #[derive(Debug)] pub struct Ingredient { pub id: i32, - pub qty: i32, + pub qty: String, pub name: String, pub unit: String, } diff --git a/cooked/src/utils.rs b/cooked/src/utils.rs index 47f1a7d..cc072fd 100644 --- a/cooked/src/utils.rs +++ b/cooked/src/utils.rs @@ -28,7 +28,7 @@ pub fn parse_steps(s: &str) -> Result)>, Error> { }) } -pub fn parse_ingredients(s: &str) -> Result, i32)>, Error> { +pub fn parse_ingredients(s: &str) -> Result, String)>, Error> { s.lines() .map(|s| s.trim()) .filter(|s| !s.is_empty()) @@ -41,19 +41,13 @@ pub fn parse_ingredients(s: &str) -> Result, i32)>, 0 | 1 => return Err(InvalidIngeredientList), // no unit 2 => { - let qty = pieces - .remove(0) - .parse() - .map_err(|_| InvalidIngeredientList)?; - Ok((pieces.join(" "), None, qty)) + let qty = pieces.remove(0); + Ok((pieces.join(" "), None, qty.to_string())) } _ => { - let qty = pieces - .remove(0) - .parse() - .map_err(|_| InvalidIngeredientList)?; + let qty = pieces.remove(0); let unit = pieces.remove(0); - Ok((pieces.join(" "), Some(unit.to_string()), qty)) + Ok((pieces.join(" "), Some(unit.to_string()), qty.to_string())) } } }) diff --git a/entities/src/ingredients.rs b/entities/src/ingredients.rs index e1a35aa..30f5330 100644 --- a/entities/src/ingredients.rs +++ b/entities/src/ingredients.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: i32, + #[sea_orm(unique)] pub name: String, } diff --git a/entities/src/lib.rs b/entities/src/lib.rs index 85a3ddc..2aeecf5 100644 --- a/entities/src/lib.rs +++ b/entities/src/lib.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 pub mod prelude; diff --git a/entities/src/prelude.rs b/entities/src/prelude.rs index 89fe208..4a6dc2c 100644 --- a/entities/src/prelude.rs +++ b/entities/src/prelude.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 pub use super::ingredients::Entity as Ingredients; pub use super::recipe_ingredients::Entity as RecipeIngredients; diff --git a/entities/src/recipe_ingredients.rs b/entities/src/recipe_ingredients.rs index 69894a6..fa3eb90 100644 --- a/entities/src/recipe_ingredients.rs +++ b/entities/src/recipe_ingredients.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -9,7 +9,7 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub ingredient_id: i32, - pub qty: i32, + pub qty: String, pub unit: String, pub recipe_id: i32, } @@ -20,16 +20,16 @@ pub enum Relation { belongs_to = "super::ingredients::Entity", from = "Column::IngredientId", to = "super::ingredients::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" + on_update = "Cascade", + on_delete = "Cascade" )] Ingredients, #[sea_orm( belongs_to = "super::recipies::Entity", from = "Column::RecipeId", to = "super::recipies::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" + on_update = "Cascade", + on_delete = "Cascade" )] Recipies, } diff --git a/entities/src/recipe_steps.rs b/entities/src/recipe_steps.rs index 965872c..bdfc8b5 100644 --- a/entities/src/recipe_steps.rs +++ b/entities/src/recipe_steps.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -19,8 +19,8 @@ pub enum Relation { belongs_to = "super::recipies::Entity", from = "Column::RecipeId", to = "super::recipies::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" + on_update = "Cascade", + on_delete = "Cascade" )] Recipies, } diff --git a/entities/src/recipe_tags.rs b/entities/src/recipe_tags.rs index 37d4869..eb536b4 100644 --- a/entities/src/recipe_tags.rs +++ b/entities/src/recipe_tags.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -18,16 +18,16 @@ pub enum Relation { belongs_to = "super::recipies::Entity", from = "Column::RecipeId", to = "super::recipies::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" + on_update = "Cascade", + on_delete = "Cascade" )] Recipies, #[sea_orm( belongs_to = "super::tags::Entity", from = "Column::TagId", to = "super::tags::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" + on_update = "Cascade", + on_delete = "Cascade" )] Tags, } diff --git a/entities/src/recipies.rs b/entities/src/recipies.rs index 8b5aa1a..556bd72 100644 --- a/entities/src/recipies.rs +++ b/entities/src/recipies.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/entities/src/tags.rs b/entities/src/tags.rs index bb29716..8b63f0a 100644 --- a/entities/src/tags.rs +++ b/entities/src/tags.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: i32, + #[sea_orm(unique)] pub name: String, } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 2c605af..1e75471 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20220101_000001_create_table; +mod m20220101_000002_change_quantity_type; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20220101_000001_create_table::Migration)] + vec![ + Box::new(m20220101_000001_create_table::Migration), + Box::new(m20220101_000002_change_quantity_type::Migration), + ] } } diff --git a/migration/src/m20220101_000002_change_quantity_type.rs b/migration/src/m20220101_000002_change_quantity_type.rs new file mode 100644 index 0000000..7983791 --- /dev/null +++ b/migration/src/m20220101_000002_change_quantity_type.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(RecipeIngredient::RecipeIngredients) + .modify_column(ColumnDef::new(Alias::new("qty")).string().default("1")) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(RecipeIngredient::RecipeIngredients) + .modify_column(ColumnDef::new(Alias::new("qty")).integer().default(1)) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum RecipeIngredient { + RecipeIngredients, + Id, + IngredientId, + Qty, + Unit, + RecipeId, +}