450 lines
12 KiB
Rust
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, ¶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<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(¶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<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(¶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<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))
|
|
}
|