about page

This commit is contained in:
Priec
2026-05-17 15:03:44 +02:00
parent 0bfd2f8674
commit 7176e01e8c
4 changed files with 241 additions and 3 deletions

View File

@@ -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?;

View File

@@ -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<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?;
if !is_admin(ctx, &user) {

236
src/controllers/blog.rs Normal file
View 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(&params.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(&params.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))
}

View File

@@ -1,2 +1,3 @@
pub mod admin;
pub mod auth;
pub mod blog;