first comm

This commit is contained in:
dela
2026-02-11 15:14:20 +08:00
commit 604f323c34
30 changed files with 5475 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL=sqlite:./blog.db
JWT_SECRET=your-secret-key-at-least-32-chars-long
BIND_ADDR=127.0.0.1:8080
RUST_LOG=info

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/target
.env
*.db
*.db-shm
*.db-wal
uploads/
.sqlx/
/docs

3580
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
Cargo.toml Normal file
View File

@@ -0,0 +1,41 @@
[package]
name = "blog_backend"
version = "0.1.0"
edition = "2021"
[dependencies]
# --- Web Framework ---
actix-web = "4"
actix-cors = "0.7"
actix-files = "0.6"
actix-multipart = "0.7"
actix-governor = "0.6"
futures-util = "0.3"
# --- Async Runtime ---
tokio = { version = "1", features = ["full"] }
# --- Database ---
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
# --- Serialization ---
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
validator = { version = "0.18", features = ["derive"] }
# --- Error Handling ---
thiserror = "2"
anyhow = "1"
# --- Auth & Security ---
argon2 = "0.5"
jsonwebtoken = "9"
rand = "0.8"
# --- Config & Logging ---
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"

293
README.md Normal file
View File

@@ -0,0 +1,293 @@
# Rust Blog Backend
基于 **Rust + Actix-web + SQLite** 的博客后端 API 服务。
## 特性
- 用户注册/登录 (JWT 认证)
- 博客文章 CRUD (创建、读取、更新、软删除)
- 图片上传 (支持 jpg/png/webp含文件类型校验)
- 分页查询
- 请求限流 (Rate Limiting)
- SQLite 数据库 (WAL 模式,高性能)
## 项目结构
```
blog_backend/
├── .env # 环境配置 (不提交到 Git)
├── .env.example # 配置模板
├── Cargo.toml # 依赖清单
├── migrations/ # 数据库迁移文件
├── uploads/ # 上传文件存储目录
├── postman_collection.json # Postman 测试集合
└── src/
├── main.rs # 入口HTTP 服务器启动
├── config.rs # 配置加载
├── db.rs # 数据库连接池
├── error.rs # 统一错误处理
├── models/ # 数据模型 (User, Post)
├── dtos/ # 请求/响应结构体
├── repository/ # 数据库操作层
├── handlers/ # API 控制器
├── middleware/ # JWT 中间件
└── utils/ # 工具函数 (密码哈希、JWT)
```
## 快速开始
### 1. 环境要求
- Rust 1.70+
- SQLite 3
### 2. 安装依赖
```bash
# 安装 sqlx-cli (可选,用于数据库迁移管理)
cargo install sqlx-cli --no-default-features --features sqlite
```
### 3. 配置环境变量
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
DATABASE_URL=sqlite:./blog.db
JWT_SECRET=你的密钥至少32个字符
BIND_ADDR=127.0.0.1:8080
RUST_LOG=debug
```
### 4. 运行
```bash
# 开发模式
cargo run
# 生产构建
cargo build --release
strip target/release/blog_backend # 可选:减小体积
./target/release/blog_backend
```
## API 接口
### 认证
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| POST | `/api/auth/register` | 用户注册 | 否 |
| POST | `/api/auth/login` | 用户登录 | 否 |
**注册/登录请求:**
```json
{
"email": "user@example.com",
"password": "password123"
}
```
**响应:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "uuid",
"email": "user@example.com"
}
}
```
### 文章
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| GET | `/api/posts` | 获取文章列表 (分页) | 否 |
| GET | `/api/posts/:id` | 获取单篇文章 | 否 |
| POST | `/api/posts` | 创建文章 | 是 |
| PUT | `/api/posts/:id` | 更新文章 | 是 |
| DELETE | `/api/posts/:id` | 删除文章 (软删除) | 是 |
**分页参数:**
```
GET /api/posts?page=1&limit=10
```
**创建文章请求:**
```json
{
"title": "文章标题",
"content": "文章内容",
"published": true
}
```
**分页响应:**
```json
{
"data": [...],
"page": 1,
"limit": 10,
"total": 100,
"total_pages": 10
}
```
### 文件上传
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| POST | `/api/upload` | 上传图片 | 是 |
**请求:** `multipart/form-data`,字段名 `file`
**限制:**
- 支持格式jpg, jpeg, png, webp
- 最大大小5MB
- 包含 Magic Number 校验防止伪装文件
**响应:**
```json
{
"filename": "uuid.jpg",
"url": "/uploads/uuid.jpg"
}
```
### 静态文件
| 路径 | 说明 |
|------|------|
| `/uploads/*` | 访问上传的文件 |
## 认证说明
需要认证的接口,请在请求头中添加:
```
Authorization: Bearer <token>
```
## 限流规则
| 接口类型 | 限制 |
|----------|------|
| 全局 | 60 请求/分钟 |
| 认证接口 | 5 请求/分钟 |
| 上传接口 | 5 请求/分钟 |
## 部署
### 1. 构建发布版本
```bash
cargo build --release
strip target/release/blog_backend
```
### 2. 复制文件到服务器
```bash
scp target/release/blog_backend user@server:/opt/blog/
scp .env.example user@server:/opt/blog/.env
scp -r migrations/ user@server:/opt/blog/
```
### 3. 服务器配置
```bash
# 创建目录
mkdir -p /opt/blog/uploads
# 编辑配置
vim /opt/blog/.env
```
生产环境 `.env`
```env
DATABASE_URL=sqlite:./blog.db
JWT_SECRET=生产环境强密钥至少32字符
BIND_ADDR=0.0.0.0:8080
RUST_LOG=warn
```
### 4. Systemd 服务
创建 `/etc/systemd/system/blog.service`
```ini
[Unit]
Description=Rust Blog Backend
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/blog
ExecStart=/opt/blog/blog_backend
Restart=always
RestartSec=5
Environment=RUST_LOG=warn
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl daemon-reload
sudo systemctl enable blog
sudo systemctl start blog
sudo systemctl status blog
```
### 5. Nginx 反向代理 (可选)
```nginx
server {
listen 80;
server_name blog.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /uploads {
alias /opt/blog/uploads;
expires 30d;
}
}
```
## 测试
导入 `postman_collection.json` 到 Postman 进行 API 测试。
测试流程:
1. 运行 **Register****Login** (自动保存 Token)
2. 运行 **Create Post** (自动保存 Post ID)
3. 测试其他接口
## 技术栈
| 组件 | 技术 |
|------|------|
| Web 框架 | Actix-web 4 |
| 数据库 | SQLite + SQLx |
| 认证 | JWT (jsonwebtoken) |
| 密码哈希 | Argon2 |
| 限流 | actix-governor |
| 日志 | tracing |
## License
MIT

View File

@@ -0,0 +1,27 @@
-- Users table
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
-- Posts table
CREATE TABLE posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id TEXT NOT NULL REFERENCES users(id),
published BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
-- Indexes for performance
CREATE INDEX idx_posts_deleted_at ON posts(deleted_at);
CREATE INDEX idx_posts_published ON posts(published) WHERE deleted_at IS NULL;
CREATE INDEX idx_posts_author ON posts(author_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;

510
postman_collection.json Normal file
View File

@@ -0,0 +1,510 @@
{
"info": {
"_postman_id": "blog-backend-collection",
"name": "Rust Blog Backend API",
"description": "API collection for testing the Rust Blog Backend",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "base_url",
"value": "http://127.0.0.1:8080",
"type": "string"
},
{
"key": "token",
"value": "",
"type": "string"
},
{
"key": "post_id",
"value": "",
"type": "string"
}
],
"item": [
{
"name": "Auth",
"item": [
{
"name": "Register",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 201) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('token', jsonData.token);",
" console.log('Token saved:', jsonData.token);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/register",
"host": ["{{base_url}}"],
"path": ["api", "auth", "register"]
},
"description": "Register a new user. Saves the JWT token automatically."
},
"response": []
},
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('token', jsonData.token);",
" console.log('Token saved:', jsonData.token);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/login",
"host": ["{{base_url}}"],
"path": ["api", "auth", "login"]
},
"description": "Login with existing credentials. Saves the JWT token automatically."
},
"response": []
}
],
"description": "Authentication endpoints"
},
{
"name": "Posts",
"item": [
{
"name": "Create Post",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 201) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('post_id', jsonData.id);",
" console.log('Post ID saved:', jsonData.id);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"My First Blog Post\",\n \"content\": \"This is the content of my first blog post. It contains some interesting text about Rust programming.\",\n \"published\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/posts",
"host": ["{{base_url}}"],
"path": ["api", "posts"]
},
"description": "Create a new blog post. Requires authentication."
},
"response": []
},
{
"name": "Create Draft Post",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 201) {",
" var jsonData = pm.response.json();",
" console.log('Draft Post ID:', jsonData.id);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Draft Post - Work in Progress\",\n \"content\": \"This is a draft post that is not yet published.\",\n \"published\": false\n}"
},
"url": {
"raw": "{{base_url}}/api/posts",
"host": ["{{base_url}}"],
"path": ["api", "posts"]
},
"description": "Create a draft post (not published). Requires authentication."
},
"response": []
},
{
"name": "List Posts",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/posts?page=1&limit=10",
"host": ["{{base_url}}"],
"path": ["api", "posts"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number (default: 1)"
},
{
"key": "limit",
"value": "10",
"description": "Items per page (default: 10, max: 50)"
}
]
},
"description": "List all published posts with pagination. No authentication required."
},
"response": []
},
{
"name": "List Posts (Page 2)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/posts?page=2&limit=5",
"host": ["{{base_url}}"],
"path": ["api", "posts"],
"query": [
{
"key": "page",
"value": "2"
},
{
"key": "limit",
"value": "5"
}
]
},
"description": "List posts - second page with 5 items per page."
},
"response": []
},
{
"name": "Get Post by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/posts/{{post_id}}",
"host": ["{{base_url}}"],
"path": ["api", "posts", "{{post_id}}"]
},
"description": "Get a single post by ID. No authentication required."
},
"response": []
},
{
"name": "Update Post",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated Blog Post Title\",\n \"content\": \"This content has been updated with new information.\"\n}"
},
"url": {
"raw": "{{base_url}}/api/posts/{{post_id}}",
"host": ["{{base_url}}"],
"path": ["api", "posts", "{{post_id}}"]
},
"description": "Update an existing post. Requires authentication and ownership."
},
"response": []
},
{
"name": "Update Post (Publish)",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"published\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/posts/{{post_id}}",
"host": ["{{base_url}}"],
"path": ["api", "posts", "{{post_id}}"]
},
"description": "Publish a draft post. Only updates the published field."
},
"response": []
},
{
"name": "Delete Post",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"url": {
"raw": "{{base_url}}/api/posts/{{post_id}}",
"host": ["{{base_url}}"],
"path": ["api", "posts", "{{post_id}}"]
},
"description": "Soft delete a post. Requires authentication and ownership."
},
"response": []
}
],
"description": "Blog post CRUD endpoints"
},
{
"name": "Upload",
"item": [
{
"name": "Upload Image",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "",
"description": "Select a JPG, JPEG, PNG, or WebP image (max 5MB)"
}
]
},
"url": {
"raw": "{{base_url}}/api/upload",
"host": ["{{base_url}}"],
"path": ["api", "upload"]
},
"description": "Upload an image file. Requires authentication. Allowed types: jpg, jpeg, png, webp. Max size: 5MB."
},
"response": []
}
],
"description": "File upload endpoint"
},
{
"name": "Error Cases",
"item": [
{
"name": "Register - Invalid Email",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"invalid-email\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/register",
"host": ["{{base_url}}"],
"path": ["api", "auth", "register"]
},
"description": "Test validation: invalid email format"
},
"response": []
},
{
"name": "Register - Short Password",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test2@example.com\",\n \"password\": \"short\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/register",
"host": ["{{base_url}}"],
"path": ["api", "auth", "register"]
},
"description": "Test validation: password too short (min 8 chars)"
},
"response": []
},
{
"name": "Login - Wrong Password",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"wrongpassword\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/login",
"host": ["{{base_url}}"],
"path": ["api", "auth", "login"]
},
"description": "Test: wrong password returns 401"
},
"response": []
},
{
"name": "Create Post - No Auth",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Unauthorized Post\",\n \"content\": \"This should fail\",\n \"published\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/posts",
"host": ["{{base_url}}"],
"path": ["api", "posts"]
},
"description": "Test: creating post without auth returns 401"
},
"response": []
},
{
"name": "Get Post - Not Found",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/posts/non-existent-id",
"host": ["{{base_url}}"],
"path": ["api", "posts", "non-existent-id"]
},
"description": "Test: getting non-existent post returns 404"
},
"response": []
},
{
"name": "Create Post - Empty Title",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"\",\n \"content\": \"Content without title\",\n \"published\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/posts",
"host": ["{{base_url}}"],
"path": ["api", "posts"]
},
"description": "Test validation: empty title returns 400"
},
"response": []
}
],
"description": "Test error cases and validation"
}
]
}

17
src/config.rs Normal file
View File

@@ -0,0 +1,17 @@
use std::env;
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub bind_addr: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
database_url: env::var("DATABASE_URL")?,
jwt_secret: env::var("JWT_SECRET")?,
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into()),
})
}
}

14
src/db.rs Normal file
View File

@@ -0,0 +1,14 @@
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use std::str::FromStr;
pub async fn init_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
}

29
src/dtos/auth_dto.rs Normal file
View File

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 8, message = "Password must be at least 8 characters"))]
pub password: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserResponse,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: String,
pub email: String,
}

7
src/dtos/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod auth_dto;
pub mod post_dto;
pub mod pagination;
pub use auth_dto::*;
pub use post_dto::*;
pub use pagination::*;

46
src/dtos/pagination.rs Normal file
View File

@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct PaginationParams {
#[validate(range(min = 1))]
pub page: Option<i32>,
#[validate(range(min = 1, max = 50))]
pub limit: Option<i32>,
}
impl PaginationParams {
pub fn page(&self) -> i32 {
self.page.unwrap_or(1)
}
pub fn limit(&self) -> i32 {
self.limit.unwrap_or(10).min(50)
}
pub fn offset(&self) -> i32 {
(self.page() - 1) * self.limit()
}
}
#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub page: i32,
pub limit: i32,
pub total: i64,
pub total_pages: i64,
}
impl<T> PaginatedResponse<T> {
pub fn new(data: Vec<T>, page: i32, limit: i32, total: i64) -> Self {
let total_pages = (total as f64 / limit as f64).ceil() as i64;
Self {
data,
page,
limit,
total,
total_pages,
}
}
}

47
src/dtos/post_dto.rs Normal file
View File

@@ -0,0 +1,47 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct CreatePostRequest {
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
pub title: String,
#[validate(length(min = 1, message = "Content cannot be empty"))]
pub content: String,
#[serde(default)]
pub published: bool,
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdatePostRequest {
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
pub title: Option<String>,
#[validate(length(min = 1, message = "Content cannot be empty"))]
pub content: Option<String>,
pub published: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct PostResponse {
pub id: String,
pub title: String,
pub content: String,
pub author_id: String,
pub published: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
impl From<crate::models::Post> for PostResponse {
fn from(post: crate::models::Post) -> Self {
Self {
id: post.id,
title: post.title,
content: post.content,
author_id: post.author_id,
published: post.published,
created_at: post.created_at,
updated_at: post.updated_at,
}
}
}

58
src/error.rs Normal file
View File

@@ -0,0 +1,58 @@
use actix_web::{HttpResponse, ResponseError};
use thiserror::Error;
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum AppError {
#[error("Database error")]
DbError(#[from] sqlx::Error),
#[error("Unauthorized")]
Unauthorized,
#[error("Not found")]
NotFound,
#[error("Invalid input: {0}")]
ValidationError(String),
#[error("Internal error: {0}")]
InternalError(String),
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error("JWT error: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
match self {
AppError::DbError(sqlx::Error::RowNotFound) => {
HttpResponse::NotFound().json(serde_json::json!({"error": "Not found"}))
}
AppError::DbError(_) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
}
AppError::Unauthorized => {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Unauthorized"}))
}
AppError::NotFound => {
HttpResponse::NotFound().json(serde_json::json!({"error": "Not found"}))
}
AppError::ValidationError(msg) => {
HttpResponse::BadRequest().json(serde_json::json!({"error": msg}))
}
AppError::InternalError(_) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Internal server error"}))
}
AppError::RateLimitExceeded => {
HttpResponse::TooManyRequests().json(serde_json::json!({"error": "Too many requests"}))
}
AppError::JwtError(_) => {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid token"}))
}
}
}
}

80
src/handlers/auth.rs Normal file
View File

@@ -0,0 +1,80 @@
use actix_web::{web, HttpResponse};
use sqlx::SqlitePool;
use validator::Validate;
use crate::dtos::{AuthResponse, LoginRequest, RegisterRequest, UserResponse};
use crate::error::AppError;
use crate::repository::UserRepository;
use crate::utils::{hash_password, verify_password};
use crate::utils::jwt::create_token;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/register").route(web::post().to(register)),
)
.service(
web::resource("/login").route(web::post().to(login)),
);
}
async fn register(
pool: web::Data<SqlitePool>,
jwt_secret: web::Data<String>,
body: web::Json<RegisterRequest>,
) -> Result<HttpResponse, AppError> {
body.validate()
.map_err(|e| AppError::ValidationError(e.to_string()))?;
// Check if user already exists
if UserRepository::find_by_email(&pool, &body.email)
.await?
.is_some()
{
return Err(AppError::ValidationError("Email already registered".into()));
}
// Hash password and create user
let password_hash = hash_password(&body.password)?;
let user = UserRepository::create(&pool, &body.email, &password_hash).await?;
// Generate token
let token = create_token(&user.id, &jwt_secret)?;
Ok(HttpResponse::Created().json(AuthResponse {
token,
user: UserResponse {
id: user.id,
email: user.email,
},
}))
}
async fn login(
pool: web::Data<SqlitePool>,
jwt_secret: web::Data<String>,
body: web::Json<LoginRequest>,
) -> Result<HttpResponse, AppError> {
body.validate()
.map_err(|e| AppError::ValidationError(e.to_string()))?;
// Find user by email
let user = UserRepository::find_by_email(&pool, &body.email)
.await?
.ok_or(AppError::Unauthorized)?;
// Verify password
if !verify_password(&body.password, &user.password_hash)? {
return Err(AppError::Unauthorized);
}
// Generate token
let token = create_token(&user.id, &jwt_secret)?;
Ok(HttpResponse::Ok().json(AuthResponse {
token,
user: UserResponse {
id: user.id,
email: user.email,
},
}))
}

120
src/handlers/blog.rs Normal file
View File

@@ -0,0 +1,120 @@
use actix_web::{web, HttpResponse};
use sqlx::SqlitePool;
use validator::Validate;
use crate::dtos::{CreatePostRequest, PaginatedResponse, PaginationParams, PostResponse, UpdatePostRequest};
use crate::error::AppError;
use crate::middleware::AuthenticatedUser;
use crate::repository::PostRepository;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/posts")
.route(web::get().to(list_posts))
.route(web::post().to(create_post)),
)
.service(
web::resource("/posts/{id}")
.route(web::get().to(get_post))
.route(web::put().to(update_post))
.route(web::delete().to(delete_post)),
);
}
async fn list_posts(
pool: web::Data<SqlitePool>,
query: web::Query<PaginationParams>,
) -> Result<HttpResponse, AppError> {
query
.validate()
.map_err(|e| AppError::ValidationError(e.to_string()))?;
let page = query.page();
let limit = query.limit();
let offset = query.offset();
// Public endpoint: only show published posts
let posts = PostRepository::find_all(&pool, limit, offset, true).await?;
let total = PostRepository::count(&pool, true).await?;
let response: PaginatedResponse<PostResponse> = PaginatedResponse::new(
posts.into_iter().map(PostResponse::from).collect(),
page,
limit,
total,
);
Ok(HttpResponse::Ok().json(response))
}
async fn get_post(
pool: web::Data<SqlitePool>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let post = PostRepository::find_by_id(&pool, &id)
.await?
.ok_or(AppError::NotFound)?;
Ok(HttpResponse::Ok().json(PostResponse::from(post)))
}
async fn create_post(
pool: web::Data<SqlitePool>,
user: AuthenticatedUser,
body: web::Json<CreatePostRequest>,
) -> Result<HttpResponse, AppError> {
body.validate()
.map_err(|e| AppError::ValidationError(e.to_string()))?;
let post = PostRepository::create(&pool, &user.user_id, &body).await?;
Ok(HttpResponse::Created().json(PostResponse::from(post)))
}
async fn update_post(
pool: web::Data<SqlitePool>,
user: AuthenticatedUser,
path: web::Path<String>,
body: web::Json<UpdatePostRequest>,
) -> Result<HttpResponse, AppError> {
body.validate()
.map_err(|e| AppError::ValidationError(e.to_string()))?;
let id = path.into_inner();
// Verify ownership
let existing = PostRepository::find_by_id(&pool, &id)
.await?
.ok_or(AppError::NotFound)?;
if existing.author_id != user.user_id {
return Err(AppError::Unauthorized);
}
let post = PostRepository::update(&pool, &id, &body).await?;
Ok(HttpResponse::Ok().json(PostResponse::from(post)))
}
async fn delete_post(
pool: web::Data<SqlitePool>,
user: AuthenticatedUser,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
// Verify ownership
let existing = PostRepository::find_by_id(&pool, &id)
.await?
.ok_or(AppError::NotFound)?;
if existing.author_id != user.user_id {
return Err(AppError::Unauthorized);
}
PostRepository::soft_delete(&pool, &id).await?;
Ok(HttpResponse::NoContent().finish())
}

3
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod blog;
pub mod upload;

118
src/handlers/upload.rs Normal file
View File

@@ -0,0 +1,118 @@
use actix_multipart::Multipart;
use actix_web::HttpResponse;
use futures_util::StreamExt;
use serde::Serialize;
use std::io::Write;
use uuid::Uuid;
use crate::error::AppError;
use crate::middleware::AuthenticatedUser;
const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5MB
const ALLOWED_EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
const UPLOAD_DIR: &str = "./uploads";
// Magic numbers (file header signatures)
const JPEG_MAGIC: &[u8] = &[0xFF, 0xD8, 0xFF];
const PNG_MAGIC: &[u8] = &[0x89, 0x50, 0x4E, 0x47];
const WEBP_MAGIC: &[u8] = &[0x52, 0x49, 0x46, 0x46]; // "RIFF"
#[derive(Serialize)]
pub struct UploadResponse {
pub filename: String,
pub url: String,
}
pub async fn upload_image(
_user: AuthenticatedUser, // Require authentication
mut payload: Multipart,
) -> Result<HttpResponse, AppError> {
while let Some(item) = payload.next().await {
let mut field = item.map_err(|_| AppError::ValidationError("Invalid multipart data".into()))?;
// 1. Check field name
if field.name() != Some("file") {
continue;
}
// 2. Extract and validate extension
let content_disposition = field.content_disposition()
.ok_or_else(|| AppError::ValidationError("Missing content disposition".into()))?;
let original_filename = content_disposition
.get_filename()
.ok_or_else(|| AppError::ValidationError("Missing filename".into()))?;
let extension = std::path::Path::new(original_filename)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
.ok_or_else(|| AppError::ValidationError("Invalid file extension".into()))?;
if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) {
return Err(AppError::ValidationError(format!(
"File type '{}' not allowed. Allowed: {:?}",
extension, ALLOWED_EXTENSIONS
)));
}
// 3. Read file content with size limit
let mut file_bytes: Vec<u8> = Vec::new();
while let Some(chunk) = field.next().await {
let data = chunk.map_err(|_| AppError::ValidationError("Failed to read chunk".into()))?;
// Real-time size check to prevent memory exhaustion
if file_bytes.len() + data.len() > MAX_FILE_SIZE {
return Err(AppError::ValidationError(format!(
"File exceeds maximum size of {} MB",
MAX_FILE_SIZE / 1024 / 1024
)));
}
file_bytes.extend_from_slice(&data);
}
// 4. Validate magic number (actual file type)
validate_magic_number(&file_bytes, &extension)?;
// 5. Generate safe filename (UUID, never use user input)
let safe_filename = format!("{}.{}", Uuid::new_v4(), extension);
let file_path = format!("{}/{}", UPLOAD_DIR, safe_filename);
// 6. Ensure directory exists and write file
std::fs::create_dir_all(UPLOAD_DIR)
.map_err(|e| AppError::InternalError(format!("Failed to create upload dir: {}", e)))?;
let mut file = std::fs::File::create(&file_path)
.map_err(|e| AppError::InternalError(format!("Failed to create file: {}", e)))?;
file.write_all(&file_bytes)
.map_err(|e| AppError::InternalError(format!("Failed to write file: {}", e)))?;
return Ok(HttpResponse::Ok().json(UploadResponse {
filename: safe_filename.clone(),
url: format!("/uploads/{}", safe_filename),
}));
}
Err(AppError::ValidationError("No file field found".into()))
}
fn validate_magic_number(bytes: &[u8], extension: &str) -> Result<(), AppError> {
if bytes.len() < 12 {
return Err(AppError::ValidationError("File too small to validate".into()));
}
let valid = match extension {
"jpg" | "jpeg" => bytes.starts_with(JPEG_MAGIC),
"png" => bytes.starts_with(PNG_MAGIC),
"webp" => bytes.starts_with(WEBP_MAGIC) && bytes.len() >= 12 && &bytes[8..12] == b"WEBP",
_ => false,
};
if !valid {
return Err(AppError::ValidationError(
"File content does not match declared type (possible disguised file)".into(),
));
}
Ok(())
}

88
src/main.rs Normal file
View File

@@ -0,0 +1,88 @@
use actix_governor::{Governor, GovernorConfigBuilder};
use actix_web::{web, App, HttpServer};
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod db;
mod dtos;
mod error;
mod handlers;
mod middleware;
mod models;
mod repository;
mod utils;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration
dotenvy::dotenv().ok();
let config = config::Config::from_env()?;
// Initialize database
let pool = db::init_pool(&config.database_url).await?;
sqlx::migrate!().run(&pool).await?;
// Rate limiter configuration
let global_governor_config = Arc::new(
GovernorConfigBuilder::default()
.seconds_per_request(1)
.burst_size(60)
.finish()
.unwrap(),
);
let strict_governor_config = Arc::new(
GovernorConfigBuilder::default()
.seconds_per_request(12) // 5 requests per minute
.burst_size(5)
.finish()
.unwrap(),
);
let jwt_secret = config.jwt_secret.clone();
let bind_addr = config.bind_addr.clone();
tracing::info!("Starting server at {}", bind_addr);
HttpServer::new(move || {
let global_limiter = Governor::new(&global_governor_config);
let strict_limiter = Governor::new(&strict_governor_config);
let strict_limiter2 = Governor::new(&strict_governor_config);
App::new()
.app_data(web::Data::new(pool.clone()))
.app_data(web::Data::new(jwt_secret.clone()))
.wrap(actix_cors::Cors::permissive())
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(global_limiter)
.service(actix_files::Files::new("/uploads", "./uploads").show_files_listing())
.service(
web::scope("/api")
.configure(handlers::blog::config)
.service(
web::scope("/auth")
.wrap(strict_limiter)
.configure(handlers::auth::config),
)
.service(
web::resource("/upload")
.wrap(strict_limiter2)
.route(web::post().to(handlers::upload::upload_image)),
),
)
})
.bind(&bind_addr)?
.run()
.await?;
Ok(())
}

45
src/middleware/jwt.rs Normal file
View File

@@ -0,0 +1,45 @@
use actix_web::{dev::Payload, web, FromRequest, HttpRequest};
use std::future::{ready, Ready};
use crate::error::AppError;
use crate::utils::jwt::validate_token;
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: String,
}
impl FromRequest for AuthenticatedUser {
type Error = AppError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let result = extract_user(req);
ready(result)
}
}
fn extract_user(req: &HttpRequest) -> Result<AuthenticatedUser, AppError> {
// Get JWT secret from app data
let jwt_secret = req
.app_data::<web::Data<String>>()
.ok_or(AppError::InternalError("JWT secret not configured".into()))?;
// Extract token from Authorization header
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Validate token
let claims = validate_token(token, jwt_secret)?;
Ok(AuthenticatedUser {
user_id: claims.sub,
})
}

3
src/middleware/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod jwt;
pub use jwt::AuthenticatedUser;

5
src/models/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod user;
pub mod post;
pub use user::User;
pub use post::Post;

15
src/models/post.rs Normal file
View File

@@ -0,0 +1,15 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Post {
pub id: String,
pub title: String,
pub content: String,
pub author_id: String,
pub published: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
}

13
src/models/user.rs Normal file
View File

@@ -0,0 +1,13 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String,
pub email: String,
pub password_hash: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
}

5
src/repository/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod user_repository;
pub mod post_repository;
pub use user_repository::UserRepository;
pub use post_repository::PostRepository;

View File

@@ -0,0 +1,163 @@
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::dtos::{CreatePostRequest, UpdatePostRequest};
use crate::error::AppError;
use crate::models::Post;
pub struct PostRepository;
impl PostRepository {
pub async fn create(
pool: &SqlitePool,
author_id: &str,
req: &CreatePostRequest,
) -> Result<Post, AppError> {
let id = Uuid::new_v4().to_string();
sqlx::query_as::<_, Post>(
r#"
INSERT INTO posts (id, title, content, author_id, published)
VALUES (?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(&id)
.bind(&req.title)
.bind(&req.content)
.bind(author_id)
.bind(req.published)
.fetch_one(pool)
.await
.map_err(AppError::from)
}
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Post>, AppError> {
sqlx::query_as::<_, Post>(
r#"
SELECT * FROM posts
WHERE id = ? AND deleted_at IS NULL
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(AppError::from)
}
pub async fn find_all(
pool: &SqlitePool,
limit: i32,
offset: i32,
published_only: bool,
) -> Result<Vec<Post>, AppError> {
if published_only {
sqlx::query_as::<_, Post>(
r#"
SELECT * FROM posts
WHERE deleted_at IS NULL AND published = TRUE
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(AppError::from)
} else {
sqlx::query_as::<_, Post>(
r#"
SELECT * FROM posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(AppError::from)
}
}
pub async fn count(pool: &SqlitePool, published_only: bool) -> Result<i64, AppError> {
let row: (i64,) = if published_only {
sqlx::query_as(
r#"
SELECT COUNT(*) FROM posts
WHERE deleted_at IS NULL AND published = TRUE
"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_as(
r#"
SELECT COUNT(*) FROM posts
WHERE deleted_at IS NULL
"#,
)
.fetch_one(pool)
.await?
};
Ok(row.0)
}
pub async fn update(
pool: &SqlitePool,
id: &str,
req: &UpdatePostRequest,
) -> Result<Post, AppError> {
let existing = Self::find_by_id(pool, id)
.await?
.ok_or(AppError::NotFound)?;
let title = req.title.as_ref().unwrap_or(&existing.title);
let content = req.content.as_ref().unwrap_or(&existing.content);
let published = req.published.unwrap_or(existing.published);
let now = Utc::now().naive_utc();
sqlx::query_as::<_, Post>(
r#"
UPDATE posts
SET title = ?, content = ?, published = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
RETURNING *
"#,
)
.bind(title)
.bind(content)
.bind(published)
.bind(now)
.bind(id)
.fetch_one(pool)
.await
.map_err(AppError::from)
}
pub async fn soft_delete(pool: &SqlitePool, id: &str) -> Result<(), AppError> {
let now = Utc::now().naive_utc();
let result = sqlx::query(
r#"
UPDATE posts
SET deleted_at = ?
WHERE id = ? AND deleted_at IS NULL
"#,
)
.bind(now)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(())
}
}

View File

@@ -0,0 +1,58 @@
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::User;
pub struct UserRepository;
impl UserRepository {
pub async fn create(
pool: &SqlitePool,
email: &str,
password_hash: &str,
) -> Result<User, AppError> {
let id = Uuid::new_v4().to_string();
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, email, password_hash)
VALUES (?, ?, ?)
RETURNING *
"#,
)
.bind(&id)
.bind(email)
.bind(password_hash)
.fetch_one(pool)
.await
.map_err(AppError::from)
}
pub async fn find_by_email(pool: &SqlitePool, email: &str) -> Result<Option<User>, AppError> {
sqlx::query_as::<_, User>(
r#"
SELECT * FROM users
WHERE email = ? AND deleted_at IS NULL
"#,
)
.bind(email)
.fetch_optional(pool)
.await
.map_err(AppError::from)
}
#[allow(dead_code)]
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<User>, AppError> {
sqlx::query_as::<_, User>(
r#"
SELECT * FROM users
WHERE id = ? AND deleted_at IS NULL
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(AppError::from)
}
}

25
src/utils/hash.rs Normal file
View File

@@ -0,0 +1,25 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use crate::error::AppError;
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| AppError::InternalError(format!("Failed to hash password: {}", e)))
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::InternalError(format!("Invalid password hash: {}", e)))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}

49
src/utils/jwt.rs Normal file
View File

@@ -0,0 +1,49 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // user_id
pub exp: i64, // expiration time
pub iat: i64, // issued at
}
const TOKEN_EXPIRY_DAYS: i64 = 3;
#[allow(dead_code)]
const REFRESH_THRESHOLD_HOURS: i64 = 24;
pub fn create_token(user_id: &str, secret: &str) -> Result<String, AppError> {
let now = Utc::now();
let claims = Claims {
sub: user_id.to_string(),
iat: now.timestamp(),
exp: (now + Duration::days(TOKEN_EXPIRY_DAYS)).timestamp(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(AppError::from)
}
pub fn validate_token(token: &str, secret: &str) -> Result<Claims, AppError> {
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(AppError::from)
}
#[allow(dead_code)]
pub fn should_refresh(claims: &Claims) -> bool {
let now = Utc::now().timestamp();
let remaining = claims.exp - now;
remaining < (REFRESH_THRESHOLD_HOURS * 3600)
}

4
src/utils/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod hash;
pub mod jwt;
pub use hash::*;