about page
This commit is contained in:
@@ -53,6 +53,7 @@ impl Hooks for App {
|
|||||||
AppRoutes::with_default_routes() // controller routes below
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
.add_route(controllers::auth::routes())
|
.add_route(controllers::auth::routes())
|
||||||
.add_route(controllers::admin::routes())
|
.add_route(controllers::admin::routes())
|
||||||
|
.add_route(controllers::blog::routes())
|
||||||
}
|
}
|
||||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||||
queue.register(DownloadWorker::build(ctx)).await?;
|
queue.register(DownloadWorker::build(ctx)).await?;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct DashboardResponse {
|
|||||||
audit_logs: u64,
|
audit_logs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||||
ctx.config
|
ctx.config
|
||||||
.settings
|
.settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -23,11 +23,11 @@ fn admin_email(ctx: &AppContext) -> Option<&str> {
|
|||||||
.and_then(|email| email.as_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))
|
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||||
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||||
|
|
||||||
if !is_admin(ctx, &user) {
|
if !is_admin(ctx, &user) {
|
||||||
|
|||||||
236
src/controllers/blog.rs
Normal file
236
src/controllers/blog.rs
Normal file
@@ -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<String>,
|
||||||
|
published: Option<bool>,
|
||||||
|
featured_image_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ArticleResponse {
|
||||||
|
id: Uuid,
|
||||||
|
title: String,
|
||||||
|
slug: String,
|
||||||
|
content: String,
|
||||||
|
excerpt: Option<String>,
|
||||||
|
published: bool,
|
||||||
|
author_id: i32,
|
||||||
|
featured_image_id: Option<String>,
|
||||||
|
view_count: i32,
|
||||||
|
created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ArticleListResponse {
|
||||||
|
articles: Vec<ArticleResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<blog_articles::Model> 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<chrono::DateTime<chrono::FixedOffset>> {
|
||||||
|
published.then(|| Utc::now().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
||||||
|
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<AppContext>) -> Result<Response> {
|
||||||
|
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<String>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
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<AppContext>) -> Result<Response> {
|
||||||
|
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<AppContext>,
|
||||||
|
Json(params): Json<ArticleParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
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<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<ArticleParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
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<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
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<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
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<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod blog;
|
||||||
|
|||||||
Reference in New Issue
Block a user