diff --git a/src/app.rs b/src/app.rs index 17cb446..d03d951 100644 --- a/src/app.rs +++ b/src/app.rs @@ -53,6 +53,7 @@ impl Hooks for App { AppRoutes::with_default_routes() // controller routes below .add_route(controllers::auth::routes()) .add_route(controllers::admin::routes()) + .add_route(controllers::blog::routes()) } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; diff --git a/src/controllers/admin.rs b/src/controllers/admin.rs index ad9d940..51d8516 100644 --- a/src/controllers/admin.rs +++ b/src/controllers/admin.rs @@ -15,7 +15,7 @@ struct DashboardResponse { audit_logs: u64, } -fn admin_email(ctx: &AppContext) -> Option<&str> { +pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> { ctx.config .settings .as_ref() @@ -23,11 +23,11 @@ fn admin_email(ctx: &AppContext) -> Option<&str> { .and_then(|email| email.as_str()) } -fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { +pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) } -async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result { +pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result { let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; if !is_admin(ctx, &user) { diff --git a/src/controllers/blog.rs b/src/controllers/blog.rs new file mode 100644 index 0000000..4be4115 --- /dev/null +++ b/src/controllers/blog.rs @@ -0,0 +1,236 @@ +use crate::{ + controllers::admin, + models::_entities::blog_articles, +}; +use chrono::Utc; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct ArticleParams { + title: String, + content: String, + excerpt: Option, + published: Option, + featured_image_id: Option, +} + +#[derive(Debug, Serialize)] +struct ArticleResponse { + id: Uuid, + title: String, + slug: String, + content: String, + excerpt: Option, + published: bool, + author_id: i32, + featured_image_id: Option, + view_count: i32, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + published_at: Option>, +} + +#[derive(Debug, Serialize)] +struct ArticleListResponse { + articles: Vec, +} + +impl From for ArticleResponse { + fn from(article: blog_articles::Model) -> Self { + Self { + id: article.id, + title: article.title, + slug: article.slug, + content: article.content, + excerpt: article.excerpt, + published: article.published, + author_id: article.author_id, + featured_image_id: article.featured_image_id, + view_count: article.view_count, + created_at: article.created_at, + updated_at: article.updated_at, + published_at: article.published_at, + } + } +} + +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()) +} + +async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result { + blog_articles::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +async fn public_index(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? + .into_iter() + .map(ArticleResponse::from) + .collect(); + + format::json(ArticleListResponse { articles }) +} + +#[debug_handler] +async fn public_show(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::json(ArticleResponse::from(article)) +} + +#[debug_handler] +async fn admin_index(auth: auth::JWT, 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? + .into_iter() + .map(ArticleResponse::from) + .collect(); + + format::json(ArticleListResponse { articles }) +} + +#[debug_handler] +async fn admin_create( + auth: auth::JWT, + State(ctx): State, + Json(params): Json, +) -> Result { + let admin_user = admin::current_admin(auth, &ctx).await?; + let published = params.published.unwrap_or(false); + + let article = blog_articles::ActiveModel { + id: Set(Uuid::new_v4()), + title: Set(params.title.clone()), + slug: Set(slugify(¶ms.title)), + content: Set(params.content), + excerpt: Set(params.excerpt), + published: Set(published), + author_id: Set(admin_user.id), + featured_image_id: Set(params.featured_image_id), + view_count: Set(0), + published_at: Set(published_at_for(published)), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + format::json(ArticleResponse::from(article)) +} + +#[debug_handler] +async fn admin_update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + admin::current_admin(auth, &ctx).await?; + + let existing = find_article_by_id(&ctx, id).await?; + let was_published = existing.published; + let published = params.published.unwrap_or(was_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(params.excerpt); + article.published = Set(published); + article.featured_image_id = Set(params.featured_image_id); + if published && !was_published { + article.published_at = Set(published_at_for(true)); + } else if !published { + article.published_at = Set(None); + } + + let article = article.update(&ctx.db).await?; + format::json(ArticleResponse::from(article)) +} + +#[debug_handler] +async fn admin_delete(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { + admin::current_admin(auth, &ctx).await?; + let article = find_article_by_id(&ctx, id).await?; + article.delete(&ctx.db).await?; + format::json(()) +} + +#[debug_handler] +async fn admin_publish(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { + admin::current_admin(auth, &ctx).await?; + let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); + article.published = Set(true); + article.published_at = Set(published_at_for(true)); + let article = article.update(&ctx.db).await?; + format::json(ArticleResponse::from(article)) +} + +#[debug_handler] +async fn admin_unpublish(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { + admin::current_admin(auth, &ctx).await?; + let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); + article.published = Set(false); + article.published_at = Set(None); + let article = article.update(&ctx.db).await?; + format::json(ArticleResponse::from(article)) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api") + .add("/blog", get(public_index)) + .add("/blog/{slug}", get(public_show)) + .add("/admin/blog/articles", get(admin_index)) + .add("/admin/blog/articles", post(admin_create)) + .add("/admin/blog/articles/{id}", put(admin_update)) + .add("/admin/blog/articles/{id}", delete(admin_delete)) + .add("/admin/blog/articles/{id}/publish", post(admin_publish)) + .add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 52815c1..5d28ec9 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,2 +1,3 @@ pub mod admin; pub mod auth; +pub mod blog;