chore: reorganize project into monorepo

This commit is contained in:
2026-03-28 10:40:22 +08:00
parent 60367a5f51
commit 1455d93246
201 changed files with 30081 additions and 93 deletions

File diff suppressed because it is too large Load Diff

View 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, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.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, &params.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, &params.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, &params.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, &params.email).await else {
tracing::debug!(
email = params.email,
"login attempt with non-existent email"
);
return unauthorized("Invalid credentials!");
};
let valid = user.verify_password(&params.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(&params.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, &params.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, &params.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))
}

View 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(&params)?;
let slug = normalized_slug(&params, &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(&params)?;
let slug = normalized_slug(&params, &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))
}

View 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))
}

View 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))
}

View 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;

View 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, &params.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))
}

View 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))
}

View 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))
}

View 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))
}

View 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))
}