initial commit of gitara site
This commit is contained in:
57
src/controllers/admin.rs
Normal file
57
src/controllers/admin.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::models::{
|
||||
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
|
||||
users as users_model,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
users: u64,
|
||||
blog_articles: u64,
|
||||
audio_albums: u64,
|
||||
audio_tracks: u64,
|
||||
audit_logs: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
current_admin(auth, &ctx).await?;
|
||||
|
||||
format::json(DashboardResponse {
|
||||
users: users::Entity::find().count(&ctx.db).await?,
|
||||
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
|
||||
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
|
||||
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
|
||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/dashboard", get(dashboard))
|
||||
}
|
||||
316
src/controllers/auth.rs
Normal file
316
src/controllers/auth.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
use time::Duration as TimeDuration;
|
||||
|
||||
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
pub(crate) const AUTH_COOKIE: &str = "auth_token";
|
||||
|
||||
fn get_allow_email_domain_re() -> &'static Regex {
|
||||
EMAIL_DOMAIN_RE.get_or_init(|| {
|
||||
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
||||
Cookie::build((AUTH_COOKIE, token.to_string()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(max_age_seconds as i64))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
|
||||
Cookie::build((AUTH_COOKIE, ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ForgotParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResetParams {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MagicLinkParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResendVerificationParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Register function creates a new user with the given parameters and sends a
|
||||
/// welcome email to the user
|
||||
#[debug_handler]
|
||||
async fn register(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<RegisterParams>,
|
||||
) -> Result<Response> {
|
||||
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.email,
|
||||
"could not register user",
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Verify register user. if the user not verified his email, he can't login to
|
||||
/// the system.
|
||||
#[debug_handler]
|
||||
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
|
||||
return unauthorized("invalid token");
|
||||
};
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
||||
} else {
|
||||
let active_model = user.into_active_model();
|
||||
let user = active_model.verified(&ctx.db).await?;
|
||||
tracing::info!(pid = user.pid.to_string(), "user verified");
|
||||
}
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// In case the user forgot his password this endpoints generate a forgot token
|
||||
/// and send email to the user. In case the email not found in our DB, we are
|
||||
/// returning a valid request for for security reasons (not exposing users DB
|
||||
/// list).
|
||||
#[debug_handler]
|
||||
async fn forgot(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ForgotParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// reset user password by the given parameters
|
||||
#[debug_handler]
|
||||
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::info!("reset token not found");
|
||||
|
||||
return format::json(());
|
||||
};
|
||||
user.into_active_model()
|
||||
.reset_password(&ctx.db, ¶ms.password)
|
||||
.await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Creates a user login and returns a token
|
||||
#[debug_handler]
|
||||
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
tracing::debug!(
|
||||
email = params.email,
|
||||
"login attempt with non-existent email"
|
||||
);
|
||||
return unauthorized("Invalid credentials!");
|
||||
};
|
||||
|
||||
let valid = user.verify_password(¶ms.password);
|
||||
|
||||
if !valid {
|
||||
return unauthorized("unauthorized!");
|
||||
}
|
||||
|
||||
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_cookie(&token, jwt_secret.expiration)])?
|
||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
format::json(CurrentResponse::new(&user, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn logout() -> Result<Response> {
|
||||
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
||||
}
|
||||
|
||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||
///
|
||||
/// # Flow
|
||||
/// 1. **Request a Magic Link**:
|
||||
/// A registered user sends a POST request to `/magic-link` with their email.
|
||||
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
|
||||
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
|
||||
///
|
||||
/// 2. **Click the Magic Link**:
|
||||
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
|
||||
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
|
||||
/// If invalid or expired, an unauthorized response is returned.
|
||||
///
|
||||
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
|
||||
async fn magic_link(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MagicLinkParams>,
|
||||
) -> Result<Response> {
|
||||
let email_regex = get_allow_email_domain_re();
|
||||
if !email_regex.is_match(¶ms.email) {
|
||||
tracing::debug!(
|
||||
email = params.email,
|
||||
"The provided email is invalid or does not match the allowed domains"
|
||||
);
|
||||
return bad_request("invalid request");
|
||||
}
|
||||
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::debug!(email = params.email, "user not found by email");
|
||||
return format::empty_json();
|
||||
};
|
||||
|
||||
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
|
||||
AuthMailer::send_magic_link(&ctx, &user).await?;
|
||||
|
||||
format::empty_json()
|
||||
}
|
||||
|
||||
/// Verifies a magic link token and authenticates the user.
|
||||
async fn magic_link_verify(
|
||||
Path(token): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return unauthorized("unauthorized!");
|
||||
};
|
||||
|
||||
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
|
||||
|
||||
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_cookie(&token, jwt_secret.expiration)])?
|
||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn resend_verification_email(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ResendVerificationParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
tracing::info!(
|
||||
email = params.email,
|
||||
"User not found for resend verification"
|
||||
);
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
tracing::info!(
|
||||
pid = user.pid.to_string(),
|
||||
"User already verified, skipping resend"
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/auth")
|
||||
.add("/register", post(register))
|
||||
.add("/verify/{token}", get(verify))
|
||||
.add("/login", post(login))
|
||||
.add("/logout", post(logout))
|
||||
.add("/forgot", post(forgot))
|
||||
.add("/reset", post(reset))
|
||||
.add("/current", get(current))
|
||||
.add("/magic-link", post(magic_link))
|
||||
.add("/magic-link/{token}", get(magic_link_verify))
|
||||
.add("/resend-verification-mail", post(resend_verification_email))
|
||||
}
|
||||
245
src/controllers/blog.rs
Normal file
245
src/controllers/blog.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
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))
|
||||
}
|
||||
479
src/controllers/frontend.rs
Normal file
479
src/controllers/frontend.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
use crate::{
|
||||
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
||||
models::{
|
||||
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
|
||||
users::{self, LoginParams},
|
||||
},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use chrono::Utc;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, 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?;
|
||||
|
||||
// A random published song to suggest on the landing page.
|
||||
let featured_track = audio_tracks::Entity::find()
|
||||
.filter(audio_tracks::Column::Published.eq(true))
|
||||
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
// A random published album, never the one the suggested song belongs to.
|
||||
let mut album_query =
|
||||
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
|
||||
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
|
||||
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
|
||||
}
|
||||
let featured_album = album_query
|
||||
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"home/index.html",
|
||||
json!({
|
||||
"articles": articles,
|
||||
"featured_track": featured_track,
|
||||
"featured_album": featured_album,
|
||||
"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),
|
||||
)
|
||||
}
|
||||
63
src/controllers/i18n.rs
Normal file
63
src/controllers/i18n.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use axum::{
|
||||
http::{header, HeaderMap},
|
||||
response::Redirect,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const LANG_COOKIE: &str = "lang";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LangForm {
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
pub fn current_lang(jar: &axum_extra::extract::cookie::CookieJar) -> String {
|
||||
match jar
|
||||
.get(LANG_COOKIE)
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
{
|
||||
Some(ref lang) if lang == "en" => "en".to_string(),
|
||||
_ => "sk".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Response> {
|
||||
let lang = if form.lang == "en" { "en" } else { "sk" };
|
||||
let cookie = format!("{LANG_COOKIE}={lang}; Path=/; Max-Age=31536000; SameSite=Lax");
|
||||
|
||||
Ok((
|
||||
[(header::SET_COOKIE, cookie)],
|
||||
Redirect::to(&back_path(&headers)),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn back_path(headers: &HeaderMap) -> String {
|
||||
let raw = headers
|
||||
.get(header::REFERER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("/");
|
||||
|
||||
if raw.starts_with('/') {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
if let Some(after_scheme) = raw.split_once("://").map(|(_, rest)| rest) {
|
||||
if let Some(path_start) = after_scheme.find('/') {
|
||||
let path = &after_scheme[path_start..];
|
||||
return if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
"/".to_string()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
"/".to_string()
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/lang", post(set_lang))
|
||||
}
|
||||
1141
src/controllers/media.rs
Normal file
1141
src/controllers/media.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
src/controllers/mod.rs
Normal file
7
src/controllers/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod blog;
|
||||
pub mod frontend;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod pages;
|
||||
86
src/controllers/pages.rs
Normal file
86
src/controllers/pages.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::{controllers::admin, models::_entities::site_pages};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
const ABOUT_SLUG: &str = "about";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AboutParams {
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PageResponse {
|
||||
id: Uuid,
|
||||
slug: String,
|
||||
title: String,
|
||||
content: String,
|
||||
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
}
|
||||
|
||||
impl From<site_pages::Model> for PageResponse {
|
||||
fn from(page: site_pages::Model) -> Self {
|
||||
Self {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
updated_at: page.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_about(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)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(PageResponse::from(find_about(&ctx).await?))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update_about(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<AboutParams>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
|
||||
let page = match find_about(&ctx).await {
|
||||
Ok(page) => {
|
||||
let mut page = page.into_active_model();
|
||||
page.title = Set(params.title);
|
||||
page.content = Set(params.content);
|
||||
page.update(&ctx.db).await?
|
||||
}
|
||||
Err(Error::NotFound) => {
|
||||
site_pages::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
slug: Set(ABOUT_SLUG.to_string()),
|
||||
title: Set(params.title),
|
||||
content: Set(params.content),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
format::json(PageResponse::from(page))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api")
|
||||
.add("/about", get(about))
|
||||
.add("/admin/about", put(update_about))
|
||||
}
|
||||
Reference in New Issue
Block a user