use crate::{ controllers::{admin, auth as auth_controller, i18n::current_lang}, models::{ _entities::{blog_articles, site_pages}, users::{self, LoginParams}, }, }; use axum_extra::extract::cookie::CookieJar; use chrono::Utc; use loco_rs::prelude::*; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::Deserialize; use serde_json::json; use uuid::Uuid; const ABOUT_SLUG: &str = "about"; #[derive(Debug, Deserialize)] struct ArticleForm { title: String, content: String, excerpt: Option, published: Option, featured_image_id: Option, } #[derive(Debug, Deserialize)] struct AboutForm { title: String, content: String, } fn slugify(title: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in title.chars().flat_map(char::to_lowercase) { if ch.is_ascii_alphanumeric() { slug.push(ch); last_was_dash = false; } else if !last_was_dash && !slug.is_empty() { slug.push('-'); last_was_dash = true; } } let slug = slug.trim_matches('-').to_string(); if slug.is_empty() { Uuid::new_v4().to_string() } else { slug } } fn published_at_for(published: bool) -> Option> { published.then(|| Utc::now().into()) } fn is_checked(value: &Option) -> bool { value.as_deref().is_some_and(|value| value == "on" || value == "true") } fn normalize_empty(value: Option) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } async fn about_page(ctx: &AppContext) -> Result { site_pages::Entity::find() .filter(site_pages::Column::Slug.eq(ABOUT_SLUG)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result { blog_articles::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { return false; }; let Ok(jwt_config) = ctx.config.get_jwt_config() else { return false; }; let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) else { return false; }; let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { return false; }; admin::is_admin(ctx, &user) } #[debug_handler] async fn home( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let articles = blog_articles::Entity::find() .filter(blog_articles::Column::Published.eq(true)) .order_by_desc(blog_articles::Column::PublishedAt) .limit(5) .all(&ctx.db) .await?; format::view( &v, "home/index.html", json!({ "articles": articles, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn about( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { format::view( &v, "pages/about.html", json!({ "page": about_page(&ctx).await?, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn blog_index( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let articles = blog_articles::Entity::find() .filter(blog_articles::Column::Published.eq(true)) .order_by_desc(blog_articles::Column::PublishedAt) .all(&ctx.db) .await?; format::view( &v, "blog/index.html", json!({ "articles": articles, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn blog_show( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { let article = blog_articles::Entity::find() .filter(blog_articles::Column::Slug.eq(slug)) .filter(blog_articles::Column::Published.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let mut active = article.into_active_model(); let next_count = active.view_count.as_ref().to_owned() + 1; active.view_count = Set(next_count); let article = active.update(&ctx.db).await?; format::view( &v, "blog/show.html", json!({ "article": article, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn admin_login_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { if logged_in_admin(&ctx, &jar).await { return format::redirect("/admin/dashboard"); } format::view( &v, "admin/login.html", json!({ "error": null, "logged_in_admin": false, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn admin_login( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(params): Form, ) -> Result { let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { return format::view( &v, "admin/login.html", json!({ "error": "Invalid credentials", "logged_in_admin": false, "lang": current_lang(&jar), }), ); }; if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) { return format::view( &v, "admin/login.html", json!({ "error": "Invalid credentials", "logged_in_admin": false, "lang": current_lang(&jar), }), ); } let jwt_secret = ctx.config.get_jwt_config()?; let token = user .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::render() .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? .redirect("/admin/dashboard") } #[debug_handler] async fn admin_logout() -> Result { format::render() .cookies(&[auth_controller::clear_auth_cookie()])? .redirect("/admin/login") } #[debug_handler] async fn admin_home( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let admin_user = admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/index.html", json!({ "admin": admin_user, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_about( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/about.html", json!({ "page": about_page(&ctx).await?, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_about_update( auth: auth::JWT, State(ctx): State, Form(params): Form, ) -> Result { admin::current_admin(auth, &ctx).await?; let mut page = about_page(&ctx).await?.into_active_model(); page.title = Set(params.title); page.content = Set(params.content); page.update(&ctx.db).await?; format::redirect("/admin/about") } #[debug_handler] async fn admin_articles( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let articles = blog_articles::Entity::find() .order_by_desc(blog_articles::Column::CreatedAt) .all(&ctx.db) .await?; format::view( &v, "admin/blog/index.html", json!({ "articles": articles, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_article_new( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view(&v, "admin/blog/new.html", json!({ "lang": current_lang(&jar) })) } #[debug_handler] async fn admin_article_create( auth: auth::JWT, State(ctx): State, Form(params): Form, ) -> Result { let admin_user = admin::current_admin(auth, &ctx).await?; let published = is_checked(¶ms.published); blog_articles::ActiveModel { id: Set(Uuid::new_v4()), title: Set(params.title.clone()), slug: Set(slugify(¶ms.title)), content: Set(params.content), excerpt: Set(normalize_empty(params.excerpt)), published: Set(published), author_id: Set(admin_user.id), featured_image_id: Set(normalize_empty(params.featured_image_id)), view_count: Set(0), published_at: Set(published_at_for(published)), ..Default::default() } .insert(&ctx.db) .await?; format::redirect("/admin/blog/articles") } #[debug_handler] async fn admin_article_edit( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/blog/edit.html", json!({ "article": article_by_id(&ctx, id).await?, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_article_update( auth: auth::JWT, Path(id): Path, State(ctx): State, Form(params): Form, ) -> Result { admin::current_admin(auth, &ctx).await?; let existing = article_by_id(&ctx, id).await?; let was_published = existing.published; let published = is_checked(¶ms.published); let mut article = existing.into_active_model(); article.title = Set(params.title.clone()); article.slug = Set(slugify(¶ms.title)); article.content = Set(params.content); article.excerpt = Set(normalize_empty(params.excerpt)); article.published = Set(published); article.featured_image_id = Set(normalize_empty(params.featured_image_id)); if published && !was_published { article.published_at = Set(published_at_for(true)); } else if !published { article.published_at = Set(None); } article.update(&ctx.db).await?; format::redirect("/admin/blog/articles") } #[debug_handler] async fn admin_article_delete( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; article_by_id(&ctx, id).await?.delete(&ctx.db).await?; format::redirect("/admin/blog/articles") } pub fn routes() -> Routes { Routes::new() .add("/", get(home)) .add("/about", get(about)) .add("/blog", get(blog_index)) .add("/blog/{slug}", get(blog_show)) .add("/admin/login", get(admin_login_page)) .add("/admin/login", post(admin_login)) .add("/admin/logout", post(admin_logout)) .add("/admin", get(admin_login_page)) .add("/admin/dashboard", get(admin_home)) .add("/admin/about", get(admin_about)) .add("/admin/about", post(admin_about_update)) .add("/admin/blog/articles", get(admin_articles)) .add("/admin/blog/articles/new", get(admin_article_new)) .add("/admin/blog/articles", post(admin_article_create)) .add("/admin/blog/articles/{id}/edit", get(admin_article_edit)) .add("/admin/blog/articles/{id}", post(admin_article_update)) .add("/admin/blog/articles/{id}/delete", post(admin_article_delete)) }