chore: reorganize project into monorepo
This commit is contained in:
1231
backend/src/controllers/admin.rs
Normal file
1231
backend/src/controllers/admin.rs
Normal file
File diff suppressed because it is too large
Load Diff
273
backend/src/controllers/auth.rs
Normal file
273
backend/src/controllers/auth.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
#[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::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
|
||||
/// 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::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[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("/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))
|
||||
}
|
||||
166
backend/src/controllers/category.rs
Normal file
166
backend/src/controllers/category.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::{categories, posts};
|
||||
use crate::services::content;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CategorySummary {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub name: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in value.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalized_name(params: &Params) -> Result<String> {
|
||||
let name = params
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn normalized_slug(params: &Params, fallback: &str) -> String {
|
||||
params
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| slugify(fallback))
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
let item = categories::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
let category_items = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Slug)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let post_items = posts::Entity::find().all(&ctx.db).await?;
|
||||
|
||||
let categories = category_items
|
||||
.into_iter()
|
||||
.map(|category| {
|
||||
let name = category
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| category.slug.clone());
|
||||
let count = post_items
|
||||
.iter()
|
||||
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
|
||||
.count();
|
||||
|
||||
CategorySummary {
|
||||
id: category.id,
|
||||
name,
|
||||
slug: category.slug,
|
||||
count,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(categories)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||
let name = normalized_name(¶ms)?;
|
||||
let slug = normalized_slug(¶ms, &name);
|
||||
|
||||
let existing = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let item = if let Some(existing_category) = existing {
|
||||
let mut model = existing_category.into_active_model();
|
||||
model.name = Set(Some(name));
|
||||
model.slug = Set(slug);
|
||||
model.update(&ctx.db).await?
|
||||
} else {
|
||||
categories::ActiveModel {
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let name = normalized_name(¶ms)?;
|
||||
let slug = normalized_slug(¶ms, &name);
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
item.name = Set(Some(name));
|
||||
item.slug = Set(slug);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/categories")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("/{id}", get(get_one))
|
||||
.add("/{id}", delete(remove))
|
||||
.add("/{id}", put(update))
|
||||
.add("/{id}", patch(update))
|
||||
}
|
||||
193
backend/src/controllers/comment.rs
Normal file
193
backend/src/controllers/comment.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::{
|
||||
comments::{ActiveModel, Column, Entity, Model},
|
||||
posts,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub post_id: Option<Uuid>,
|
||||
pub post_slug: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn update(&self, item: &mut ActiveModel) {
|
||||
if let Some(post_id) = self.post_id {
|
||||
item.post_id = Set(Some(post_id));
|
||||
}
|
||||
if let Some(post_slug) = &self.post_slug {
|
||||
item.post_slug = Set(Some(post_slug.clone()));
|
||||
}
|
||||
if let Some(author) = &self.author {
|
||||
item.author = Set(Some(author.clone()));
|
||||
}
|
||||
if let Some(email) = &self.email {
|
||||
item.email = Set(Some(email.clone()));
|
||||
}
|
||||
if let Some(avatar) = &self.avatar {
|
||||
item.avatar = Set(Some(avatar.clone()));
|
||||
}
|
||||
if let Some(content) = &self.content {
|
||||
item.content = Set(Some(content.clone()));
|
||||
}
|
||||
if let Some(reply_to) = self.reply_to {
|
||||
item.reply_to = Set(Some(reply_to));
|
||||
}
|
||||
if let Some(approved) = self.approved {
|
||||
item.approved = Set(Some(approved));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub post_id: Option<String>,
|
||||
pub post_slug: Option<String>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct CreateCommentRequest {
|
||||
#[serde(default, alias = "postId")]
|
||||
pub post_id: Option<String>,
|
||||
#[serde(default, alias = "postSlug")]
|
||||
pub post_slug: Option<String>,
|
||||
#[serde(default, alias = "nickname")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default, alias = "replyTo")]
|
||||
pub reply_to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result<Option<String>> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Ok(id) = trimmed.parse::<i32>() {
|
||||
let post = posts::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
return Ok(post.map(|item| item.slug));
|
||||
}
|
||||
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let mut db_query = Entity::find().order_by_asc(Column::CreatedAt);
|
||||
|
||||
let post_slug = if let Some(post_slug) = query.post_slug {
|
||||
Some(post_slug)
|
||||
} else if let Some(post_id) = query.post_id {
|
||||
resolve_post_slug(&ctx, &post_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(post_slug) = post_slug {
|
||||
db_query = db_query.filter(Column::PostSlug.eq(post_slug));
|
||||
}
|
||||
|
||||
if let Some(approved) = query.approved {
|
||||
db_query = db_query.filter(Column::Approved.eq(approved));
|
||||
}
|
||||
|
||||
format::json(db_query.all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<CreateCommentRequest>,
|
||||
) -> Result<Response> {
|
||||
let post_slug = if let Some(post_slug) = params.post_slug.as_deref() {
|
||||
Some(post_slug.to_string())
|
||||
} else if let Some(post_id) = params.post_id.as_deref() {
|
||||
resolve_post_slug(&ctx, post_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
item.post_id = Set(params
|
||||
.post_id
|
||||
.as_deref()
|
||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||
item.post_slug = Set(post_slug);
|
||||
item.author = Set(params.author);
|
||||
item.email = Set(params.email);
|
||||
item.avatar = Set(params.avatar);
|
||||
item.content = Set(params.content);
|
||||
item.reply_to = Set(params
|
||||
.reply_to
|
||||
.as_deref()
|
||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/comments/")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
.add("{id}", put(update))
|
||||
.add("{id}", patch(update))
|
||||
}
|
||||
137
backend/src/controllers/friend_link.rs
Normal file
137
backend/src/controllers/friend_link.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub site_name: Option<String>,
|
||||
pub site_url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn update(&self, item: &mut ActiveModel) {
|
||||
item.site_url = Set(self.site_url.clone());
|
||||
if let Some(site_name) = &self.site_name {
|
||||
item.site_name = Set(Some(site_name.clone()));
|
||||
}
|
||||
if let Some(avatar_url) = &self.avatar_url {
|
||||
item.avatar_url = Set(Some(avatar_url.clone()));
|
||||
}
|
||||
if let Some(description) = &self.description {
|
||||
item.description = Set(Some(description.clone()));
|
||||
}
|
||||
if let Some(category) = &self.category {
|
||||
item.category = Set(Some(category.clone()));
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
item.status = Set(Some(status.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub status: Option<String>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct CreateFriendLinkRequest {
|
||||
#[serde(default, alias = "siteName")]
|
||||
pub site_name: Option<String>,
|
||||
#[serde(alias = "siteUrl")]
|
||||
pub site_url: String,
|
||||
#[serde(default, alias = "avatarUrl")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let mut db_query = Entity::find().order_by_desc(Column::CreatedAt);
|
||||
|
||||
if let Some(status) = query.status {
|
||||
db_query = db_query.filter(Column::Status.eq(status));
|
||||
}
|
||||
|
||||
if let Some(category) = query.category {
|
||||
db_query = db_query.filter(Column::Category.eq(category));
|
||||
}
|
||||
|
||||
format::json(db_query.all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<CreateFriendLinkRequest>,
|
||||
) -> Result<Response> {
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
item.site_name = Set(params.site_name);
|
||||
item.site_url = Set(params.site_url);
|
||||
item.avatar_url = Set(params.avatar_url);
|
||||
item.description = Set(params.description);
|
||||
item.category = Set(params.category);
|
||||
item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string())));
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/friend_links/")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
.add("{id}", put(update))
|
||||
.add("{id}", patch(update))
|
||||
}
|
||||
10
backend/src/controllers/mod.rs
Normal file
10
backend/src/controllers/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod category;
|
||||
pub mod comment;
|
||||
pub mod friend_link;
|
||||
pub mod post;
|
||||
pub mod review;
|
||||
pub mod search;
|
||||
pub mod site_settings;
|
||||
pub mod tag;
|
||||
263
backend/src/controllers/post.rs
Normal file
263
backend/src/controllers/post.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model};
|
||||
use crate::services::content;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub title: Option<String>,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn update(&self, item: &mut ActiveModel) {
|
||||
item.title = Set(self.title.clone());
|
||||
item.slug = Set(self.slug.clone());
|
||||
item.description = Set(self.description.clone());
|
||||
item.content = Set(self.content.clone());
|
||||
item.category = Set(self.category.clone());
|
||||
item.tags = Set(self.tags.clone());
|
||||
item.post_type = Set(self.post_type.clone());
|
||||
item.image = Set(self.image.clone());
|
||||
item.pinned = Set(self.pinned);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub slug: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub search: Option<String>,
|
||||
#[serde(alias = "type")]
|
||||
pub post_type: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MarkdownUpdateParams {
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MarkdownDocumentResponse {
|
||||
pub slug: String,
|
||||
pub path: String,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
|
||||
let item = Entity::find()
|
||||
.filter(Column::Slug.eq(slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
|
||||
let wanted = wanted_tag.trim().to_lowercase();
|
||||
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
|
||||
let normalized = tag.trim().to_lowercase();
|
||||
normalized == wanted
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
let posts = Entity::find()
|
||||
.order_by_desc(Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let filtered: Vec<Model> = posts
|
||||
.into_iter()
|
||||
.filter(|post| {
|
||||
if let Some(slug) = &query.slug {
|
||||
if post.slug != *slug {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(category) = &query.category {
|
||||
if post
|
||||
.category
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(category))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(post_type) = &query.post_type {
|
||||
if post
|
||||
.post_type
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(post_type))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pinned) = query.pinned {
|
||||
if post.pinned.unwrap_or(false) != pinned {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tag) = &query.tag {
|
||||
if !post_has_tag(post, tag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(search) = &query.search {
|
||||
let wanted = search.trim().to_lowercase();
|
||||
let haystack = [
|
||||
post.title.as_deref().unwrap_or_default(),
|
||||
post.description.as_deref().unwrap_or_default(),
|
||||
post.content.as_deref().unwrap_or_default(),
|
||||
post.category.as_deref().unwrap_or_default(),
|
||||
&post.slug,
|
||||
]
|
||||
.join("\n")
|
||||
.to_lowercase();
|
||||
|
||||
if !haystack.contains(&wanted)
|
||||
&& !post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(|tag| tag.as_str())
|
||||
.any(|tag| tag.to_lowercase().contains(&wanted))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
format::json(filtered)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
params.update(&mut item);
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(load_item_by_slug(&ctx, &slug).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_markdown_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
let (path, markdown) = content::read_markdown_document(&slug)?;
|
||||
format::json(MarkdownDocumentResponse { slug, path, markdown })
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_markdown_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MarkdownUpdateParams>,
|
||||
) -> Result<Response> {
|
||||
let updated = content::write_markdown_document(&ctx, &slug, ¶ms.markdown).await?;
|
||||
let (path, markdown) = content::read_markdown_document(&updated.slug)?;
|
||||
|
||||
format::json(MarkdownDocumentResponse {
|
||||
slug: updated.slug,
|
||||
path,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/posts/")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("slug/{slug}/markdown", get(get_markdown_by_slug))
|
||||
.add("slug/{slug}/markdown", put(update_markdown_by_slug))
|
||||
.add("slug/{slug}/markdown", patch(update_markdown_by_slug))
|
||||
.add("slug/{slug}", get(get_by_slug))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
.add("{id}", put(update))
|
||||
.add("{id}", patch(update))
|
||||
}
|
||||
133
backend/src/controllers/review.rs
Normal file
133
backend/src/controllers/review.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use axum::extract::{Path, State};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::reviews::{self, Entity as ReviewEntity};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CreateReviewRequest {
|
||||
pub title: String,
|
||||
pub review_type: String,
|
||||
pub rating: i32,
|
||||
pub review_date: String,
|
||||
pub status: String,
|
||||
pub description: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UpdateReviewRequest {
|
||||
pub title: Option<String>,
|
||||
pub review_type: Option<String>,
|
||||
pub rating: Option<i32>,
|
||||
pub review_date: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub cover: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
|
||||
let reviews = ReviewEntity::find()
|
||||
.order_by_desc(reviews::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::json(reviews)
|
||||
}
|
||||
|
||||
pub async fn get_one(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||
|
||||
match review {
|
||||
Some(r) => format::json(r),
|
||||
None => Err(Error::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(req): Json<CreateReviewRequest>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let new_review = reviews::ActiveModel {
|
||||
title: Set(Some(req.title)),
|
||||
review_type: Set(Some(req.review_type)),
|
||||
rating: Set(Some(req.rating)),
|
||||
review_date: Set(Some(req.review_date)),
|
||||
status: Set(Some(req.status)),
|
||||
description: Set(Some(req.description)),
|
||||
tags: Set(Some(serde_json::to_string(&req.tags).unwrap_or_default())),
|
||||
cover: Set(Some(req.cover)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let review = new_review.insert(&ctx.db).await?;
|
||||
format::json(review)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(req): Json<UpdateReviewRequest>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||
|
||||
let Some(mut review) = review.map(|r| r.into_active_model()) else {
|
||||
return Err(Error::NotFound);
|
||||
};
|
||||
|
||||
if let Some(title) = req.title {
|
||||
review.title = Set(Some(title));
|
||||
}
|
||||
if let Some(review_type) = req.review_type {
|
||||
review.review_type = Set(Some(review_type));
|
||||
}
|
||||
if let Some(rating) = req.rating {
|
||||
review.rating = Set(Some(rating));
|
||||
}
|
||||
if let Some(review_date) = req.review_date {
|
||||
review.review_date = Set(Some(review_date));
|
||||
}
|
||||
if let Some(status) = req.status {
|
||||
review.status = Set(Some(status));
|
||||
}
|
||||
if let Some(description) = req.description {
|
||||
review.description = Set(Some(description));
|
||||
}
|
||||
if let Some(tags) = req.tags {
|
||||
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
|
||||
}
|
||||
if let Some(cover) = req.cover {
|
||||
review.cover = Set(Some(cover));
|
||||
}
|
||||
|
||||
let review = review.update(&ctx.db).await?;
|
||||
format::json(review)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||
|
||||
match review {
|
||||
Some(r) => {
|
||||
r.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
None => Err(Error::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/reviews")
|
||||
.add("/", get(list).post(create))
|
||||
.add("/{id}", get(get_one).put(update).delete(remove))
|
||||
}
|
||||
190
backend/src/controllers/search.rs
Normal file
190
backend/src/controllers/search.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::_entities::posts;
|
||||
use crate::services::content;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, FromQueryResult)]
|
||||
pub struct SearchResult {
|
||||
pub id: i32,
|
||||
pub title: Option<String>,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<Value>,
|
||||
pub post_type: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub rank: f64,
|
||||
}
|
||||
|
||||
fn search_sql() -> &'static str {
|
||||
r#"
|
||||
SELECT
|
||||
p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.content,
|
||||
p.category,
|
||||
p.tags,
|
||||
p.post_type,
|
||||
p.pinned,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
ts_rank_cd(
|
||||
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'),
|
||||
plainto_tsquery('simple', $1)
|
||||
)::float8 AS rank
|
||||
FROM posts p
|
||||
WHERE (
|
||||
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D')
|
||||
) @@ plainto_tsquery('simple', $1)
|
||||
ORDER BY rank DESC, p.created_at DESC
|
||||
LIMIT $2
|
||||
"#
|
||||
}
|
||||
|
||||
fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 {
|
||||
let wanted_lower = wanted.to_lowercase();
|
||||
let mut rank = 0.0;
|
||||
|
||||
if post
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 4.0;
|
||||
}
|
||||
|
||||
if post
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 2.5;
|
||||
}
|
||||
|
||||
if post
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 1.0;
|
||||
}
|
||||
|
||||
if post
|
||||
.category
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 1.5;
|
||||
}
|
||||
|
||||
if post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag.to_lowercase().contains(&wanted_lower))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
rank += 2.0;
|
||||
}
|
||||
|
||||
rank
|
||||
}
|
||||
|
||||
async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<SearchResult>> {
|
||||
let mut results = posts::Entity::find().all(&ctx.db).await?;
|
||||
results.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|post| {
|
||||
let rank = app_level_rank(&post, q);
|
||||
(post, rank)
|
||||
})
|
||||
.filter(|(_, rank)| *rank > 0.0)
|
||||
.take(limit as usize)
|
||||
.map(|(post, rank)| SearchResult {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
post_type: post.post_type,
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
rank,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
if q.is_empty() {
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres {
|
||||
let statement = Statement::from_sql_and_values(
|
||||
DbBackend::Postgres,
|
||||
search_sql(),
|
||||
[q.clone().into(), (limit as i64).into()],
|
||||
);
|
||||
|
||||
match SearchResult::find_by_statement(statement).all(&ctx.db).await {
|
||||
Ok(rows) => rows,
|
||||
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
}
|
||||
} else {
|
||||
fallback_search(&ctx, &q, limit).await?
|
||||
};
|
||||
|
||||
format::json(results)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().prefix("api/search/").add("/", get(search))
|
||||
}
|
||||
179
backend/src/controllers/site_settings.rs
Normal file
179
backend/src/controllers/site_settings.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct SiteSettingsPayload {
|
||||
#[serde(default, alias = "siteName")]
|
||||
pub site_name: Option<String>,
|
||||
#[serde(default, alias = "siteShortName")]
|
||||
pub site_short_name: Option<String>,
|
||||
#[serde(default, alias = "siteUrl")]
|
||||
pub site_url: Option<String>,
|
||||
#[serde(default, alias = "siteTitle")]
|
||||
pub site_title: Option<String>,
|
||||
#[serde(default, alias = "siteDescription")]
|
||||
pub site_description: Option<String>,
|
||||
#[serde(default, alias = "heroTitle")]
|
||||
pub hero_title: Option<String>,
|
||||
#[serde(default, alias = "heroSubtitle")]
|
||||
pub hero_subtitle: Option<String>,
|
||||
#[serde(default, alias = "ownerName")]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(default, alias = "ownerTitle")]
|
||||
pub owner_title: Option<String>,
|
||||
#[serde(default, alias = "ownerBio")]
|
||||
pub owner_bio: Option<String>,
|
||||
#[serde(default, alias = "ownerAvatarUrl")]
|
||||
pub owner_avatar_url: Option<String>,
|
||||
#[serde(default, alias = "socialGithub")]
|
||||
pub social_github: Option<String>,
|
||||
#[serde(default, alias = "socialTwitter")]
|
||||
pub social_twitter: Option<String>,
|
||||
#[serde(default, alias = "socialEmail")]
|
||||
pub social_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default, alias = "techStack")]
|
||||
pub tech_stack: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl SiteSettingsPayload {
|
||||
fn apply(self, item: &mut ActiveModel) {
|
||||
if let Some(site_name) = self.site_name {
|
||||
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
||||
}
|
||||
if let Some(site_short_name) = self.site_short_name {
|
||||
item.site_short_name = Set(normalize_optional_string(Some(site_short_name)));
|
||||
}
|
||||
if let Some(site_url) = self.site_url {
|
||||
item.site_url = Set(normalize_optional_string(Some(site_url)));
|
||||
}
|
||||
if let Some(site_title) = self.site_title {
|
||||
item.site_title = Set(normalize_optional_string(Some(site_title)));
|
||||
}
|
||||
if let Some(site_description) = self.site_description {
|
||||
item.site_description = Set(normalize_optional_string(Some(site_description)));
|
||||
}
|
||||
if let Some(hero_title) = self.hero_title {
|
||||
item.hero_title = Set(normalize_optional_string(Some(hero_title)));
|
||||
}
|
||||
if let Some(hero_subtitle) = self.hero_subtitle {
|
||||
item.hero_subtitle = Set(normalize_optional_string(Some(hero_subtitle)));
|
||||
}
|
||||
if let Some(owner_name) = self.owner_name {
|
||||
item.owner_name = Set(normalize_optional_string(Some(owner_name)));
|
||||
}
|
||||
if let Some(owner_title) = self.owner_title {
|
||||
item.owner_title = Set(normalize_optional_string(Some(owner_title)));
|
||||
}
|
||||
if let Some(owner_bio) = self.owner_bio {
|
||||
item.owner_bio = Set(normalize_optional_string(Some(owner_bio)));
|
||||
}
|
||||
if let Some(owner_avatar_url) = self.owner_avatar_url {
|
||||
item.owner_avatar_url = Set(normalize_optional_string(Some(owner_avatar_url)));
|
||||
}
|
||||
if let Some(social_github) = self.social_github {
|
||||
item.social_github = Set(normalize_optional_string(Some(social_github)));
|
||||
}
|
||||
if let Some(social_twitter) = self.social_twitter {
|
||||
item.social_twitter = Set(normalize_optional_string(Some(social_twitter)));
|
||||
}
|
||||
if let Some(social_email) = self.social_email {
|
||||
item.social_email = Set(normalize_optional_string(Some(social_email)));
|
||||
}
|
||||
if let Some(location) = self.location {
|
||||
item.location = Set(normalize_optional_string(Some(location)));
|
||||
}
|
||||
if let Some(tech_stack) = self.tech_stack {
|
||||
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_payload() -> SiteSettingsPayload {
|
||||
SiteSettingsPayload {
|
||||
site_name: Some("InitCool".to_string()),
|
||||
site_short_name: Some("Termi".to_string()),
|
||||
site_url: Some("https://termi.dev".to_string()),
|
||||
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
|
||||
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()),
|
||||
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
|
||||
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
|
||||
owner_name: Some("InitCool".to_string()),
|
||||
owner_title: Some("前端开发者 / 技术博主".to_string()),
|
||||
owner_bio: Some(
|
||||
"一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||
.to_string(),
|
||||
),
|
||||
owner_avatar_url: None,
|
||||
social_github: Some("https://github.com".to_string()),
|
||||
social_twitter: Some("https://twitter.com".to_string()),
|
||||
social_email: Some("mailto:hello@termi.dev".to_string()),
|
||||
location: Some("Hong Kong".to_string()),
|
||||
tech_stack: Some(vec![
|
||||
"Astro".to_string(),
|
||||
"Svelte".to_string(),
|
||||
"Tailwind CSS".to_string(),
|
||||
"TypeScript".to_string(),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||
if let Some(settings) = Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
{
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
let mut item = ActiveModel {
|
||||
id: Set(1),
|
||||
..Default::default()
|
||||
};
|
||||
default_payload().apply(&mut item);
|
||||
Ok(item.insert(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_current(&ctx).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<SiteSettingsPayload>,
|
||||
) -> Result<Response> {
|
||||
let current = load_current(&ctx).await?;
|
||||
let mut item = current.into_active_model();
|
||||
params.apply(&mut item);
|
||||
format::json(item.update(&ctx.db).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/site_settings/")
|
||||
.add("/", get(show))
|
||||
.add("/", put(update))
|
||||
.add("/", patch(update))
|
||||
}
|
||||
77
backend/src/controllers/tag.rs
Normal file
77
backend/src/controllers/tag.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::tags::{ActiveModel, Entity, Model};
|
||||
use crate::services::content;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn update(&self, item: &mut ActiveModel) {
|
||||
item.name = Set(self.name.clone());
|
||||
item.slug = Set(self.slug.clone());
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(Entity::find().all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
params.update(&mut item);
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/tags/")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
.add("{id}", put(update))
|
||||
.add("{id}", patch(update))
|
||||
}
|
||||
Reference in New Issue
Block a user