Files
universal_web_loco_rewrite/src/controllers/frontend.rs
2026-05-19 20:03:20 +02:00

450 lines
12 KiB
Rust

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<String>,
published: Option<String>,
featured_image_id: Option<String>,
}
#[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<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
fn is_checked(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
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::Model> {
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::Model> {
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<TeraView>,
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)
.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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<TeraView>,
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?;
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<TeraView>,
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::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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.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(&params.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<Response> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<AppContext>,
Form(params): Form<AboutForm>,
) -> Result<Response> {
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<TeraView>,
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?;
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = is_checked(&params.published);
blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.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<TeraView>,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<Uuid>,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = is_checked(&params.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(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<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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))
}