chore: reorganize project into monorepo
This commit is contained in:
368
backend/src/app.rs
Normal file
368
backend/src/app.rs
Normal file
@@ -0,0 +1,368 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{http::Method, Router as AxumRouter};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Hooks, Initializer},
|
||||
bgworker::{BackgroundWorker, Queue},
|
||||
boot::{create_app, BootResult, StartMode},
|
||||
config::Config,
|
||||
controller::AppRoutes,
|
||||
db::{self, truncate_table},
|
||||
environment::Environment,
|
||||
task::Tasks,
|
||||
Result,
|
||||
};
|
||||
use migration::Migrator;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use std::path::Path;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers, initializers,
|
||||
models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users},
|
||||
tasks,
|
||||
workers::downloader::DownloadWorker,
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
#[async_trait]
|
||||
impl Hooks for App {
|
||||
fn app_name() -> &'static str {
|
||||
env!("CARGO_CRATE_NAME")
|
||||
}
|
||||
|
||||
fn app_version() -> String {
|
||||
format!(
|
||||
"{} ({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("BUILD_SHA")
|
||||
.or(option_env!("GITHUB_SHA"))
|
||||
.unwrap_or("dev")
|
||||
)
|
||||
}
|
||||
|
||||
async fn boot(
|
||||
mode: StartMode,
|
||||
environment: &Environment,
|
||||
config: Config,
|
||||
) -> Result<BootResult> {
|
||||
create_app::<Self, Migrator>(mode, environment, config).await
|
||||
}
|
||||
|
||||
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||
Ok(vec![
|
||||
Box::new(initializers::content_sync::ContentSyncInitializer),
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
])
|
||||
}
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::admin::routes())
|
||||
.add_route(controllers::review::routes())
|
||||
.add_route(controllers::category::routes())
|
||||
.add_route(controllers::friend_link::routes())
|
||||
.add_route(controllers::tag::routes())
|
||||
.add_route(controllers::comment::routes())
|
||||
.add_route(controllers::post::routes())
|
||||
.add_route(controllers::search::routes())
|
||||
.add_route(controllers::site_settings::routes())
|
||||
.add_route(controllers::auth::routes())
|
||||
}
|
||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE])
|
||||
.allow_headers(Any);
|
||||
|
||||
Ok(router.layer(cors))
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
// tasks-inject (do not remove)
|
||||
}
|
||||
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||
truncate_table(&ctx.db, users::Entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
// Seed users - use loco's default seed which handles duplicates
|
||||
let users_file = base.join("users.yaml");
|
||||
if users_file.exists() {
|
||||
if let Err(e) =
|
||||
db::seed::<users::ActiveModel>(&ctx.db, &users_file.display().to_string()).await
|
||||
{
|
||||
tracing::warn!("Users seed skipped or failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed tags first (no foreign key dependencies) - use Unchanged to ignore conflicts
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("tags.yaml")) {
|
||||
let tags_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
for tag in tags_data {
|
||||
let id = tag["id"].as_i64().unwrap_or(0) as i32;
|
||||
let name = tag["name"].as_str().unwrap_or("").to_string();
|
||||
let slug = tag["slug"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let existing = tags::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
if existing.is_none() {
|
||||
let new_tag = tags::ActiveModel {
|
||||
id: Set(id),
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = new_tag.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed posts
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("posts.yaml")) {
|
||||
let posts_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
for post in posts_data {
|
||||
let pid = post["pid"].as_i64().unwrap_or(0) as i32;
|
||||
let title = post["title"].as_str().unwrap_or("").to_string();
|
||||
let slug = post["slug"].as_str().unwrap_or("").to_string();
|
||||
let content = post["content"].as_str().unwrap_or("").to_string();
|
||||
let excerpt = post["excerpt"].as_str().unwrap_or("").to_string();
|
||||
let category = post["category"].as_str().unwrap_or("").to_string();
|
||||
let pinned = post["pinned"].as_bool().unwrap_or(false);
|
||||
let post_type = post["post_type"].as_str().unwrap_or("article").to_string();
|
||||
let tags_vec = post["tags"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tags_json = if tags_vec.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::json!(tags_vec))
|
||||
};
|
||||
|
||||
let existing = posts::Entity::find_by_id(pid).one(&ctx.db).await?;
|
||||
let has_existing = existing.is_some();
|
||||
let mut post_model = existing
|
||||
.map(|model| model.into_active_model())
|
||||
.unwrap_or_else(|| posts::ActiveModel {
|
||||
id: Set(pid),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
post_model.title = Set(Some(title));
|
||||
post_model.slug = Set(slug);
|
||||
post_model.content = Set(Some(content));
|
||||
post_model.description = Set(Some(excerpt));
|
||||
post_model.category = Set(Some(category));
|
||||
post_model.tags = Set(tags_json);
|
||||
post_model.pinned = Set(Some(pinned));
|
||||
post_model.post_type = Set(Some(post_type));
|
||||
|
||||
if has_existing {
|
||||
let _ = post_model.update(&ctx.db).await;
|
||||
} else {
|
||||
let _ = post_model.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed comments
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("comments.yaml")) {
|
||||
let comments_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
for comment in comments_data {
|
||||
let id = comment["id"].as_i64().unwrap_or(0) as i32;
|
||||
let pid = comment["pid"].as_i64().unwrap_or(0) as i32;
|
||||
let author = comment["author"].as_str().unwrap_or("").to_string();
|
||||
let email = comment["email"].as_str().unwrap_or("").to_string();
|
||||
let content_text = comment["content"].as_str().unwrap_or("").to_string();
|
||||
let approved = comment["approved"].as_bool().unwrap_or(false);
|
||||
let post_slug = posts::Entity::find_by_id(pid)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|post| post.slug);
|
||||
|
||||
let existing = comments::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
let has_existing = existing.is_some();
|
||||
let mut comment_model = existing
|
||||
.map(|model| model.into_active_model())
|
||||
.unwrap_or_else(|| comments::ActiveModel {
|
||||
id: Set(id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
comment_model.author = Set(Some(author));
|
||||
comment_model.email = Set(Some(email));
|
||||
comment_model.content = Set(Some(content_text));
|
||||
comment_model.approved = Set(Some(approved));
|
||||
comment_model.post_slug = Set(post_slug);
|
||||
|
||||
if has_existing {
|
||||
let _ = comment_model.update(&ctx.db).await;
|
||||
} else {
|
||||
let _ = comment_model.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed friend links
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("friend_links.yaml")) {
|
||||
let links_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
for link in links_data {
|
||||
let site_name = link["site_name"].as_str().unwrap_or("").to_string();
|
||||
let site_url = link["site_url"].as_str().unwrap_or("").to_string();
|
||||
let avatar_url = link["avatar_url"].as_str().map(|s: &str| s.to_string());
|
||||
let description = link["description"].as_str().unwrap_or("").to_string();
|
||||
let category = link["category"].as_str().unwrap_or("").to_string();
|
||||
let status = link["status"].as_str().unwrap_or("pending").to_string();
|
||||
|
||||
let existing = friend_links::Entity::find()
|
||||
.filter(friend_links::Column::SiteUrl.eq(&site_url))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
if existing.is_none() {
|
||||
let new_link = friend_links::ActiveModel {
|
||||
site_name: Set(Some(site_name)),
|
||||
site_url: Set(site_url),
|
||||
avatar_url: Set(avatar_url),
|
||||
description: Set(Some(description)),
|
||||
category: Set(Some(category)),
|
||||
status: Set(Some(status)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = new_link.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed site settings
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("site_settings.yaml")) {
|
||||
let settings_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
if let Some(settings) = settings_data.first() {
|
||||
let existing = site_settings::Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let tech_stack = settings["tech_stack"]
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| serde_json::json!(items));
|
||||
|
||||
let item = site_settings::ActiveModel {
|
||||
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
||||
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
|
||||
site_short_name: Set(
|
||||
settings["site_short_name"].as_str().map(ToString::to_string),
|
||||
),
|
||||
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
|
||||
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
|
||||
site_description: Set(
|
||||
settings["site_description"].as_str().map(ToString::to_string),
|
||||
),
|
||||
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
|
||||
hero_subtitle: Set(
|
||||
settings["hero_subtitle"].as_str().map(ToString::to_string),
|
||||
),
|
||||
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
|
||||
owner_title: Set(
|
||||
settings["owner_title"].as_str().map(ToString::to_string),
|
||||
),
|
||||
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
|
||||
owner_avatar_url: Set(
|
||||
settings["owner_avatar_url"].as_str().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}),
|
||||
),
|
||||
social_github: Set(
|
||||
settings["social_github"].as_str().map(ToString::to_string),
|
||||
),
|
||||
social_twitter: Set(
|
||||
settings["social_twitter"].as_str().map(ToString::to_string),
|
||||
),
|
||||
social_email: Set(
|
||||
settings["social_email"].as_str().map(ToString::to_string),
|
||||
),
|
||||
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
||||
tech_stack: Set(tech_stack),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = item.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed reviews
|
||||
if let Ok(seed_data) = std::fs::read_to_string(base.join("reviews.yaml")) {
|
||||
let reviews_data: Vec<serde_json::Value> =
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||
for review in reviews_data {
|
||||
let title = review["title"].as_str().unwrap_or("").to_string();
|
||||
let review_type = review["review_type"].as_str().unwrap_or("").to_string();
|
||||
let rating = review["rating"].as_i64().unwrap_or(0) as i32;
|
||||
let review_date = review["review_date"].as_str().unwrap_or("").to_string();
|
||||
let status = review["status"].as_str().unwrap_or("completed").to_string();
|
||||
let description = review["description"].as_str().unwrap_or("").to_string();
|
||||
let cover = review["cover"].as_str().unwrap_or("📝").to_string();
|
||||
let tags_vec = review["tags"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let existing = reviews::Entity::find()
|
||||
.filter(reviews::Column::Title.eq(&title))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
if existing.is_none() {
|
||||
let new_review = reviews::ActiveModel {
|
||||
title: Set(Some(title)),
|
||||
review_type: Set(Some(review_type)),
|
||||
rating: Set(Some(rating)),
|
||||
review_date: Set(Some(review_date)),
|
||||
status: Set(Some(status)),
|
||||
description: Set(Some(description)),
|
||||
cover: Set(Some(cover)),
|
||||
tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = new_review.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
8
backend/src/bin/main.rs
Normal file
8
backend/src/bin/main.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use migration::Migrator;
|
||||
use termi_api::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
||||
8
backend/src/bin/tool.rs
Normal file
8
backend/src/bin/tool.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use migration::Migrator;
|
||||
use termi_api::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
||||
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))
|
||||
}
|
||||
1
backend/src/data/mod.rs
Normal file
1
backend/src/data/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
48
backend/src/fixtures/comments.yaml
Normal file
48
backend/src/fixtures/comments.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
author: "Alice"
|
||||
email: "alice@example.com"
|
||||
content: "Great introduction! Looking forward to more content."
|
||||
approved: true
|
||||
|
||||
- id: 2
|
||||
pid: 1
|
||||
author: "Bob"
|
||||
email: "bob@example.com"
|
||||
content: "The terminal UI looks amazing. Love the design!"
|
||||
approved: true
|
||||
|
||||
- id: 3
|
||||
pid: 2
|
||||
author: "Charlie"
|
||||
email: "charlie@example.com"
|
||||
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
||||
approved: true
|
||||
|
||||
- id: 4
|
||||
pid: 3
|
||||
author: "Diana"
|
||||
email: "diana@example.com"
|
||||
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
||||
approved: true
|
||||
|
||||
- id: 5
|
||||
pid: 4
|
||||
author: "Eve"
|
||||
email: "eve@example.com"
|
||||
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
||||
approved: true
|
||||
|
||||
- id: 6
|
||||
pid: 5
|
||||
author: "Frank"
|
||||
email: "frank@example.com"
|
||||
content: "Loco.rs looks promising. Might use it for my next project."
|
||||
approved: false
|
||||
|
||||
- id: 7
|
||||
pid: 2
|
||||
author: "Grace"
|
||||
email: "grace@example.com"
|
||||
content: "Would love to see more advanced Rust patterns in future posts."
|
||||
approved: true
|
||||
38
backend/src/fixtures/friend_links.yaml
Normal file
38
backend/src/fixtures/friend_links.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
- id: 1
|
||||
site_name: "Tech Blog Daily"
|
||||
site_url: "https://techblog.example.com"
|
||||
avatar_url: "https://techblog.example.com/avatar.png"
|
||||
description: "Daily tech news and tutorials"
|
||||
category: "tech"
|
||||
status: "approved"
|
||||
|
||||
- id: 2
|
||||
site_name: "Rustacean Station"
|
||||
site_url: "https://rustacean.example.com"
|
||||
avatar_url: "https://rustacean.example.com/logo.png"
|
||||
description: "All things Rust programming"
|
||||
category: "tech"
|
||||
status: "approved"
|
||||
|
||||
- id: 3
|
||||
site_name: "Design Patterns"
|
||||
site_url: "https://designpatterns.example.com"
|
||||
avatar_url: "https://designpatterns.example.com/icon.png"
|
||||
description: "UI/UX design inspiration"
|
||||
category: "design"
|
||||
status: "approved"
|
||||
|
||||
- id: 4
|
||||
site_name: "Code Snippets"
|
||||
site_url: "https://codesnippets.example.com"
|
||||
description: "Useful code snippets for developers"
|
||||
category: "dev"
|
||||
status: "pending"
|
||||
|
||||
- id: 5
|
||||
site_name: "Web Dev Weekly"
|
||||
site_url: "https://webdevweekly.example.com"
|
||||
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
||||
description: "Weekly web development newsletter"
|
||||
category: "dev"
|
||||
status: "pending"
|
||||
191
backend/src/fixtures/posts.yaml
Normal file
191
backend/src/fixtures/posts.yaml
Normal file
@@ -0,0 +1,191 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
title: "Welcome to Termi Blog"
|
||||
slug: "welcome-to-termi"
|
||||
content: |
|
||||
# Welcome to Termi Blog
|
||||
|
||||
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Fast performance with Astro
|
||||
- 🎨 Terminal-style UI design
|
||||
- 💬 Comments system
|
||||
- 🔗 Friend links
|
||||
- 🏷️ Tags and categories
|
||||
|
||||
## Code Example
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, Termi!");
|
||||
}
|
||||
```
|
||||
|
||||
Stay tuned for more posts!
|
||||
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
||||
category: "general"
|
||||
published: true
|
||||
pinned: true
|
||||
tags:
|
||||
- welcome
|
||||
- astro
|
||||
- loco-rs
|
||||
|
||||
- id: 2
|
||||
pid: 2
|
||||
title: "Rust Programming Tips"
|
||||
slug: "rust-programming-tips"
|
||||
content: |
|
||||
# Rust Programming Tips
|
||||
|
||||
Here are some essential tips for Rust developers:
|
||||
|
||||
## 1. Ownership and Borrowing
|
||||
|
||||
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||
|
||||
## 2. Pattern Matching
|
||||
|
||||
Use `match` expressions for exhaustive pattern matching:
|
||||
|
||||
```rust
|
||||
match result {
|
||||
Ok(value) => println!("Success: {}", value),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Error Handling
|
||||
|
||||
Use `Result` and `Option` types effectively with the `?` operator.
|
||||
|
||||
Happy coding!
|
||||
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- programming
|
||||
- tips
|
||||
|
||||
- id: 3
|
||||
pid: 3
|
||||
title: "Building a Blog with Astro"
|
||||
slug: "building-blog-with-astro"
|
||||
content: |
|
||||
# Building a Blog with Astro
|
||||
|
||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||
|
||||
## Why Astro?
|
||||
|
||||
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
||||
- **Island Architecture**: Hydrate only interactive components
|
||||
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
||||
- **Great DX**: Excellent developer experience with hot module replacement
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm create astro@latest
|
||||
cd my-astro-project
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Astro is perfect for content-focused websites like blogs.
|
||||
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- astro
|
||||
- web-dev
|
||||
- static-site
|
||||
|
||||
- id: 4
|
||||
pid: 4
|
||||
title: "Terminal UI Design Principles"
|
||||
slug: "terminal-ui-design"
|
||||
content: |
|
||||
# Terminal UI Design Principles
|
||||
|
||||
Terminal-style interfaces are making a comeback in modern web design.
|
||||
|
||||
## Key Elements
|
||||
|
||||
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
||||
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
||||
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
||||
4. **ASCII Art**: Decorative elements using text characters
|
||||
5. **Blinking Cursor**: The iconic terminal cursor
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: `#0d1117`
|
||||
- Text: `#c9d1d9`
|
||||
- Accent: `#58a6ff`
|
||||
- Success: `#3fb950`
|
||||
- Warning: `#d29922`
|
||||
- Error: `#f85149`
|
||||
|
||||
## Implementation
|
||||
|
||||
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
||||
category: "design"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- design
|
||||
- terminal
|
||||
- ui
|
||||
|
||||
- id: 5
|
||||
pid: 5
|
||||
title: "Loco.rs Backend Framework"
|
||||
slug: "loco-rs-framework"
|
||||
content: |
|
||||
# Introduction to Loco.rs
|
||||
|
||||
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||
|
||||
## Features
|
||||
|
||||
- **MVC Architecture**: Model-View-Controller pattern
|
||||
- **SeaORM Integration**: Powerful ORM for database operations
|
||||
- **Background Jobs**: Built-in job processing
|
||||
- **Authentication**: Ready-to-use auth system
|
||||
- **CLI Generator**: Scaffold resources quickly
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cargo install loco
|
||||
loco new myapp
|
||||
cd myapp
|
||||
cargo loco start
|
||||
```
|
||||
|
||||
## Why Loco.rs?
|
||||
|
||||
- Opinionated but flexible
|
||||
- Production-ready defaults
|
||||
- Excellent documentation
|
||||
- Active community
|
||||
|
||||
Perfect for building APIs and web applications in Rust.
|
||||
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- loco-rs
|
||||
- backend
|
||||
- api
|
||||
59
backend/src/fixtures/reviews.yaml
Normal file
59
backend/src/fixtures/reviews.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
- id: 1
|
||||
title: "塞尔达传说:王国之泪"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-03-20"
|
||||
status: "completed"
|
||||
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
||||
tags: ["Switch", "开放世界", "冒险"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 2
|
||||
title: "进击的巨人"
|
||||
review_type: "anime"
|
||||
rating: 5
|
||||
review_date: "2023-11-10"
|
||||
status: "completed"
|
||||
description: "史诗级完结,剧情反转令人震撼"
|
||||
tags: ["热血", "悬疑", "神作"]
|
||||
cover: "🎭"
|
||||
|
||||
- id: 3
|
||||
title: "赛博朋克 2077"
|
||||
review_type: "game"
|
||||
rating: 4
|
||||
review_date: "2024-01-15"
|
||||
status: "completed"
|
||||
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
||||
tags: ["PC", "RPG", "科幻"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 4
|
||||
title: "三体"
|
||||
review_type: "book"
|
||||
rating: 5
|
||||
review_date: "2023-08-05"
|
||||
status: "completed"
|
||||
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
||||
tags: ["科幻", "经典", "雨果奖"]
|
||||
cover: "📚"
|
||||
|
||||
- id: 5
|
||||
title: "星际穿越"
|
||||
review_type: "movie"
|
||||
rating: 5
|
||||
review_date: "2024-02-14"
|
||||
status: "completed"
|
||||
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
||||
tags: ["科幻", "IMAX", "诺兰"]
|
||||
cover: "🎬"
|
||||
|
||||
- id: 6
|
||||
title: "博德之门3"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-04-01"
|
||||
status: "in-progress"
|
||||
description: "CRPG的文艺复兴,骰子决定命运"
|
||||
tags: ["PC", "CRPG", "多人"]
|
||||
cover: "🎮"
|
||||
21
backend/src/fixtures/site_settings.yaml
Normal file
21
backend/src/fixtures/site_settings.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
- id: 1
|
||||
site_name: "InitCool"
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://termi.dev"
|
||||
site_title: "InitCool - 终端风格的内容平台"
|
||||
site_description: "一个基于终端美学的个人内容站,记录代码、设计和生活。"
|
||||
hero_title: "欢迎来到我的极客终端博客"
|
||||
hero_subtitle: "这里记录技术、代码和生活点滴"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "前端开发者 / 技术博主"
|
||||
owner_bio: "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||
owner_avatar_url: ""
|
||||
social_github: "https://github.com"
|
||||
social_twitter: "https://twitter.com"
|
||||
social_email: "mailto:hello@termi.dev"
|
||||
location: "Hong Kong"
|
||||
tech_stack:
|
||||
- "Astro"
|
||||
- "Svelte"
|
||||
- "Tailwind CSS"
|
||||
- "TypeScript"
|
||||
39
backend/src/fixtures/tags.yaml
Normal file
39
backend/src/fixtures/tags.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
- id: 1
|
||||
name: "Welcome"
|
||||
slug: "welcome"
|
||||
|
||||
- id: 2
|
||||
name: "Astro"
|
||||
slug: "astro"
|
||||
|
||||
- id: 3
|
||||
name: "Rust"
|
||||
slug: "rust"
|
||||
|
||||
- id: 4
|
||||
name: "Programming"
|
||||
slug: "programming"
|
||||
|
||||
- id: 5
|
||||
name: "Tech"
|
||||
slug: "tech"
|
||||
|
||||
- id: 6
|
||||
name: "Design"
|
||||
slug: "design"
|
||||
|
||||
- id: 7
|
||||
name: "Terminal"
|
||||
slug: "terminal"
|
||||
|
||||
- id: 8
|
||||
name: "Loco.rs"
|
||||
slug: "loco-rs"
|
||||
|
||||
- id: 9
|
||||
name: "Backend"
|
||||
slug: "backend"
|
||||
|
||||
- id: 10
|
||||
name: "Web Dev"
|
||||
slug: "web-dev"
|
||||
17
backend/src/fixtures/users.yaml
Normal file
17
backend/src/fixtures/users.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
- id: 1
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 2
|
||||
pid: 22222222-2222-2222-2222-222222222222
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
191
backend/src/initializers/content_sync.rs
Normal file
191
backend/src/initializers/content_sync.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::{
|
||||
app::{AppContext, Initializer},
|
||||
Result,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::_entities::{comments, posts, site_settings};
|
||||
use crate::services::content;
|
||||
|
||||
const FIXTURES_DIR: &str = "src/fixtures";
|
||||
|
||||
pub struct ContentSyncInitializer;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ContentSyncInitializer {
|
||||
fn name(&self) -> String {
|
||||
"content-sync".to_string()
|
||||
}
|
||||
|
||||
async fn before_run(&self, app_context: &AppContext) -> Result<()> {
|
||||
sync_content(app_context, Path::new(FIXTURES_DIR)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_content(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
content::sync_markdown_posts(ctx).await?;
|
||||
sync_site_settings(ctx, base).await?;
|
||||
sync_comment_post_slugs(ctx, base).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_fixture_rows(base: &Path, file_name: &str) -> Vec<serde_json::Value> {
|
||||
let path: PathBuf = base.join(file_name);
|
||||
let seed_data = match std::fs::read_to_string(path) {
|
||||
Ok(data) => data,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
serde_yaml::from_str(&seed_data).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn as_optional_string(value: &serde_json::Value) -> Option<String> {
|
||||
value.as_str().and_then(|item| {
|
||||
let trimmed = item.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_blank(value: &Option<String>) -> bool {
|
||||
value.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||
}
|
||||
|
||||
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
let rows = read_fixture_rows(base, "site_settings.yaml");
|
||||
let Some(seed) = rows.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let tech_stack = seed["tech_stack"]
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| serde_json::json!(items));
|
||||
|
||||
let existing = site_settings::Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let mut model = existing.clone().into_active_model();
|
||||
|
||||
if is_blank(&existing.site_name) {
|
||||
model.site_name = Set(as_optional_string(&seed["site_name"]));
|
||||
}
|
||||
if is_blank(&existing.site_short_name) {
|
||||
model.site_short_name = Set(as_optional_string(&seed["site_short_name"]));
|
||||
}
|
||||
if is_blank(&existing.site_url) {
|
||||
model.site_url = Set(as_optional_string(&seed["site_url"]));
|
||||
}
|
||||
if is_blank(&existing.site_title) {
|
||||
model.site_title = Set(as_optional_string(&seed["site_title"]));
|
||||
}
|
||||
if is_blank(&existing.site_description) {
|
||||
model.site_description = Set(as_optional_string(&seed["site_description"]));
|
||||
}
|
||||
if is_blank(&existing.hero_title) {
|
||||
model.hero_title = Set(as_optional_string(&seed["hero_title"]));
|
||||
}
|
||||
if is_blank(&existing.hero_subtitle) {
|
||||
model.hero_subtitle = Set(as_optional_string(&seed["hero_subtitle"]));
|
||||
}
|
||||
if is_blank(&existing.owner_name) {
|
||||
model.owner_name = Set(as_optional_string(&seed["owner_name"]));
|
||||
}
|
||||
if is_blank(&existing.owner_title) {
|
||||
model.owner_title = Set(as_optional_string(&seed["owner_title"]));
|
||||
}
|
||||
if is_blank(&existing.owner_bio) {
|
||||
model.owner_bio = Set(as_optional_string(&seed["owner_bio"]));
|
||||
}
|
||||
if is_blank(&existing.owner_avatar_url) {
|
||||
model.owner_avatar_url = Set(as_optional_string(&seed["owner_avatar_url"]));
|
||||
}
|
||||
if is_blank(&existing.social_github) {
|
||||
model.social_github = Set(as_optional_string(&seed["social_github"]));
|
||||
}
|
||||
if is_blank(&existing.social_twitter) {
|
||||
model.social_twitter = Set(as_optional_string(&seed["social_twitter"]));
|
||||
}
|
||||
if is_blank(&existing.social_email) {
|
||||
model.social_email = Set(as_optional_string(&seed["social_email"]));
|
||||
}
|
||||
if is_blank(&existing.location) {
|
||||
model.location = Set(as_optional_string(&seed["location"]));
|
||||
}
|
||||
if existing.tech_stack.is_none() {
|
||||
model.tech_stack = Set(tech_stack);
|
||||
}
|
||||
|
||||
let _ = model.update(&ctx.db).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let model = site_settings::ActiveModel {
|
||||
id: Set(seed["id"].as_i64().unwrap_or(1) as i32),
|
||||
site_name: Set(as_optional_string(&seed["site_name"])),
|
||||
site_short_name: Set(as_optional_string(&seed["site_short_name"])),
|
||||
site_url: Set(as_optional_string(&seed["site_url"])),
|
||||
site_title: Set(as_optional_string(&seed["site_title"])),
|
||||
site_description: Set(as_optional_string(&seed["site_description"])),
|
||||
hero_title: Set(as_optional_string(&seed["hero_title"])),
|
||||
hero_subtitle: Set(as_optional_string(&seed["hero_subtitle"])),
|
||||
owner_name: Set(as_optional_string(&seed["owner_name"])),
|
||||
owner_title: Set(as_optional_string(&seed["owner_title"])),
|
||||
owner_bio: Set(as_optional_string(&seed["owner_bio"])),
|
||||
owner_avatar_url: Set(as_optional_string(&seed["owner_avatar_url"])),
|
||||
social_github: Set(as_optional_string(&seed["social_github"])),
|
||||
social_twitter: Set(as_optional_string(&seed["social_twitter"])),
|
||||
social_email: Set(as_optional_string(&seed["social_email"])),
|
||||
location: Set(as_optional_string(&seed["location"])),
|
||||
tech_stack: Set(tech_stack),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = model.insert(&ctx.db).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_comment_post_slugs(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
let rows = read_fixture_rows(base, "comments.yaml");
|
||||
|
||||
for seed in rows {
|
||||
let id = seed["id"].as_i64().unwrap_or(0) as i32;
|
||||
let pid = seed["pid"].as_i64().unwrap_or(0) as i32;
|
||||
if id == 0 || pid == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(existing) = comments::Entity::find_by_id(id).one(&ctx.db).await? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if existing.post_slug.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(post) = posts::Entity::find_by_id(pid).one(&ctx.db).await? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut model = existing.into_active_model();
|
||||
model.post_slug = Set(Some(post.slug));
|
||||
let _ = model.update(&ctx.db).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
2
backend/src/initializers/mod.rs
Normal file
2
backend/src/initializers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod content_sync;
|
||||
pub mod view_engine;
|
||||
43
backend/src/initializers/view_engine.rs
Normal file
43
backend/src/initializers/view_engine.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{Extension, Router as AxumRouter};
|
||||
use fluent_templates::{ArcLoader, FluentLoader};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Initializer},
|
||||
controller::views::{engines, ViewEngine},
|
||||
Error, Result,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
const I18N_DIR: &str = "assets/i18n";
|
||||
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct ViewEngineInitializer;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ViewEngineInitializer {
|
||||
fn name(&self) -> String {
|
||||
"view-engine".to_string()
|
||||
}
|
||||
|
||||
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
|
||||
let arc = std::sync::Arc::new(
|
||||
ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
|
||||
.shared_resources(Some(&[I18N_SHARED.into()]))
|
||||
.customize(|bundle| bundle.set_use_isolating(false))
|
||||
.build()
|
||||
.map_err(|e| Error::string(&e.to_string()))?,
|
||||
);
|
||||
info!("locales loaded");
|
||||
|
||||
engines::TeraView::build()?.post_process(move |tera| {
|
||||
tera.register_function("t", FluentLoader::new(arc.clone()));
|
||||
Ok(())
|
||||
})?
|
||||
} else {
|
||||
engines::TeraView::build()?
|
||||
};
|
||||
|
||||
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||
}
|
||||
}
|
||||
10
backend/src/lib.rs
Normal file
10
backend/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
90
backend/src/mailers/auth.rs
Normal file
90
backend/src/mailers/auth.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// auth mailer
|
||||
#![allow(non_upper_case_globals)]
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct AuthMailer {}
|
||||
impl Mailer for AuthMailer {}
|
||||
impl AuthMailer {
|
||||
/// Sending welcome email the the given user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&welcome,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"verifyToken": user.email_verification_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sending forgot password email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&forgot,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"resetToken": user.reset_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a magic link authentication email to the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&magic_link,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
|
||||
"the user model not contains magic link token",
|
||||
))?,
|
||||
"host": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
backend/src/mailers/auth/forgot/html.t
Normal file
11
backend/src/mailers/auth/forgot/html.t
Normal file
@@ -0,0 +1,11 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Hey {{name}},
|
||||
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||
If you didn't request a password reset, please ignore this email.
|
||||
Best regards,<br>The Loco Team</br>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
backend/src/mailers/auth/forgot/subject.t
Normal file
1
backend/src/mailers/auth/forgot/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Your reset password link
|
||||
3
backend/src/mailers/auth/forgot/text.t
Normal file
3
backend/src/mailers/auth/forgot/text.t
Normal file
@@ -0,0 +1,3 @@
|
||||
Reset your password with this link:
|
||||
|
||||
http://localhost/reset#{{resetToken}}
|
||||
8
backend/src/mailers/auth/magic_link/html.t
Normal file
8
backend/src/mailers/auth/magic_link/html.t
Normal file
@@ -0,0 +1,8 @@
|
||||
;<html>
|
||||
<body>
|
||||
<p>Magic link example:</p>
|
||||
<a href="{{host}}/api/auth/magic-link/{{token}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/src/mailers/auth/magic_link/subject.t
Normal file
1
backend/src/mailers/auth/magic_link/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Magic link example
|
||||
2
backend/src/mailers/auth/magic_link/text.t
Normal file
2
backend/src/mailers/auth/magic_link/text.t
Normal file
@@ -0,0 +1,2 @@
|
||||
Magic link with this link:
|
||||
{{host}}/api/auth/magic-link/{{token}}
|
||||
13
backend/src/mailers/auth/welcome/html.t
Normal file
13
backend/src/mailers/auth/welcome/html.t
Normal file
@@ -0,0 +1,13 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Dear {{name}},
|
||||
Welcome to Loco! You can now log in to your account.
|
||||
Before you get started, please verify your account by clicking the link below:
|
||||
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
<p>Best regards,<br>The Loco Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
backend/src/mailers/auth/welcome/subject.t
Normal file
1
backend/src/mailers/auth/welcome/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Welcome {{name}}
|
||||
4
backend/src/mailers/auth/welcome/text.t
Normal file
4
backend/src/mailers/auth/welcome/text.t
Normal file
@@ -0,0 +1,4 @@
|
||||
Welcome {{name}}, you can now log in.
|
||||
Verify your account with the link below:
|
||||
|
||||
{{domain}}/api/auth/verify/{{verifyToken}}
|
||||
1
backend/src/mailers/mod.rs
Normal file
1
backend/src/mailers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
16
backend/src/models/_entities/categories.rs
Normal file
16
backend/src/models/_entities/categories.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "categories")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
25
backend/src/models/_entities/comments.rs
Normal file
25
backend/src/models/_entities/comments.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "comments")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub post_id: Option<Uuid>,
|
||||
pub post_slug: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub content: Option<String>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
22
backend/src/models/_entities/friend_links.rs
Normal file
22
backend/src/models/_entities/friend_links.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "friend_links")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
12
backend/src/models/_entities/mod.rs
Normal file
12
backend/src/models/_entities/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod categories;
|
||||
pub mod comments;
|
||||
pub mod friend_links;
|
||||
pub mod posts;
|
||||
pub mod reviews;
|
||||
pub mod site_settings;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
27
backend/src/models/_entities/posts.rs
Normal file
27
backend/src/models/_entities/posts.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "posts")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub title: Option<String>,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub tags: Option<Json>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
10
backend/src/models/_entities/prelude.rs
Normal file
10
backend/src/models/_entities/prelude.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
pub use super::categories::Entity as Categories;
|
||||
pub use super::comments::Entity as Comments;
|
||||
pub use super::friend_links::Entity as FriendLinks;
|
||||
pub use super::posts::Entity as Posts;
|
||||
pub use super::reviews::Entity as Reviews;
|
||||
pub use super::site_settings::Entity as SiteSettings;
|
||||
pub use super::tags::Entity as Tags;
|
||||
pub use super::users::Entity as Users;
|
||||
24
backend/src/models/_entities/reviews.rs
Normal file
24
backend/src/models/_entities/reviews.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "reviews")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
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<String>,
|
||||
pub cover: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
36
backend/src/models/_entities/site_settings.rs
Normal file
36
backend/src/models/_entities/site_settings.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! `SeaORM` Entity, manually maintained
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "site_settings")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub site_name: Option<String>,
|
||||
pub site_short_name: Option<String>,
|
||||
pub site_url: Option<String>,
|
||||
pub site_title: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
pub hero_title: Option<String>,
|
||||
pub hero_subtitle: Option<String>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub owner_bio: Option<String>,
|
||||
pub owner_avatar_url: Option<String>,
|
||||
pub social_github: Option<String>,
|
||||
pub social_twitter: Option<String>,
|
||||
pub social_email: Option<String>,
|
||||
pub location: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub tech_stack: Option<Json>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
18
backend/src/models/_entities/tags.rs
Normal file
18
backend/src/models/_entities/tags.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "tags")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
30
backend/src/models/_entities/users.rs
Normal file
30
backend/src/models/_entities/users.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub pid: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[sea_orm(unique)]
|
||||
pub api_key: String,
|
||||
pub name: String,
|
||||
pub reset_token: Option<String>,
|
||||
pub reset_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verification_token: Option<String>,
|
||||
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||
pub magic_link_token: Option<String>,
|
||||
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
23
backend/src/models/categories.rs
Normal file
23
backend/src/models/categories.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
pub use super::_entities::categories::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type Categories = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {}
|
||||
impl ActiveModel {}
|
||||
impl Entity {}
|
||||
28
backend/src/models/comments.rs
Normal file
28
backend/src/models/comments.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub use super::_entities::comments::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type Comments = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
28
backend/src/models/friend_links.rs
Normal file
28
backend/src/models/friend_links.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub use super::_entities::friend_links::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type FriendLinks = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
8
backend/src/models/mod.rs
Normal file
8
backend/src/models/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod _entities;
|
||||
pub mod categories;
|
||||
pub mod comments;
|
||||
pub mod friend_links;
|
||||
pub mod posts;
|
||||
pub mod site_settings;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
28
backend/src/models/posts.rs
Normal file
28
backend/src/models/posts.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub use super::_entities::posts::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type Posts = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
3
backend/src/models/site_settings.rs
Normal file
3
backend/src/models/site_settings.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub use super::_entities::site_settings::{ActiveModel, Entity, Model};
|
||||
|
||||
pub type SiteSettings = Entity;
|
||||
28
backend/src/models/tags.rs
Normal file
28
backend/src/models/tags.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub use super::_entities::tags::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type Tags = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
369
backend/src/models/users.rs
Normal file
369
backend/src/models/users.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{offset::Local, Duration};
|
||||
use loco_rs::{auth::jwt, hash, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Map;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
|
||||
|
||||
pub const MAGIC_LINK_LENGTH: i8 = 32;
|
||||
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RegisterParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct Validator {
|
||||
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
|
||||
pub name: String,
|
||||
#[validate(email(message = "invalid email"))]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Validatable for ActiveModel {
|
||||
fn validator(&self) -> Box<dyn Validate> {
|
||||
Box::new(Validator {
|
||||
name: self.name.as_ref().to_owned(),
|
||||
email: self.email.as_ref().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
self.validate()?;
|
||||
if insert {
|
||||
let mut this = self;
|
||||
this.pid = ActiveValue::Set(Uuid::new_v4());
|
||||
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authenticable for Model {
|
||||
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
|
||||
Self::find_by_pid(db, claims_key).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// finds a user by the provided email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, email)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided verification token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_verification_token(
|
||||
db: &DatabaseConnection,
|
||||
token: &str,
|
||||
) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::EmailVerificationToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the magic token and verify and token expiration
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error ot token expired
|
||||
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
query::condition()
|
||||
.eq(users::Column::MagicLinkToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
|
||||
if let Some(expired_at) = user.magic_link_expiration {
|
||||
if expired_at >= Local::now() {
|
||||
Ok(user)
|
||||
} else {
|
||||
tracing::debug!(
|
||||
user_pid = user.pid.to_string(),
|
||||
token_expiration = expired_at.to_string(),
|
||||
"magic token expired for the user."
|
||||
);
|
||||
Err(ModelError::msg("magic token expired"))
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
user_pid = user.pid.to_string(),
|
||||
"magic link expiration time not exists"
|
||||
);
|
||||
Err(ModelError::msg("expiration token not exists"))
|
||||
}
|
||||
}
|
||||
|
||||
/// finds a user by the provided reset token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ResetToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided pid
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user or DB query error
|
||||
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
|
||||
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Pid, parse_uuid)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided api key
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// Verifies whether the provided plain password matches the hashed password
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not verify password
|
||||
#[must_use]
|
||||
pub fn verify_password(&self, password: &str) -> bool {
|
||||
hash::verify_password(password, &self.password)
|
||||
}
|
||||
|
||||
/// Asynchronously creates a user with a password and saves it to the
|
||||
/// database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not save the user into the DB
|
||||
pub async fn create_with_password(
|
||||
db: &DatabaseConnection,
|
||||
params: &RegisterParams,
|
||||
) -> ModelResult<Self> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
if users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, ¶ms.email)
|
||||
.build(),
|
||||
)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ModelError::EntityAlreadyExists {});
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::ActiveModel {
|
||||
email: ActiveValue::set(params.email.to_string()),
|
||||
password: ActiveValue::set(password_hash),
|
||||
name: ActiveValue::set(params.name.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Creates a JWT
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not convert user claims to jwt token
|
||||
pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult<String> {
|
||||
jwt::JWT::new(secret)
|
||||
.generate_token(expiration, self.pid.to_string(), Map::new())
|
||||
.map_err(ModelError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModel {
|
||||
/// Sets the email verification information for the user and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method is used to record the timestamp when the email verification
|
||||
/// was sent and generate a unique verification token for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_email_verification_sent(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
) -> ModelResult<Model> {
|
||||
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Sets the information for a reset password request,
|
||||
/// generates a unique reset password token, and updates it in the
|
||||
/// database.
|
||||
///
|
||||
/// This method records the timestamp when the reset password token is sent
|
||||
/// and generates a unique token for the user.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Records the verification time when a user verifies their
|
||||
/// email and updates it in the database.
|
||||
///
|
||||
/// This method sets the timestamp when the user successfully verifies their
|
||||
/// email.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Resets the current user password with a new password and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method hashes the provided password and sets it as the new password
|
||||
/// for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error or could not hashed the given password
|
||||
pub async fn reset_password(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
password: &str,
|
||||
) -> ModelResult<Model> {
|
||||
self.password =
|
||||
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
|
||||
self.reset_token = ActiveValue::Set(None);
|
||||
self.reset_sent_at = ActiveValue::Set(None);
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Creates a magic link token for passwordless authentication.
|
||||
///
|
||||
/// Generates a random token with a specified length and sets an expiration time
|
||||
/// for the magic link. This method is used to initiate the magic link authentication flow.
|
||||
///
|
||||
/// # Errors
|
||||
/// - Returns an error if database update fails
|
||||
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
|
||||
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());
|
||||
|
||||
self.magic_link_token = ActiveValue::set(Some(random_str));
|
||||
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Verifies and invalidates the magic link after successful authentication.
|
||||
///
|
||||
/// Clears the magic link token and expiration time after the user has
|
||||
/// successfully authenticated using the magic link.
|
||||
///
|
||||
/// # Errors
|
||||
/// - Returns an error if database update fails
|
||||
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.magic_link_token = ActiveValue::set(None);
|
||||
self.magic_link_expiration = ActiveValue::set(None);
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
}
|
||||
648
backend/src/services/content.rs
Normal file
648
backend/src/services/content.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::_entities::{categories, posts, tags};
|
||||
|
||||
pub const MARKDOWN_POSTS_DIR: &str = "content/posts";
|
||||
const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
struct MarkdownFrontmatter {
|
||||
title: Option<String>,
|
||||
slug: Option<String>,
|
||||
description: Option<String>,
|
||||
category: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
post_type: Option<String>,
|
||||
image: Option<String>,
|
||||
pinned: Option<bool>,
|
||||
published: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MarkdownPost {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub content: String,
|
||||
pub category: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub post_type: String,
|
||||
pub image: Option<String>,
|
||||
pub pinned: bool,
|
||||
pub published: bool,
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownPostDraft {
|
||||
pub title: String,
|
||||
pub slug: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub content: String,
|
||||
pub category: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub post_type: String,
|
||||
pub image: Option<String>,
|
||||
pub pinned: bool,
|
||||
pub published: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownImportFile {
|
||||
pub file_name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LegacyFixturePost {
|
||||
title: String,
|
||||
slug: String,
|
||||
content: String,
|
||||
excerpt: Option<String>,
|
||||
category: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
pinned: Option<bool>,
|
||||
published: Option<bool>,
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> Error {
|
||||
Error::string(&err.to_string())
|
||||
}
|
||||
|
||||
fn yaml_error(err: serde_yaml::Error) -> Error {
|
||||
Error::string(&err.to_string())
|
||||
}
|
||||
|
||||
fn posts_dir() -> PathBuf {
|
||||
PathBuf::from(MARKDOWN_POSTS_DIR)
|
||||
}
|
||||
|
||||
pub fn markdown_post_path(slug: &str) -> PathBuf {
|
||||
posts_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
fn normalize_newlines(input: &str) -> String {
|
||||
input.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
fn trim_to_option(input: Option<String>) -> Option<String> {
|
||||
input.and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 excerpt_from_content(content: &str) -> Option<String> {
|
||||
let mut in_code_block = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_code_block || trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let excerpt = trimmed.chars().take(180).collect::<String>();
|
||||
return if excerpt.is_empty() { None } else { Some(excerpt) };
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn title_from_content(content: &str) -> Option<String> {
|
||||
content.lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix("# ")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn split_frontmatter(raw: &str) -> Result<(MarkdownFrontmatter, String)> {
|
||||
let normalized = normalize_newlines(raw);
|
||||
|
||||
if !normalized.starts_with("---\n") {
|
||||
return Ok((MarkdownFrontmatter::default(), normalized));
|
||||
}
|
||||
|
||||
let rest = &normalized[4..];
|
||||
let Some(end_index) = rest.find("\n---\n") else {
|
||||
return Err(Error::string("Markdown frontmatter is not closed"));
|
||||
};
|
||||
|
||||
let frontmatter = &rest[..end_index];
|
||||
let content = rest[end_index + 5..].to_string();
|
||||
let parsed = serde_yaml::from_str::<MarkdownFrontmatter>(frontmatter).map_err(yaml_error)?;
|
||||
|
||||
Ok((parsed, content))
|
||||
}
|
||||
|
||||
fn parse_markdown_post(path: &Path) -> Result<MarkdownPost> {
|
||||
let raw = fs::read_to_string(path).map_err(io_error)?;
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("post")
|
||||
.to_string();
|
||||
|
||||
parse_markdown_source(&file_stem, &raw, &path.to_string_lossy())
|
||||
}
|
||||
|
||||
fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<MarkdownPost> {
|
||||
let (frontmatter, content) = split_frontmatter(raw)?;
|
||||
|
||||
let slug = trim_to_option(frontmatter.slug.clone()).unwrap_or_else(|| file_stem.to_string());
|
||||
let title = trim_to_option(frontmatter.title.clone())
|
||||
.or_else(|| title_from_content(&content))
|
||||
.unwrap_or_else(|| slug.clone());
|
||||
let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
||||
let category = trim_to_option(frontmatter.category.clone());
|
||||
let tags = frontmatter
|
||||
.tags
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(MarkdownPost {
|
||||
title,
|
||||
slug,
|
||||
description,
|
||||
content: content.trim_start_matches('\n').to_string(),
|
||||
category,
|
||||
tags,
|
||||
post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()),
|
||||
image: trim_to_option(frontmatter.image.clone()),
|
||||
pinned: frontmatter.pinned.unwrap_or(false),
|
||||
published: frontmatter.published.unwrap_or(true),
|
||||
file_path: file_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_markdown_document(post: &MarkdownPost) -> String {
|
||||
let mut lines = vec![
|
||||
"---".to_string(),
|
||||
format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()),
|
||||
format!("slug: {}", post.slug),
|
||||
];
|
||||
|
||||
if let Some(description) = &post.description {
|
||||
lines.push(format!(
|
||||
"description: {}",
|
||||
serde_yaml::to_string(description)
|
||||
.unwrap_or_else(|_| format!("{description:?}"))
|
||||
.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(category) = &post.category {
|
||||
lines.push(format!("category: {}", category));
|
||||
}
|
||||
|
||||
lines.push(format!("post_type: {}", post.post_type));
|
||||
lines.push(format!("pinned: {}", post.pinned));
|
||||
lines.push(format!("published: {}", post.published));
|
||||
|
||||
if let Some(image) = &post.image {
|
||||
lines.push(format!("image: {}", image));
|
||||
}
|
||||
|
||||
if !post.tags.is_empty() {
|
||||
lines.push("tags:".to_string());
|
||||
for tag in &post.tags {
|
||||
lines.push(format!(" - {}", tag));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
lines.push(post.content.trim().to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn ensure_markdown_posts_bootstrapped() -> Result<()> {
|
||||
let dir = posts_dir();
|
||||
fs::create_dir_all(&dir).map_err(io_error)?;
|
||||
|
||||
let has_markdown = fs::read_dir(&dir)
|
||||
.map_err(io_error)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.any(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"));
|
||||
|
||||
if has_markdown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(FIXTURE_POSTS_FILE).map_err(io_error)?;
|
||||
let fixtures = serde_yaml::from_str::<Vec<LegacyFixturePost>>(&raw).map_err(yaml_error)?;
|
||||
|
||||
for fixture in fixtures {
|
||||
let post = MarkdownPost {
|
||||
title: fixture.title,
|
||||
slug: fixture.slug.clone(),
|
||||
description: trim_to_option(fixture.excerpt),
|
||||
content: fixture.content,
|
||||
category: trim_to_option(fixture.category),
|
||||
tags: fixture.tags.unwrap_or_default(),
|
||||
post_type: "article".to_string(),
|
||||
image: None,
|
||||
pinned: fixture.pinned.unwrap_or(false),
|
||||
published: fixture.published.unwrap_or(true),
|
||||
file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_markdown_posts_from_disk() -> Result<Vec<MarkdownPost>> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
|
||||
let mut posts = fs::read_dir(posts_dir())
|
||||
.map_err(io_error)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md"))
|
||||
.map(|path| parse_markdown_post(&path))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
posts.sort_by(|left, right| left.slug.cmp(&right.slug));
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Result<()> {
|
||||
for post in posts {
|
||||
for tag_name in &post.tags {
|
||||
let slug = slugify(tag_name);
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let item = tags::ActiveModel {
|
||||
name: Set(Some(tag_name.clone())),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = item.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result<Option<String>> {
|
||||
let name = raw_name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let slug = slugify(name);
|
||||
let existing = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(category) = existing {
|
||||
if let Some(existing_name) = category.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
return Ok(Some(existing_name.to_string()));
|
||||
}
|
||||
|
||||
let mut category_model = category.into_active_model();
|
||||
category_model.name = Set(Some(name.to_string()));
|
||||
let updated = category_model.update(&ctx.db).await?;
|
||||
return Ok(updated.name.or_else(|| Some(name.to_string())));
|
||||
}
|
||||
|
||||
let created = categories::ActiveModel {
|
||||
name: Set(Some(name.to_string())),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
Ok(created.name.or_else(|| Some(name.to_string())))
|
||||
}
|
||||
|
||||
async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<String>> {
|
||||
let mut canonical_tags = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for tag_name in raw_tags {
|
||||
let trimmed = tag_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let slug = slugify(trimmed);
|
||||
if slug.is_empty() || !seen.insert(slug.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let canonical_name = if let Some(tag) = existing {
|
||||
if let Some(existing_name) = tag.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
existing_name.to_string()
|
||||
} else {
|
||||
let mut tag_model = tag.into_active_model();
|
||||
tag_model.name = Set(Some(trimmed.to_string()));
|
||||
tag_model
|
||||
.update(&ctx.db)
|
||||
.await?
|
||||
.name
|
||||
.unwrap_or_else(|| trimmed.to_string())
|
||||
}
|
||||
} else {
|
||||
tags::ActiveModel {
|
||||
name: Set(Some(trimmed.to_string())),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
.name
|
||||
.unwrap_or_else(|| trimmed.to_string())
|
||||
};
|
||||
|
||||
canonical_tags.push(canonical_name);
|
||||
}
|
||||
|
||||
Ok(canonical_tags)
|
||||
}
|
||||
|
||||
async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
||||
let existing_tags = tags::Entity::find()
|
||||
.order_by_asc(tags::Column::Id)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for tag in existing_tags {
|
||||
let key = if tag.slug.trim().is_empty() {
|
||||
tag.name
|
||||
.as_deref()
|
||||
.map(slugify)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
slugify(&tag.slug)
|
||||
};
|
||||
|
||||
if key.is_empty() || seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tag.delete(&ctx.db).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
|
||||
let existing_categories = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Id)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for category in existing_categories {
|
||||
let key = if category.slug.trim().is_empty() {
|
||||
category
|
||||
.name
|
||||
.as_deref()
|
||||
.map(slugify)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
slugify(&category.slug)
|
||||
};
|
||||
|
||||
if key.is_empty() || seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = category.delete(&ctx.db).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
|
||||
let markdown_posts = load_markdown_posts_from_disk()?;
|
||||
|
||||
for post in &markdown_posts {
|
||||
let canonical_category = match post.category.as_deref() {
|
||||
Some(category) => ensure_category(ctx, category).await?,
|
||||
None => None,
|
||||
};
|
||||
let canonical_tags = canonicalize_tags(ctx, &post.tags).await?;
|
||||
|
||||
let existing = posts::Entity::find()
|
||||
.filter(posts::Column::Slug.eq(&post.slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
let has_existing = existing.is_some();
|
||||
|
||||
let mut model = existing
|
||||
.map(|item| item.into_active_model())
|
||||
.unwrap_or_default();
|
||||
|
||||
model.title = Set(Some(post.title.clone()));
|
||||
model.slug = Set(post.slug.clone());
|
||||
model.description = Set(post.description.clone());
|
||||
model.content = Set(Some(post.content.clone()));
|
||||
model.category = Set(canonical_category);
|
||||
model.tags = Set(if canonical_tags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(
|
||||
canonical_tags.into_iter().map(Value::String).collect(),
|
||||
))
|
||||
});
|
||||
model.post_type = Set(Some(post.post_type.clone()));
|
||||
model.image = Set(post.image.clone());
|
||||
model.pinned = Set(Some(post.pinned));
|
||||
|
||||
if has_existing {
|
||||
let _ = model.update(&ctx.db).await;
|
||||
} else {
|
||||
let _ = model.insert(&ctx.db).await;
|
||||
}
|
||||
}
|
||||
|
||||
sync_tags_from_posts(ctx, &markdown_posts).await?;
|
||||
dedupe_tags(ctx).await?;
|
||||
dedupe_categories(ctx).await?;
|
||||
Ok(markdown_posts)
|
||||
}
|
||||
|
||||
pub fn read_markdown_document(slug: &str) -> Result<(String, String)> {
|
||||
let path = markdown_post_path(slug);
|
||||
if !path.exists() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path).map_err(io_error)?;
|
||||
Ok((path.to_string_lossy().to_string(), raw))
|
||||
}
|
||||
|
||||
pub async fn write_markdown_document(
|
||||
ctx: &AppContext,
|
||||
slug: &str,
|
||||
markdown: &str,
|
||||
) -> Result<MarkdownPost> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
let path = markdown_post_path(slug);
|
||||
fs::write(&path, normalize_newlines(markdown)).map_err(io_error)?;
|
||||
|
||||
let updated = parse_markdown_post(&path)?;
|
||||
sync_markdown_posts(ctx).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn create_markdown_post(
|
||||
ctx: &AppContext,
|
||||
draft: MarkdownPostDraft,
|
||||
) -> Result<MarkdownPost> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
|
||||
let title = draft.title.trim().to_string();
|
||||
if title.is_empty() {
|
||||
return Err(Error::BadRequest("title is required".to_string()));
|
||||
}
|
||||
|
||||
let slug = draft
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| slugify(&title));
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err(Error::BadRequest("slug is required".to_string()));
|
||||
}
|
||||
|
||||
let post = MarkdownPost {
|
||||
title,
|
||||
slug: slug.clone(),
|
||||
description: trim_to_option(draft.description),
|
||||
content: draft.content.trim().to_string(),
|
||||
category: trim_to_option(draft.category),
|
||||
tags: draft
|
||||
.tags
|
||||
.into_iter()
|
||||
.map(|tag| tag.trim().to_string())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect(),
|
||||
post_type: {
|
||||
let normalized = draft.post_type.trim();
|
||||
if normalized.is_empty() {
|
||||
"article".to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
},
|
||||
image: trim_to_option(draft.image),
|
||||
pinned: draft.pinned,
|
||||
published: draft.published,
|
||||
file_path: markdown_post_path(&slug).to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?;
|
||||
sync_markdown_posts(ctx).await?;
|
||||
parse_markdown_post(&markdown_post_path(&slug))
|
||||
}
|
||||
|
||||
pub async fn import_markdown_documents(
|
||||
ctx: &AppContext,
|
||||
files: Vec<MarkdownImportFile>,
|
||||
) -> Result<Vec<MarkdownPost>> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
|
||||
let mut imported_slugs = Vec::new();
|
||||
|
||||
for file in files {
|
||||
let path = Path::new(&file.file_name);
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if extension != "md" && extension != "markdown" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("imported-post")
|
||||
.to_string();
|
||||
let parsed = parse_markdown_source(&file_stem, &file.content, &file.file_name)?;
|
||||
let slug = if parsed.slug.trim().is_empty() {
|
||||
slugify(&file_stem)
|
||||
} else {
|
||||
parsed.slug.clone()
|
||||
};
|
||||
|
||||
if slug.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?;
|
||||
imported_slugs.push(slug);
|
||||
}
|
||||
|
||||
sync_markdown_posts(ctx).await?;
|
||||
|
||||
imported_slugs
|
||||
.into_iter()
|
||||
.map(|slug| parse_markdown_post(&markdown_post_path(&slug)))
|
||||
.collect()
|
||||
}
|
||||
1
backend/src/services/mod.rs
Normal file
1
backend/src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod content;
|
||||
1
backend/src/tasks/mod.rs
Normal file
1
backend/src/tasks/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
41
backend/src/views/auth.rs
Normal file
41
backend/src/views/auth.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::users;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
impl LoginResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
is_verified: user.email_verified_at.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CurrentResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl CurrentResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model) -> Self {
|
||||
Self {
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
backend/src/views/mod.rs
Normal file
1
backend/src/views/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
23
backend/src/workers/downloader.rs
Normal file
23
backend/src/workers/downloader.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct DownloadWorker {
|
||||
pub ctx: AppContext,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct DownloadWorkerArgs {
|
||||
pub user_guid: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
|
||||
fn build(ctx: &AppContext) -> Self {
|
||||
Self { ctx: ctx.clone() }
|
||||
}
|
||||
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
|
||||
// TODO: Some actual work goes here...
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
backend/src/workers/mod.rs
Normal file
1
backend/src/workers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod downloader;
|
||||
Reference in New Issue
Block a user