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:
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.idea/
|
||||
.git/
|
||||
*.md
|
||||
.env
|
||||
Generated
+3629
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 文章目录:用 / 分隔的路径字符串(如 "tech/rust"),空串表示根目录
|
||||
ALTER TABLE posts ADD COLUMN folder TEXT NOT NULL DEFAULT '';
|
||||
@@ -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';
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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()))?,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
})))
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Config,
|
||||
pub pool: PgPool,
|
||||
}
|
||||
Reference in New Issue
Block a user