feat: 个人博客全栈实现(Rust/Axum + React/Tailwind + PostgreSQL)

后端(Axum + sqlx):
- GitHub OAuth 三层权限(管理员/用户/游客),MOCK_OAUTH 本地调试开关
- 文章 CRUD、草稿自动保存、发布/私有分离,发布时渲染 HTML
- 目录(folder 路径分层)、标签、评论、RSS、热门文章(按评论数)
- pages 表驱动动态内容:关于页、首页标题、个人名片
- S3 兼容对象存储图片上传接口(rust-s3,未配置时明确报错)
- 发布时自动提取正文首图作为封面 cover_url
- sqlx migrate 管理 5 个迁移

前端(React + Tailwind):
- 白天 Editorial / 黑夜星空双主题
- 首页(列表 + 热门侧栏 + 可编辑大标题)、文库(目录树+标签+搜索)
- Profile 页(GitHub 风格名片卡,全部字段后台可编辑)
- 后台:文件树文章管理、CodeMirror 编辑器(实时预览/自动保存/传图)
- Markdown 渲染:KaTeX 公式、代码块语言标注 + 双主题语法高亮
- 有封面的文章卡片自动整行展示,全局 ErrorBoundary

部署:
- docker-compose 本地三容器(postgres/backend/frontend)
- k8s manifests(适配 Caddy 终结 TLS → 隧道 → traefik 明文路由)
- 前端静态文件可由 Caddy 直接托管,集群只需 backend + postgres
- 可选 postgres 每日备份到 S3 的 CronJob
This commit is contained in:
2026-07-18 19:15:49 +08:00
commit bfc83a2400
92 changed files with 14221 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
.git/
*.md
.env
+3629
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "blog-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
jsonwebtoken = "9"
pulldown-cmark = "0.11"
rss = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
thiserror = "1"
anyhow = "1"
urlencoding = "2"
+30
View File
@@ -0,0 +1,30 @@
FROM rust:bookworm AS builder
WORKDIR /app
# 缓存依赖层
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release && rm -rf src target/release/blog-backend
# 编译真实代码
COPY src ./src
COPY migrations ./migrations
RUN touch src/main.rs && cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/blog-backend /usr/local/bin/blog-backend
COPY --from=builder /app/migrations ./migrations
ENV BIND_ADDR=0.0.0.0:3000
EXPOSE 3000
USER nobody
CMD ["blog-backend"]
+54
View File
@@ -0,0 +1,54 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TYPE user_role AS ENUM ('user', 'admin');
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
github_id BIGINT NOT NULL UNIQUE,
email TEXT,
name TEXT NOT NULL,
avatar_url TEXT,
role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT,
content_md TEXT NOT NULL DEFAULT '',
content_html TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'draft',
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
CONSTRAINT valid_status CHECK (status IN ('draft', 'private', 'public'))
);
CREATE TABLE IF NOT EXISTS drafts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL UNIQUE REFERENCES posts(id) ON DELETE CASCADE,
content_md TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_posts_status_published_at ON posts(status, published_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments(post_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_drafts_post_id ON drafts(post_id);
CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id);
+2
View File
@@ -0,0 +1,2 @@
-- 文章目录:用 / 分隔的路径字符串(如 "tech/rust"),空串表示根目录
ALTER TABLE posts ADD COLUMN folder TEXT NOT NULL DEFAULT '';
+6
View File
@@ -0,0 +1,6 @@
-- 独立页面(如关于页):slug 唯一,内容为 Markdown
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
content_md TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,2 @@
-- 文章封面图:发布时从 Markdown 正文提取第一张图片的 URL
ALTER TABLE posts ADD COLUMN cover_url TEXT;
@@ -0,0 +1,2 @@
-- about 页更名为 profile 页
UPDATE pages SET slug = 'profile' WHERE slug = 'about';
+131
View File
@@ -0,0 +1,131 @@
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
http::request::Parts,
response::{IntoResponse, Redirect, Response},
};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::{config::Config, error::AppError, models::UserRole, state::AppState};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub role: String,
pub exp: i64,
}
#[derive(Debug, Clone)]
pub enum AuthUser {
Guest,
User { id: String },
Admin { id: String },
}
impl AuthUser {
pub fn require_admin(&self) -> crate::error::Result<String> {
match self {
AuthUser::Admin { id } => Ok(id.clone()),
_ => Err(AppError::Forbidden("admin required".to_string())),
}
}
pub fn require_user(&self) -> crate::error::Result<String> {
match self {
AuthUser::User { id } | AuthUser::Admin { id } => Ok(id.clone()),
AuthUser::Guest => Err(AppError::Unauthorized("login required".to_string())),
}
}
pub fn user_id(&self) -> Option<&str> {
match self {
AuthUser::User { id } | AuthUser::Admin { id } => Some(id),
AuthUser::Guest => None,
}
}
pub fn role_name(&self) -> &'static str {
match self {
AuthUser::Guest => "guest",
AuthUser::User { .. } => "user",
AuthUser::Admin { .. } => "admin",
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = AppState::from_ref(state);
let token = parts
.headers
.get("authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(|s| s.to_string());
let Some(token) = token else {
return Ok(AuthUser::Guest);
};
let claims = decode::<Claims>(
&token,
&DecodingKey::from_secret(state.config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|e| AppError::Unauthorized(format!("invalid token: {e}")).into_response())?
.claims;
match claims.role.as_str() {
"admin" => Ok(AuthUser::Admin { id: claims.sub }),
_ => Ok(AuthUser::User { id: claims.sub }),
}
}
}
pub fn issue_token(user_id: &str, role: UserRole, secret: &str) -> crate::error::Result<String> {
let claims = Claims {
sub: user_id.to_string(),
role: match role {
UserRole::Admin => "admin".to_string(),
UserRole::User => "user".to_string(),
},
exp: (Utc::now() + Duration::days(7)).timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
pub fn github_login_url(config: &Config, redirect: &str) -> String {
// mock 模式下不跳转 GitHub,直接回调本地接口,伪造一次授权成功
if config.mock_oauth {
return format!(
"{}/api/auth/github/callback?code=mock-code&state={}",
config.app_url.trim_end_matches('/'),
urlencoding::encode(redirect),
);
}
format!(
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}/api/auth/github/callback&state={}",
urlencoding::encode(&config.github_client_id),
urlencoding::encode(&config.app_url),
urlencoding::encode(redirect),
)
}
pub fn redirect_with_token(frontend_url: &str, token: &str) -> Redirect {
Redirect::temporary(&format!("{}#token={}", frontend_url.trim_end_matches('/'), token))
}
+48
View File
@@ -0,0 +1,48 @@
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind_addr: String,
pub jwt_secret: String,
pub github_client_id: String,
pub github_client_secret: String,
pub github_admin_id: i64,
pub app_url: String,
pub frontend_url: String,
/// 开启后跳过真实 GitHub OAuth,直接以管理员身份签发 token(仅本地调试用)
pub mock_oauth: bool,
/// S3 兼容对象存储配置,s3_endpoint 为空表示未启用上传
pub s3_endpoint: String,
pub s3_region: String,
pub s3_bucket: String,
pub s3_access_key: String,
pub s3_secret_key: String,
/// 上传后对外访问的 URL 前缀(如 https://cdn.example.com/bucket)
pub s3_public_url: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
database_url: env::var("DATABASE_URL")?,
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string()),
jwt_secret: env::var("JWT_SECRET")?,
github_client_id: env::var("GITHUB_CLIENT_ID")?,
github_client_secret: env::var("GITHUB_CLIENT_SECRET")?,
github_admin_id: env::var("GITHUB_ADMIN_ID")?.parse::<i64>()?,
app_url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()),
frontend_url: env::var("FRONTEND_URL")
.unwrap_or_else(|_| "http://localhost:5173".to_string()),
mock_oauth: env::var("MOCK_OAUTH")
.map(|v| v == "true" || v == "1")
.unwrap_or(false),
s3_endpoint: env::var("S3_ENDPOINT").unwrap_or_default(),
s3_region: env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
s3_bucket: env::var("S3_BUCKET").unwrap_or_default(),
s3_access_key: env::var("S3_ACCESS_KEY").unwrap_or_default(),
s3_secret_key: env::var("S3_SECRET_KEY").unwrap_or_default(),
s3_public_url: env::var("S3_PUBLIC_URL").unwrap_or_default(),
})
}
}
+67
View File
@@ -0,0 +1,67 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()),
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::RowNotFound => AppError::NotFound("resource not found".to_string()),
sqlx::Error::Database(db) if db.constraint().is_some() => {
AppError::BadRequest(format!("constraint violation: {}", db.message()))
}
_ => AppError::Internal(e.to_string()),
}
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::Internal(e.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(e: reqwest::Error) -> Self {
AppError::Internal(format!("http client error: {e}"))
}
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
AppError::Internal(format!("jwt error: {e}"))
}
}
+80
View File
@@ -0,0 +1,80 @@
mod auth;
mod config;
mod error;
mod markdown;
mod models;
mod routes;
mod state;
use axum::{
http::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
Method,
},
Router,
};
use sqlx::postgres::PgPoolOptions;
use tower_http::{
compression::CompressionLayer,
cors::{AllowOrigin, CorsLayer},
trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{config::Config, state::AppState};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "blog_backend=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
tracing::info!("starting blog backend");
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&config.database_url)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
tracing::info!("database migrations applied");
let state = AppState {
config: config.clone(),
pool,
};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::exact(config.frontend_url.parse()?))
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
.allow_credentials(true);
let app = Router::new()
.merge(routes::router())
.layer(cors)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!("listening on {}", config.bind_addr);
axum::serve(listener, app).await?;
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
use pulldown_cmark::{html, Event, Options, Parser, Tag};
pub fn render(content: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(content, opts);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
/// 提取正文里第一张图片的 URL 作为封面,没有图片返回 None
pub fn extract_cover_url(content: &str) -> Option<String> {
Parser::new(content).find_map(|event| match event {
Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url.to_string()),
_ => None,
})
}
+127
View File
@@ -0,0 +1,127 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub github_id: i64,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
pub role: UserRole,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
User,
Admin,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Post {
pub id: Uuid,
pub slug: String,
pub title: String,
pub summary: Option<String>,
pub content_md: String,
pub content_html: String,
pub tags: Vec<String>,
pub folder: String,
pub cover_url: Option<String>,
pub status: PostStatus,
pub author_id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum PostStatus {
Draft,
Private,
Public,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Draft {
pub id: Uuid,
pub post_id: Uuid,
pub content_md: String,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct PostSummary {
pub id: Uuid,
pub slug: String,
pub title: String,
pub summary: Option<String>,
pub status: PostStatus,
pub published_at: Option<DateTime<Utc>>,
pub tags: Vec<String>,
pub folder: String,
pub cover_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PostDetail {
pub id: Uuid,
pub slug: String,
pub title: String,
pub summary: Option<String>,
pub content_md: String,
pub status: PostStatus,
pub published_at: Option<DateTime<Utc>>,
pub tags: Vec<String>,
pub folder: String,
pub cover_url: Option<String>,
pub author: Author,
}
#[derive(Debug, Clone, Serialize)]
pub struct Author {
pub id: Uuid,
pub name: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CommentView {
pub id: Uuid,
pub content: String,
pub created_at: DateTime<Utc>,
pub author: Author,
}
#[derive(Debug, Clone, Serialize)]
pub struct TagCount {
pub name: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct FolderCount {
pub name: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct HotPost {
#[serde(flatten)]
pub post: PostSummary,
pub comment_count: i64,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ArchiveItem {
pub slug: String,
pub title: String,
pub folder: String,
pub published_at: Option<DateTime<Utc>>,
}
+395
View File
@@ -0,0 +1,395 @@
use axum::{
extract::{Path, State},
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::Row;
use uuid::Uuid;
use crate::{
auth::AuthUser,
error::{AppError, Result},
markdown,
models::{Author, Post, PostDetail, PostSummary},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/posts", post(create_post).get(list_posts))
.route("/api/admin/posts/:id", get(get_post).put(update_post).delete(delete_post))
.route("/api/admin/posts/:id/draft", post(save_draft).get(get_draft))
.route("/api/admin/posts/:id/publish", post(publish_post))
.route("/api/admin/posts/:id/unpublish", post(unpublish_post))
.route("/api/admin/preview", post(preview))
.route("/api/admin/comments/:id", delete(delete_comment))
}
async fn list_posts(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Vec<PostSummary>>> {
auth.require_admin()?;
let posts = sqlx::query_as::<_, PostSummary>(
r#"
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
FROM posts
ORDER BY created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(posts))
}
async fn get_post(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<PostDetail>> {
auth.require_admin()?;
let row = sqlx::query(
r#"
SELECT
p.id, p.slug, p.title, p.summary, p.content_md, p.status,
p.published_at, p.tags, p.folder, p.cover_url,
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.id = $1
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("post not found".to_string()))?;
Ok(Json(row_to_post_detail(row)?))
}
#[derive(Deserialize)]
struct CreatePostBody {
slug: String,
title: String,
summary: Option<String>,
tags: Option<Vec<String>>,
folder: Option<String>,
content_md: Option<String>,
}
async fn create_post(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CreatePostBody>,
) -> Result<Json<Post>> {
let admin_id = auth.require_admin()?;
let admin_uuid = Uuid::parse_str(&admin_id).map_err(|e| AppError::BadRequest(e.to_string()))?;
let slug = body.slug.trim().to_string();
let title = body.title.trim().to_string();
if slug.is_empty() || title.is_empty() {
return Err(AppError::BadRequest("slug and title are required".to_string()));
}
let tags: Vec<String> = body
.tags
.unwrap_or_default()
.into_iter()
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect();
let content_md = body.content_md.unwrap_or_default();
let content_html = markdown::render(&content_md);
let cover_url = markdown::extract_cover_url(&content_md);
let folder = normalize_folder(body.folder.as_deref());
let post: Post = sqlx::query_as(
r#"
INSERT INTO posts (slug, title, summary, tags, folder, cover_url, content_md, content_html, author_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
RETURNING *
"#,
)
.bind(&slug)
.bind(&title)
.bind(body.summary.as_ref())
.bind(&tags)
.bind(&folder)
.bind(cover_url.as_ref())
.bind(&content_md)
.bind(&content_html)
.bind(admin_uuid)
.fetch_one(&state.pool)
.await?;
Ok(Json(post))
}
#[derive(Deserialize)]
struct UpdatePostBody {
slug: Option<String>,
title: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
folder: Option<String>,
}
/// 规范化目录路径:去掉首尾斜杠和空白,"a//b/" -> "a/b"
fn normalize_folder(input: Option<&str>) -> String {
input
.unwrap_or("")
.split('/')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("/")
}
async fn update_post(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<UpdatePostBody>,
) -> Result<Json<Post>> {
auth.require_admin()?;
let post: Post = sqlx::query_as(
r#"
UPDATE posts
SET slug = COALESCE($2, slug),
title = COALESCE($3, title),
summary = COALESCE($4, summary),
tags = COALESCE($5, tags),
folder = COALESCE($6, folder),
updated_at = NOW()
WHERE id = $1
RETURNING *
"#,
)
.bind(id)
.bind(
body.slug
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
)
.bind(
body.title
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
)
.bind(body.summary.as_ref())
.bind(body.tags.as_ref().map(|tags| {
tags.iter()
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
}))
.bind(body.folder.as_deref().map(|f| normalize_folder(Some(f))))
.fetch_one(&state.pool)
.await?;
Ok(Json(post))
}
async fn delete_post(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
sqlx::query("DELETE FROM posts WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({"ok": true})))
}
#[derive(Deserialize)]
struct SaveDraftBody {
content_md: String,
}
async fn save_draft(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SaveDraftBody>,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
sqlx::query(
r#"
INSERT INTO drafts (post_id, content_md, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (post_id) DO UPDATE SET
content_md = EXCLUDED.content_md,
updated_at = NOW()
"#,
)
.bind(id)
.bind(&body.content_md)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({"ok": true})))
}
async fn get_draft(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
let draft: Option<crate::models::Draft> = sqlx::query_as("SELECT * FROM drafts WHERE post_id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await?;
Ok(Json(serde_json::json!({
"content_md": draft.map(|d| d.content_md).unwrap_or_default(),
})))
}
async fn publish_post(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<PostDetail>> {
auth.require_admin()?;
let draft: Option<crate::models::Draft> = sqlx::query_as("SELECT * FROM drafts WHERE post_id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await?;
let content_md = if let Some(d) = draft {
d.content_md
} else {
let row = sqlx::query("SELECT content_md FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("post not found".to_string()))?;
row.get::<String, _>("content_md")
};
let content_html = markdown::render(&content_md);
let cover_url = markdown::extract_cover_url(&content_md);
let row = sqlx::query(
r#"
UPDATE posts
SET content_md = $2,
content_html = $3,
cover_url = $4,
status = 'public',
published_at = COALESCE(published_at, NOW()),
updated_at = NOW()
FROM users u
WHERE posts.id = $1 AND u.id = posts.author_id
RETURNING
posts.id, posts.slug, posts.title, posts.summary,
posts.content_md, posts.status,
posts.published_at, posts.tags, posts.folder, posts.cover_url,
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
"#,
)
.bind(id)
.bind(&content_md)
.bind(&content_html)
.bind(cover_url.as_ref())
.fetch_one(&state.pool)
.await?;
Ok(Json(row_to_post_detail(row)?))
}
async fn unpublish_post(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<PostDetail>> {
auth.require_admin()?;
let row = sqlx::query(
r#"
UPDATE posts
SET status = 'private', updated_at = NOW()
FROM users u
WHERE posts.id = $1 AND u.id = posts.author_id
RETURNING
posts.id, posts.slug, posts.title, posts.summary,
posts.content_md, posts.status,
posts.published_at, posts.tags, posts.folder, posts.cover_url,
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
"#,
)
.bind(id)
.fetch_one(&state.pool)
.await?;
Ok(Json(row_to_post_detail(row)?))
}
#[derive(Deserialize)]
struct PreviewBody {
content_md: String,
}
async fn preview(auth: AuthUser, Json(body): Json<PreviewBody>) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
let html = markdown::render(&body.content_md);
Ok(Json(serde_json::json!({ "content_html": html })))
}
async fn delete_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
sqlx::query("UPDATE comments SET deleted_at = NOW() WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({"ok": true})))
}
fn row_to_post_detail(row: sqlx::postgres::PgRow) -> Result<PostDetail> {
Ok(PostDetail {
id: row.try_get("id").map_err(|e| AppError::Internal(e.to_string()))?,
slug: row.try_get("slug").map_err(|e| AppError::Internal(e.to_string()))?,
title: row.try_get("title").map_err(|e| AppError::Internal(e.to_string()))?,
summary: row.try_get("summary").map_err(|e| AppError::Internal(e.to_string()))?,
content_md: row.try_get("content_md").map_err(|e| AppError::Internal(e.to_string()))?,
status: row.try_get("status").map_err(|e| AppError::Internal(e.to_string()))?,
published_at: row
.try_get("published_at")
.map_err(|e| AppError::Internal(e.to_string()))?,
tags: row.try_get("tags").map_err(|e| AppError::Internal(e.to_string()))?,
folder: row
.try_get("folder")
.map_err(|e| AppError::Internal(e.to_string()))?,
cover_url: row
.try_get("cover_url")
.map_err(|e| AppError::Internal(e.to_string()))?,
author: Author {
id: row.try_get("author_id").map_err(|e| AppError::Internal(e.to_string()))?,
name: row
.try_get("author_name")
.map_err(|e| AppError::Internal(e.to_string()))?,
avatar_url: row
.try_get("author_avatar")
.map_err(|e| AppError::Internal(e.to_string()))?,
},
})
}
+206
View File
@@ -0,0 +1,206 @@
use axum::{
extract::{Query, State},
response::Redirect,
routing::get,
Json, Router,
};
use reqwest::header;
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use crate::{
auth::{issue_token, redirect_with_token, AuthUser},
error::{AppError, Result},
models::{Author, User, UserRole},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/auth/github", get(github_login))
.route("/api/auth/github/callback", get(github_callback))
.route("/api/auth/me", get(me))
}
#[derive(Deserialize)]
struct LoginQuery {
redirect: Option<String>,
}
async fn github_login(
State(state): State<AppState>,
Query(q): Query<LoginQuery>,
) -> Result<Redirect> {
let redirect = q.redirect.unwrap_or_else(|| state.config.frontend_url.clone());
let frontend = state.config.frontend_url.trim_end_matches('/');
// 白名单校验:redirect 必须以 frontend_url 为前缀
if !redirect.starts_with(frontend) {
return Err(AppError::BadRequest(
"invalid redirect url".to_string(),
));
}
let url = crate::auth::github_login_url(&state.config, &redirect);
Ok(Redirect::temporary(&url))
}
#[derive(Deserialize)]
struct CallbackQuery {
code: String,
state: String,
}
#[derive(Deserialize)]
struct GitHubTokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct GitHubUser {
id: i64,
login: String,
email: Option<String>,
avatar_url: Option<String>,
}
async fn github_callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
) -> Result<Redirect> {
// 校验 state 必须是 frontend_url 前缀
let frontend = state.config.frontend_url.trim_end_matches('/');
if !q.state.starts_with(frontend) {
return Err(AppError::BadRequest(
"invalid oauth state".to_string(),
));
}
let github_user: GitHubUser = if state.config.mock_oauth {
// mock 模式:跳过 GitHub 令牌交换,伪造一个管理员用户
GitHubUser {
id: state.config.github_admin_id,
login: "mock-admin".to_string(),
email: Some("mock-admin@localhost".to_string()),
avatar_url: None,
}
} else {
let client = reqwest::Client::new();
let token_response = client
.post("https://github.com/login/oauth/access_token")
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(format!(
"client_id={}&client_secret={}&code={}",
urlencoding::encode(&state.config.github_client_id),
urlencoding::encode(&state.config.github_client_secret),
urlencoding::encode(&q.code)
))
.send()
.await
.map_err(|e| AppError::Internal(format!("github token request failed: {e}")))?;
let status = token_response.status();
if !status.is_success() {
let text = token_response
.text()
.await
.unwrap_or_else(|_| "unknown".to_string());
return Err(AppError::Internal(format!(
"github token error {}: {}",
status.as_u16(),
text
)));
}
let token_res: GitHubTokenResponse = token_response
.json()
.await
.map_err(|e| AppError::Internal(format!("github token decode failed: {e}")))?;
let user_response = client
.get("https://api.github.com/user")
.header(header::AUTHORIZATION, format!("Bearer {}", token_res.access_token))
.header(header::USER_AGENT, "blog-backend")
.send()
.await
.map_err(|e| AppError::Internal(format!("github user request failed: {e}")))?;
let user_status = user_response.status();
if !user_status.is_success() {
let text = user_response
.text()
.await
.unwrap_or_else(|_| "unknown".to_string());
return Err(AppError::Internal(format!(
"github user error {}: {}",
user_status.as_u16(),
text
)));
}
user_response
.json()
.await
.map_err(|e| AppError::Internal(format!("github user decode failed: {e}")))?
};
let role = if github_user.id == state.config.github_admin_id {
UserRole::Admin
} else {
UserRole::User
};
let row = sqlx::query(
r#"
INSERT INTO users (github_id, name, email, avatar_url, role)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (github_id) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email,
avatar_url = EXCLUDED.avatar_url,
role = EXCLUDED.role,
updated_at = NOW()
RETURNING id
"#,
)
.bind(github_user.id)
.bind(&github_user.login)
.bind(github_user.email.as_ref())
.bind(github_user.avatar_url.as_ref())
.bind(role.clone())
.fetch_one(&state.pool)
.await?;
let user_id: uuid::Uuid = row.get("id");
let token = issue_token(&user_id.to_string(), role, &state.config.jwt_secret)?;
Ok(redirect_with_token(&q.state, &token))
}
async fn me(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<serde_json::Value>> {
let user_id = match auth.user_id() {
Some(id) => id,
None => return Ok(Json(json!({"role": "guest"}))),
};
let uuid = uuid::Uuid::parse_str(user_id).map_err(|e| AppError::Unauthorized(e.to_string()))?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(uuid)
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({
"role": auth.role_name(),
"user": Author {
id: user.id,
name: user.name,
avatar_url: user.avatar_url,
},
})))
}
+20
View File
@@ -0,0 +1,20 @@
use axum::Router;
use crate::state::AppState;
pub mod admin;
pub mod auth;
pub mod pages;
pub mod public;
pub mod upload;
pub mod user;
pub fn router() -> Router<AppState> {
Router::new()
.merge(public::router())
.merge(auth::router())
.merge(user::router())
.merge(admin::router())
.merge(pages::router())
.merge(upload::router())
}
+73
View File
@@ -0,0 +1,73 @@
use axum::{
extract::{Path, State},
routing::get,
Json, Router,
};
use serde::Deserialize;
use sqlx::Row;
use crate::{
auth::AuthUser,
error::Result,
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/pages/:slug", get(get_page))
.route("/api/admin/pages/:slug", axum::routing::put(put_page))
}
async fn get_page(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<serde_json::Value>> {
let row = sqlx::query("SELECT content_md, updated_at FROM pages WHERE slug = $1")
.bind(&slug)
.fetch_optional(&state.pool)
.await?;
let (content_md, updated_at) = match row {
Some(r) => (
r.get::<String, _>("content_md"),
r.get::<chrono::DateTime<chrono::Utc>, _>("updated_at"),
),
None => (String::new(), chrono::Utc::now()),
};
Ok(Json(serde_json::json!({
"slug": slug,
"content_md": content_md,
"updated_at": updated_at,
})))
}
#[derive(Deserialize)]
struct PutPageBody {
content_md: String,
}
async fn put_page(
State(state): State<AppState>,
auth: AuthUser,
Path(slug): Path<String>,
Json(body): Json<PutPageBody>,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
sqlx::query(
r#"
INSERT INTO pages (slug, content_md, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (slug) DO UPDATE SET
content_md = EXCLUDED.content_md,
updated_at = NOW()
"#,
)
.bind(&slug)
.bind(&body.content_md)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+294
View File
@@ -0,0 +1,294 @@
use axum::{
extract::{Path, Query, State},
http::header::CONTENT_TYPE,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use chrono::Utc;
use rss::{ChannelBuilder, ItemBuilder};
use serde::Deserialize;
use sqlx::Row;
use crate::{
error::{AppError, Result},
models::{ArchiveItem, Author, CommentView, FolderCount, HotPost, PostDetail, PostSummary, TagCount},
state::AppState,
};
#[derive(Deserialize)]
pub struct ListPostsQuery {
tag: Option<String>,
q: Option<String>,
folder: Option<String>,
page: Option<i64>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/health", get(health))
.route("/api/posts", get(list_posts))
.route("/api/posts/hot", get(hot_posts))
.route("/api/posts/:slug", get(get_post))
.route("/api/posts/:slug/comments", get(list_comments))
.route("/api/tags", get(list_tags))
.route("/api/folders", get(list_folders))
.route("/api/archive", get(archive))
.route("/api/rss.xml", get(rss_feed))
}
async fn health() -> &'static str {
"ok"
}
const PAGE_SIZE: i64 = 20;
async fn list_posts(
State(state): State<AppState>,
Query(q): Query<ListPostsQuery>,
) -> Result<Json<Vec<PostSummary>>> {
let page = q.page.unwrap_or(1).max(1);
let offset = (page - 1) * PAGE_SIZE;
let tag = q.tag.as_deref().filter(|s| !s.is_empty());
let keyword = q.q.as_deref().filter(|s| !s.is_empty());
let folder = q.folder.as_deref().filter(|s| !s.is_empty());
let posts = sqlx::query_as::<_, PostSummary>(
r#"
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
FROM posts
WHERE status = 'public'
AND ($1::text IS NULL OR $1 = ANY(tags))
AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' OR summary ILIKE '%' || $2 || '%')
AND ($3::text IS NULL OR folder = $3 OR folder LIKE $3 || '/%')
ORDER BY published_at DESC NULLS LAST
LIMIT $4 OFFSET $5
"#,
)
.bind(tag)
.bind(keyword)
.bind(folder)
.bind(PAGE_SIZE)
.bind(offset)
.fetch_all(&state.pool)
.await?;
Ok(Json(posts))
}
/// 归档:所有公开文章的轻量列表(无正文),供前台构建完整文件树
async fn archive(State(state): State<AppState>) -> Result<Json<Vec<ArchiveItem>>> {
let items = sqlx::query_as::<_, ArchiveItem>(
r#"
SELECT slug, title, folder, published_at
FROM posts
WHERE status = 'public'
ORDER BY folder, published_at DESC NULLS LAST
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(items))
}
/// 目录列表:公开文章按 folder 分组计数
async fn list_folders(State(state): State<AppState>) -> Result<Json<Vec<FolderCount>>> {
let folders = sqlx::query_as::<_, FolderCount>(
r#"
SELECT folder AS name, COUNT(*) AS count
FROM posts
WHERE status = 'public'
GROUP BY folder
ORDER BY folder
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(folders))
}
/// 热门文章:按评论数排序取前 5
async fn hot_posts(State(state): State<AppState>) -> Result<Json<Vec<HotPost>>> {
let rows = sqlx::query(
r#"
SELECT
p.id, p.slug, p.title, p.summary, p.status, p.published_at, p.tags, p.folder, p.cover_url,
COUNT(c.id) AS comment_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id AND c.deleted_at IS NULL
WHERE p.status = 'public'
GROUP BY p.id
ORDER BY comment_count DESC, p.published_at DESC NULLS LAST
LIMIT 5
"#,
)
.fetch_all(&state.pool)
.await?;
let posts = rows
.into_iter()
.map(|r| HotPost {
post: PostSummary {
id: r.get("id"),
slug: r.get("slug"),
title: r.get("title"),
summary: r.get("summary"),
status: r.get("status"),
published_at: r.get("published_at"),
tags: r.get("tags"),
folder: r.get("folder"),
cover_url: r.get("cover_url"),
},
comment_count: r.get("comment_count"),
})
.collect();
Ok(Json(posts))
}
async fn get_post(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<PostDetail>> {
let row = sqlx::query(
r#"
SELECT
p.id, p.slug, p.title, p.summary, p.content_md, p.status,
p.published_at, p.tags, p.folder, p.cover_url,
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.slug = $1 AND p.status = 'public'
"#,
)
.bind(&slug)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("post '{}' not found", slug)))?;
let post = PostDetail {
id: row.get("id"),
slug: row.get("slug"),
title: row.get("title"),
summary: row.get("summary"),
content_md: row.get("content_md"),
status: row.get("status"),
published_at: row.get("published_at"),
tags: row.get("tags"),
folder: row.get("folder"),
cover_url: row.get("cover_url"),
author: Author {
id: row.get("author_id"),
name: row.get("author_name"),
avatar_url: row.get("author_avatar"),
},
};
Ok(Json(post))
}
async fn list_comments(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Vec<CommentView>>> {
let comments = sqlx::query(
r#"
SELECT
c.id, c.content, c.created_at,
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
FROM comments c
JOIN posts p ON c.post_id = p.id
JOIN users u ON c.user_id = u.id
WHERE p.slug = $1 AND p.status = 'public' AND c.deleted_at IS NULL
ORDER BY c.created_at DESC
"#,
)
.bind(&slug)
.fetch_all(&state.pool)
.await?;
let views = comments
.into_iter()
.map(|r| CommentView {
id: r.get("id"),
content: r.get("content"),
created_at: r.get("created_at"),
author: Author {
id: r.get("author_id"),
name: r.get("author_name"),
avatar_url: r.get("author_avatar"),
},
})
.collect();
Ok(Json(views))
}
async fn list_tags(State(state): State<AppState>) -> Result<Json<Vec<TagCount>>> {
let rows = sqlx::query(
r#"
SELECT tag AS name, COUNT(*) AS count
FROM posts, UNNEST(tags) AS tag
WHERE status = 'public'
GROUP BY tag
ORDER BY count DESC, tag ASC
"#,
)
.fetch_all(&state.pool)
.await?;
let tags = rows
.into_iter()
.map(|r| TagCount {
name: r.get("name"),
count: r.get("count"),
})
.collect();
Ok(Json(tags))
}
async fn rss_feed(State(state): State<AppState>) -> Result<Response> {
let posts = sqlx::query_as::<_, PostSummary>(
r#"
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
FROM posts
WHERE status = 'public' AND published_at IS NOT NULL
ORDER BY published_at DESC
LIMIT 20
"#,
)
.fetch_all(&state.pool)
.await?;
let items: Vec<_> = posts
.into_iter()
.filter_map(|p| {
let pub_date = p.published_at?;
Some(
ItemBuilder::default()
.title(p.title)
.link(format!(
"{}/posts/{}",
state.config.frontend_url.trim_end_matches('/'),
p.slug
))
.description(p.summary.unwrap_or_default())
.pub_date(pub_date.to_rfc2822())
.build(),
)
})
.collect();
let channel = ChannelBuilder::default()
.title("Blog")
.link(state.config.frontend_url.clone())
.description("Personal blog powered by Rust & React")
.last_build_date(Utc::now().to_rfc2822())
.items(items)
.build();
Ok(([(CONTENT_TYPE, "application/rss+xml; charset=utf-8")], channel.to_string()).into_response())
}
+88
View File
@@ -0,0 +1,88 @@
use axum::{
extract::{DefaultBodyLimit, Multipart, State},
routing::post,
Json, Router,
};
use s3::{creds::Credentials, Bucket, Region};
use crate::{
auth::AuthUser,
error::{AppError, Result},
state::AppState,
};
/// 单文件大小上限 15MB
const MAX_UPLOAD_BYTES: usize = 15 * 1024 * 1024;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/upload", post(upload))
.layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))
}
async fn upload(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>> {
auth.require_admin()?;
let cfg = &state.config;
if cfg.s3_endpoint.is_empty() || cfg.s3_bucket.is_empty() {
return Err(AppError::BadRequest(
"object storage is not configured".to_string(),
));
}
let field = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("invalid multipart body: {e}")))?
.ok_or_else(|| AppError::BadRequest("missing file field".to_string()))?;
let content_type = field
.content_type()
.map(str::to_owned)
.unwrap_or_else(|| "application/octet-stream".to_string());
if !content_type.starts_with("image/") {
return Err(AppError::BadRequest("only image uploads are allowed".to_string()));
}
let ext = field
.file_name()
.and_then(|name| name.rsplit('.').next().map(|e| e.to_lowercase()))
.filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or_else(|| "png".to_string());
let data = field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?;
let region = Region::Custom {
region: cfg.s3_region.clone(),
endpoint: cfg.s3_endpoint.clone(),
};
let credentials = Credentials::new(
Some(&cfg.s3_access_key),
Some(&cfg.s3_secret_key),
None,
None,
None,
)
.map_err(|e| AppError::Internal(format!("invalid s3 credentials: {e}")))?;
let bucket = Bucket::new(&cfg.s3_bucket, region, credentials)
.map_err(|e| AppError::Internal(format!("invalid s3 bucket config: {e}")))?
.with_path_style();
let key = format!("uploads/{}.{}", uuid::Uuid::new_v4(), ext);
bucket
.put_object_with_content_type(&key, &data, &content_type)
.await
.map_err(|e| AppError::Internal(format!("s3 upload failed: {e}")))?;
let base = cfg.s3_public_url.trim_end_matches('/');
Ok(Json(serde_json::json!({
"url": format!("{}/{}", base, key),
})))
}
+72
View File
@@ -0,0 +1,72 @@
use axum::{
extract::{Path, State},
routing::post,
Json, Router,
};
use serde::Deserialize;
use sqlx::Row;
use uuid::Uuid;
use crate::{
auth::AuthUser,
error::{AppError, Result},
models::CommentView,
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new().route("/api/posts/:slug/comments", post(create_comment))
}
#[derive(Deserialize)]
struct CreateCommentBody {
content: String,
}
async fn create_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(slug): Path<String>,
Json(body): Json<CreateCommentBody>,
) -> Result<Json<CommentView>> {
let user_id = auth.require_user()?;
let user_uuid = Uuid::parse_str(&user_id).map_err(|e| AppError::BadRequest(e.to_string()))?;
let content = body.content.trim();
if content.is_empty() {
return Err(AppError::BadRequest("comment content is empty".to_string()));
}
let row = sqlx::query(
r#"
INSERT INTO comments (post_id, user_id, content)
SELECT p.id, $2, $3
FROM posts p
WHERE p.slug = $1 AND p.status = 'public'
RETURNING
comments.id,
comments.content,
comments.created_at,
$2 as user_id,
(SELECT name FROM users WHERE id = $2) as author_name,
(SELECT avatar_url FROM users WHERE id = $2) as author_avatar
"#,
)
.bind(&slug)
.bind(user_uuid)
.bind(content)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("public post '{}' not found", slug)))?;
Ok(Json(CommentView {
id: row.get("id"),
content: row.get("content"),
created_at: row.get("created_at"),
author: crate::models::Author {
id: user_uuid,
name: row.get("author_name"),
avatar_url: row.get("author_avatar"),
},
}))
}
+9
View File
@@ -0,0 +1,9 @@
use sqlx::PgPool;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub pool: PgPool,
}