✨ feat: 添加本地认证系统和 Kubernetes 部署支持
- 实现本地认证功能(注册、登录、忘记密码、重置密码) - 添加邮件服务模块支持密码重置 - 新增 Kubernetes 部署配置和文档 - 添加生产环境部署指南和脚本 - 数据库迁移支持本地认证 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# ============ Ollama Configuration ============
|
||||
# Local Ollama service URL (default port: 11434)
|
||||
OLLAMA_PROXY_URL=http://127.0.0.1:11434
|
||||
|
||||
# Enable Ollama provider
|
||||
ENABLED_OLLAMA=1
|
||||
|
||||
# Optional: Specify custom models (comma separated)
|
||||
# OLLAMA_MODEL_LIST=llama2,codellama,mistral,qwen
|
||||
|
||||
# ============ Database Configuration (for Knowledge Base) ============
|
||||
# PostgreSQL with PGVector extension
|
||||
DATABASE_URL=postgresql://brunogao@localhost:5432/lingjingwanxiang
|
||||
|
||||
# Use local PostgreSQL driver (not Neon serverless)
|
||||
DATABASE_DRIVER=node
|
||||
|
||||
# Database encryption key (generated with: openssl rand -base64 32)
|
||||
KEY_VAULTS_SECRET=ViDaP5K/rQX0fBY5ggsqupHN4GkQYU95OKO0gieQNkg=
|
||||
|
||||
# ============ Object Storage (for file uploads) ============
|
||||
# MinIO local S3-compatible storage
|
||||
S3_ENDPOINT=http://127.0.0.1:9002
|
||||
S3_BUCKET=lingjingwanxiang
|
||||
S3_PUBLIC_DOMAIN=http://127.0.0.1:9002
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
# ============ Optional: OpenAI (for embeddings) ============
|
||||
# Required for knowledge base embeddings
|
||||
# OPENAI_API_KEY=sk-your-key-here
|
||||
|
||||
# ============ Authentication ============
|
||||
# NextAuth configuration
|
||||
NEXTAUTH_SECRET=iH3sVoHNBBVacCwVijDWk3MSx5E5VZvGua0xy3XGJ2g=
|
||||
NEXT_AUTH_SECRET=iH3sVoHNBBVacCwVijDWk3MSx5E5VZvGua0xy3XGJ2g=
|
||||
NEXTAUTH_URL=http://localhost:3210
|
||||
|
||||
# Enable auth (set to 0 for development without auth)
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
|
||||
|
||||
# ============ Development Settings ============
|
||||
# Keep as development for builds
|
||||
NODE_ENV=development
|
||||
PORT=3210
|
||||
@@ -43,6 +43,11 @@ tmp/
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Kubernetes secrets (DO NOT COMMIT)
|
||||
k8s/.env.k8s
|
||||
k8s/secret.yaml
|
||||
k8s/*.secret.yaml
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*.lock
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# 本地认证系统配置指南
|
||||
|
||||
本文档描述如何启用和配置 LobeChat 的本地认证系统(用户名 / 邮箱 + 密码认证)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
✅ **用户注册方式**
|
||||
|
||||
- 邮箱 + 密码(带邮箱验证流程)
|
||||
- 用户名 + 密码(无需邮箱)
|
||||
- 管理员创建用户
|
||||
|
||||
✅ **认证功能**
|
||||
|
||||
- 登录(支持邮箱或用户名)
|
||||
- 邮箱验证
|
||||
- 密码重置(忘记密码)
|
||||
|
||||
✅ **管理功能**
|
||||
|
||||
- 管理员创建用户
|
||||
- 管理员更新用户密码
|
||||
- 管理员删除用户
|
||||
|
||||
✅ **安全特性**
|
||||
|
||||
- BCrypt 密码加密(10 轮加盐)
|
||||
- 密码强度验证(最少 8 字符,需包含大小写、数字、特殊字符)
|
||||
- 一次性令牌(邮箱验证、密码重置)
|
||||
- 用户数据完全隔离(CASCADE 删除)
|
||||
|
||||
---
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### 1. 启用本地认证
|
||||
|
||||
在 `.env` 文件中添加:
|
||||
|
||||
```bash
|
||||
# 启用 NextAuth
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
|
||||
|
||||
# 启用本地认证
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH=1
|
||||
|
||||
# NextAuth 密钥(必填,用于 JWT 签名)
|
||||
NEXT_AUTH_SECRET=your-secret-key-here
|
||||
|
||||
# NextAuth 会话策略(推荐 database)
|
||||
NEXT_AUTH_SSO_SESSION_STRATEGY=database
|
||||
```
|
||||
|
||||
### 2. 数据库配置(必需)
|
||||
|
||||
本地认证需要 PostgreSQL 数据库:
|
||||
|
||||
```bash
|
||||
# PostgreSQL 连接字符串
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/lobechat
|
||||
|
||||
# 数据库驱动(Neon 或 Node.js)
|
||||
DATABASE_DRIVER=node # 或 neon
|
||||
```
|
||||
|
||||
### 3. 邮件服务配置(可选)
|
||||
|
||||
如果需要邮箱验证和密码重置功能,配置邮件服务:
|
||||
|
||||
```bash
|
||||
# 启用邮件发送
|
||||
EMAIL_ENABLED=true
|
||||
|
||||
# 发件人地址
|
||||
EMAIL_FROM=noreply@your-domain.com
|
||||
|
||||
# 应用基础 URL(用于邮件中的链接)
|
||||
NEXT_PUBLIC_BASE_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
**注意**:EmailService 当前使用日志输出,需要集成实际的邮件服务提供商(SendGrid、AWS SES 等)。
|
||||
|
||||
### 4. 可选配置
|
||||
|
||||
```bash
|
||||
# SSO 提供商(可与本地认证并存)
|
||||
NEXT_AUTH_SSO_PROVIDERS=github,google # 用逗号分隔
|
||||
|
||||
# 调试模式
|
||||
NEXT_AUTH_DEBUG=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
1. **运行 migration**:
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
这会创建以下表:
|
||||
|
||||
- `users` - 添加 `password_hash` 字段和 email 索引
|
||||
- `password_reset_tokens` - 密码重置令牌
|
||||
- `email_verification_tokens` - 邮箱验证令牌
|
||||
|
||||
2. **验证 migration**:
|
||||
|
||||
检查数据库中是否存在新表:
|
||||
|
||||
```sql
|
||||
\dt *tokens
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name='users' AND column_name='password_hash';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 使用示例
|
||||
|
||||
### 用户注册
|
||||
|
||||
#### 邮箱注册
|
||||
|
||||
```typescript
|
||||
const result = await trpc.localAuth.registerWithEmail.mutate({
|
||||
email: 'user@example.com',
|
||||
password: 'StrongPass123!',
|
||||
username: 'johndoe', // 可选
|
||||
firstName: 'John', // 可选
|
||||
lastName: 'Doe', // 可选
|
||||
});
|
||||
// 返回:{ success: true, userId: '...', message: '...' }
|
||||
// 用户会收到验证邮件
|
||||
```
|
||||
|
||||
#### 用户名注册
|
||||
|
||||
```typescript
|
||||
const result = await trpc.localAuth.registerWithUsername.mutate({
|
||||
username: 'johndoe',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'John', // 可选
|
||||
lastName: 'Doe', // 可选
|
||||
});
|
||||
// 返回:{ success: true, userId: '...', message: '...' }
|
||||
```
|
||||
|
||||
### 用户登录
|
||||
|
||||
```typescript
|
||||
const result = await trpc.localAuth.login.mutate({
|
||||
identifier: 'user@example.com', // 或 'johndoe'
|
||||
password: 'StrongPass123!',
|
||||
});
|
||||
// 返回:{ success: true, userId: '...', email: '...', username: '...' }
|
||||
```
|
||||
|
||||
### 邮箱验证
|
||||
|
||||
```typescript
|
||||
// 1. 用户点击邮件中的验证链接,获取 token
|
||||
const result = await trpc.localAuth.verifyEmail.mutate({
|
||||
token: 'verification-token-from-email',
|
||||
});
|
||||
// 返回:{ success: true, message: 'Email verified successfully' }
|
||||
|
||||
// 2. 重新发送验证邮件
|
||||
await trpc.localAuth.resendVerificationEmail.mutate({
|
||||
email: 'user@example.com',
|
||||
});
|
||||
```
|
||||
|
||||
### 密码重置
|
||||
|
||||
```typescript
|
||||
// 1. 请求重置
|
||||
await trpc.localAuth.requestPasswordReset.mutate({
|
||||
email: 'user@example.com',
|
||||
});
|
||||
// 用户收到重置邮件
|
||||
|
||||
// 2. 执行重置
|
||||
await trpc.localAuth.resetPassword.mutate({
|
||||
token: 'reset-token-from-email',
|
||||
password: 'NewStrongPass123!',
|
||||
});
|
||||
```
|
||||
|
||||
### 管理员功能
|
||||
|
||||
需要管理员权限(`admin` 或 `super_admin` 角色):
|
||||
|
||||
```typescript
|
||||
// 创建用户
|
||||
await trpc.localAuth.adminCreateUser.mutate({
|
||||
email: 'newuser@example.com',
|
||||
username: 'newuser',
|
||||
password: 'TempPass123!',
|
||||
emailVerified: true, // 跳过邮箱验证
|
||||
sendWelcomeEmail: true, // 发送欢迎邮件
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
});
|
||||
|
||||
// 更新用户密码
|
||||
await trpc.localAuth.adminUpdateUserPassword.mutate({
|
||||
userId: 'user-id',
|
||||
password: 'NewPass123!',
|
||||
notifyUser: true, // 发送通知邮件
|
||||
});
|
||||
|
||||
// 删除用户
|
||||
await trpc.localAuth.adminDeleteUser.mutate({
|
||||
userId: 'user-id',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NextAuth 集成
|
||||
|
||||
本地认证通过 NextAuth Credentials Provider 实现。NextAuth 会话管理所有认证方法:
|
||||
|
||||
```typescript
|
||||
import { signIn } from 'next-auth/react';
|
||||
// 登出
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
// 使用本地认证登录
|
||||
await signIn('credentials', {
|
||||
identifier: 'user@example.com',
|
||||
password: 'password',
|
||||
callbackUrl: '/',
|
||||
});
|
||||
|
||||
await signOut({ callbackUrl: '/login' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 管理员角色配置
|
||||
|
||||
本地认证使用现有的 RBAC 系统。为用户分配管理员角色:
|
||||
|
||||
```sql
|
||||
-- 创建 admin 角色(如果不存在)
|
||||
INSERT INTO rbac_roles (name, description, is_system)
|
||||
VALUES ('admin', 'Administrator role', true)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- 分配角色给用户
|
||||
INSERT INTO rbac_user_roles (user_id, role_id)
|
||||
VALUES (
|
||||
'user-id-here',
|
||||
(SELECT id FROM rbac_roles WHERE name = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 密码策略
|
||||
|
||||
当前密码策略(在 `src/utils/server/password.ts` 中定义):
|
||||
|
||||
- **最短长度**:8 字符
|
||||
- **最长长度**:128 字符
|
||||
- **必须包含**:
|
||||
- 至少一个小写字母 (a-z)
|
||||
- 至少一个大写字母 (A-Z)
|
||||
- 至少一个数字 (0-9)
|
||||
- 至少一个特殊字符 (!@#$%^&\*(),.?":{}|<>)
|
||||
|
||||
可以在 `validatePasswordStrength()` 函数中修改策略。
|
||||
|
||||
---
|
||||
|
||||
## 邮件模板自定义
|
||||
|
||||
邮件模板位于 `src/server/modules/EmailService.ts`,包含:
|
||||
|
||||
- `sendVerificationEmail()` - 邮箱验证
|
||||
- `sendPasswordResetEmail()` - 密码重置
|
||||
- `sendWelcomeEmail()` - 欢迎邮件
|
||||
|
||||
可以修改 HTML 和文本内容以匹配品牌风格。
|
||||
|
||||
---
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 已实现
|
||||
|
||||
1. ✅ **密码加密**:使用 BCrypt 10 轮加盐
|
||||
2. ✅ **令牌安全**:
|
||||
- 邮箱验证令牌:24 小时有效期
|
||||
- 密码重置令牌:1 小时有效期
|
||||
- 一次性使用(验证后立即删除)
|
||||
3. ✅ **用户隔离**:所有数据库查询都过滤 `userId`
|
||||
4. ✅ **CASCADE 删除**:删除用户时自动清理相关数据
|
||||
5. ✅ **防信息泄露**:密码重置和邮箱重发不透露用户是否存在
|
||||
|
||||
### 建议额外措施
|
||||
|
||||
1. **速率限制**:为登录、注册、密码重置添加速率限制
|
||||
2. **账户锁定**:多次失败登录后锁定账户
|
||||
3. **2FA**:添加双因素认证支持
|
||||
4. **审计日志**:记录认证相关操作
|
||||
5. **密码历史**:防止重复使用旧密码
|
||||
|
||||
---
|
||||
|
||||
## 与现有认证方式共存
|
||||
|
||||
本地认证可以与其他认证方式(Clerk、OAuth)并存:
|
||||
|
||||
```bash
|
||||
# 同时启用多种认证方式
|
||||
NEXT_PUBLIC_ENABLE_CLERK_AUTH=1 # Clerk 托管认证
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=1 # NextAuth (SSO + Local)
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH=1 # 本地认证
|
||||
NEXT_AUTH_SSO_PROVIDERS=github,google # OAuth 提供商
|
||||
```
|
||||
|
||||
用户可以选择任一方式登录。
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题:无法注册用户
|
||||
|
||||
**检查**:
|
||||
|
||||
1. 数据库 migration 是否执行成功
|
||||
2. `NEXT_PUBLIC_ENABLE_LOCAL_AUTH` 是否设置为 `1`
|
||||
3. `NEXT_AUTH_SECRET` 是否配置
|
||||
|
||||
### 问题:验证邮件未发送
|
||||
|
||||
**检查**:
|
||||
|
||||
1. `EMAIL_ENABLED=true` 是否设置
|
||||
2. `NEXT_PUBLIC_BASE_URL` 是否正确
|
||||
3. 查看服务器日志中的 `[EMAIL]` 开头的消息
|
||||
|
||||
### 问题:管理员端点返回 403
|
||||
|
||||
**检查**:
|
||||
|
||||
1. 用户是否被分配了 `admin` 或 `super_admin` 角色
|
||||
2. RBAC 表中是否存在角色记录
|
||||
|
||||
### 问题:数据库错误 "column does not exist"
|
||||
|
||||
**解决**:重新运行 migration
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发和测试
|
||||
|
||||
### 测试数据
|
||||
|
||||
```sql
|
||||
-- 创建测试用户(密码:TestPass123!)
|
||||
INSERT INTO users (id, email, username, password_hash, email_verified_at)
|
||||
VALUES (
|
||||
'test-user-1',
|
||||
'test@example.com',
|
||||
'testuser',
|
||||
'$2a$10$example_hash_here', -- 使用实际的 bcrypt hash
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- 创建管理员角色并分配
|
||||
INSERT INTO rbac_roles (name, description, is_system)
|
||||
VALUES ('admin', 'Administrator', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO rbac_user_roles (user_id, role_id)
|
||||
VALUES ('test-user-1', (SELECT id FROM rbac_roles WHERE name='admin'));
|
||||
```
|
||||
|
||||
### 密码哈希生成
|
||||
|
||||
```typescript
|
||||
import { hashPassword } from '@/utils/server/password';
|
||||
|
||||
const hash = await hashPassword('TestPass123!');
|
||||
console.log(hash);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── server/
|
||||
│ ├── routers/lambda/
|
||||
│ │ └── localAuth.ts # tRPC 路由
|
||||
│ └── modules/
|
||||
│ └── EmailService.ts # 邮件服务
|
||||
├── libs/next-auth/
|
||||
│ ├── auth.config.ts # NextAuth 配置
|
||||
│ └── providers/
|
||||
│ └── credentials.ts # Credentials Provider
|
||||
├── utils/server/
|
||||
│ └── password.ts # 密码工具函数
|
||||
├── envs/
|
||||
│ └── auth.ts # 认证环境变量
|
||||
packages/database/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ │ ├── localAuth.ts # 令牌管理 Model
|
||||
│ │ └── user.ts # 用户 Model (扩展)
|
||||
│ └── schemas/
|
||||
│ └── user.ts # 用户表 Schema (扩展)
|
||||
└── migrations/
|
||||
└── 0048_dark_blue_blade.sql # 本地认证 migration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本功能遵循 LobeChat 项目的开源许可证。
|
||||
@@ -0,0 +1,241 @@
|
||||
# 灵境万象 - 生产模式使用指南
|
||||
|
||||
## 🚀 当前状态
|
||||
|
||||
**应用已运行在生产模式!**
|
||||
|
||||
- **访问地址**: <http://192.168.1.83:3210>
|
||||
- **默认模型**: lingjingwanxiang:32b
|
||||
- **进程管理**: PM2
|
||||
|
||||
---
|
||||
|
||||
## 📋 PM2 常用命令
|
||||
|
||||
### 查看状态
|
||||
|
||||
```bash
|
||||
pm2 status # 查看所有进程状态
|
||||
pm2 info lingjingwanxiang # 查看详细信息
|
||||
pm2 monit # 实时监控
|
||||
```
|
||||
|
||||
### 进程管理
|
||||
|
||||
```bash
|
||||
pm2 restart lingjingwanxiang # 重启应用
|
||||
pm2 reload lingjingwanxiang # 平滑重载(零停机)
|
||||
pm2 stop lingjingwanxiang # 停止应用
|
||||
pm2 start lingjingwanxiang # 启动应用
|
||||
pm2 delete lingjingwanxiang # 删除进程
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
pm2 logs lingjingwanxiang # 实时日志
|
||||
pm2 logs lingjingwanxiang --lines 100 # 查看最近100行
|
||||
pm2 logs lingjingwanxiang --nostream # 显示日志不跟踪
|
||||
pm2 flush lingjingwanxiang # 清空日志
|
||||
```
|
||||
|
||||
### 开机自启动
|
||||
|
||||
**首次配置**(需要 sudo 权限):
|
||||
\`\`\`bash
|
||||
sudo env PATH=$PATH:/Users/brunogao/.nvm/versions/node/v21.7.3/bin \\
|
||||
/Users/brunogao/.nvm/versions/node/v21.7.3/lib/node_modules/pm2/bin/pm2 \\
|
||||
startup launchd -u brunogao --hp /Users/brunogao
|
||||
\`\`\`
|
||||
|
||||
**保存当前进程列表**:
|
||||
\`\`\`bash
|
||||
pm2 save
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 模式切换
|
||||
|
||||
### 从开发模式切换到生产模式
|
||||
|
||||
1. **停止开发服务器**:
|
||||
\`\`\`bash
|
||||
pkill -f "next dev"
|
||||
\`\`\`
|
||||
|
||||
2. **启动生产服务器**:
|
||||
\`\`\`bash
|
||||
pm2 start ecosystem.config.js
|
||||
\`\`\`
|
||||
|
||||
### 从生产模式切换到开发模式
|
||||
|
||||
1. **停止生产服务器**:
|
||||
\`\`\`bash
|
||||
pm2 stop lingjingwanxiang
|
||||
\`\`\`
|
||||
|
||||
2. **启动开发服务器**:
|
||||
\`\`\`bash
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 🔨 重新构建
|
||||
|
||||
当代码更新后,需要重新构建:
|
||||
|
||||
\`\`\`bash
|
||||
|
||||
# 停止服务器
|
||||
|
||||
pm2 stop lingjingwanxiang
|
||||
|
||||
# 重新构建
|
||||
|
||||
pnpm run build
|
||||
|
||||
# 重启服务器
|
||||
|
||||
pm2 restart lingjingwanxiang
|
||||
\`\`\`
|
||||
|
||||
或者使用一键脚本:
|
||||
\`\`\`bash
|
||||
pm2 stop lingjingwanxiang && pnpm run build && pm2 restart lingjingwanxiang
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能监控
|
||||
|
||||
### 基础监控
|
||||
|
||||
\`\`\`bash
|
||||
pm2 monit # 实时 CPU 和内存监控
|
||||
pm2 status # 进程状态概览
|
||||
\`\`\`
|
||||
|
||||
### 查看资源使用
|
||||
|
||||
\`\`\`bash
|
||||
pm2 info lingjingwanxiang # 详细信息包括内存、CPU、重启次数等
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置文件
|
||||
|
||||
### PM2 配置文件
|
||||
|
||||
**位置**: `/Users/brunogao/work/codes/AI/lobe-chat/ecosystem.config.js`
|
||||
|
||||
**主要配置**:
|
||||
|
||||
- **名称**: lingjingwanxiang
|
||||
- **端口**: 3210
|
||||
- **实例数**: 1
|
||||
- **执行模式**: fork
|
||||
- **自动重启**: 是
|
||||
- **最大内存**: 2GB
|
||||
- **日志目录**: `./logs/`
|
||||
|
||||
### 启动脚本
|
||||
|
||||
**位置**: `/Users/brunogao/work/codes/AI/lobe-chat/start-production.sh`
|
||||
|
||||
---
|
||||
|
||||
## 📝 日志位置
|
||||
|
||||
- **PM2 输出日志**: `./logs/pm2-out.log`
|
||||
- **PM2 错误日志**: `./logs/pm2-error.log`
|
||||
- **PM2 系统日志**: `~/.pm2/logs/`
|
||||
|
||||
---
|
||||
|
||||
## ⚡️ 快速命令
|
||||
|
||||
### 一键重启
|
||||
|
||||
\`\`\`bash
|
||||
pm2 restart lingjingwanxiang
|
||||
\`\`\`
|
||||
|
||||
### 查看实时日志
|
||||
|
||||
\`\`\`bash
|
||||
pm2 logs lingjingwanxiang
|
||||
\`\`\`
|
||||
|
||||
### 检查应用健康状态
|
||||
|
||||
\`\`\`bash
|
||||
curl -s <http://localhost:3210> | grep "<title>"
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 🆘 故障排查
|
||||
|
||||
### 应用无法启动
|
||||
|
||||
1. 查看日志:`pm2 logs lingjingwanxiang`
|
||||
2. 检查端口占用:`lsof -i :3210`
|
||||
3. 验证构建:确保 `.next` 目录存在
|
||||
4. 检查数据库连接:`psql -U brunogao -d lingjingwanxiang -c "SELECT 1;"`
|
||||
|
||||
### 应用频繁重启
|
||||
|
||||
1. 查看重启原因:`pm2 info lingjingwanxiang`
|
||||
2. 检查内存使用:应用可能超过 2GB 限制
|
||||
3. 查看错误日志:`pm2 logs lingjingwanxiang --err`
|
||||
|
||||
### 性能问题
|
||||
|
||||
1. 增加内存限制:编辑 `ecosystem.config.js` 中的 `max_memory_restart`
|
||||
2. 使用集群模式:将 `instances` 改为 `max` 或具体数字
|
||||
3. 启用缓存:确保环境变量配置正确
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
1. **定期备份数据库**:
|
||||
\`\`\`bash
|
||||
pg_dump -U brunogao lingjingwanxiang > backup-$(date +%Y%m%d).sql
|
||||
\`\`\`
|
||||
|
||||
2. **监控日志大小**:
|
||||
\`\`\`bash
|
||||
pm2 flush lingjingwanxiang # 定期清理日志
|
||||
\`\`\`
|
||||
|
||||
3. **优雅重启**:
|
||||
\`\`\`bash
|
||||
pm2 reload lingjingwanxiang # 零停机重启
|
||||
\`\`\`
|
||||
|
||||
4. **保存 PM2 配置**:
|
||||
\`\`\`bash
|
||||
pm2 save # 更改后保存
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 📞 支持信息
|
||||
|
||||
- **应用路径**: /Users/brunogao/work/codes/AI/lobe-chat
|
||||
- **Node 版本**: v21.7.3
|
||||
- **PM2 版本**: 运行 `pm2 -v` 查看
|
||||
- **数据库**: PostgreSQL 14.19
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [Ollama 模型配置](./LINGJINGWANXIANG-32B-SETUP.md)
|
||||
- [PM2 官方文档](https://pm2.keymetrics.io/)
|
||||
- [Next.js 生产部署](https://nextjs.org/docs/deployment)
|
||||
@@ -940,6 +940,38 @@ table topics {
|
||||
}
|
||||
}
|
||||
|
||||
table email_verification_tokens {
|
||||
id text [pk, not null]
|
||||
user_id text [not null]
|
||||
email text [not null]
|
||||
token text [not null, unique]
|
||||
expires_at "timestamp with time zone" [not null]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'email_verification_tokens_user_id_idx']
|
||||
token [name: 'email_verification_tokens_token_idx']
|
||||
email [name: 'email_verification_tokens_email_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table password_reset_tokens {
|
||||
id text [pk, not null]
|
||||
user_id text [not null]
|
||||
token text [not null, unique]
|
||||
expires_at "timestamp with time zone" [not null]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'password_reset_tokens_user_id_idx']
|
||||
token [name: 'password_reset_tokens_token_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table user_installed_plugins {
|
||||
user_id text [not null]
|
||||
identifier text [not null]
|
||||
@@ -981,10 +1013,15 @@ table users {
|
||||
is_onboarded boolean [default: false]
|
||||
clerk_created_at "timestamp with time zone"
|
||||
email_verified_at "timestamp with time zone"
|
||||
password_hash text
|
||||
preference jsonb
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
email [name: 'users_email_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table user_memories {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
// Auto restart
|
||||
autorestart: true,
|
||||
cwd: '/Users/brunogao/work/codes/AI/lobe-chat',
|
||||
// Environment variables
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3210,
|
||||
},
|
||||
// Logging
|
||||
error_file: './logs/pm2-error.log',
|
||||
exec_mode: 'fork',
|
||||
instances: 1,
|
||||
interpreter: 'bash',
|
||||
// Process management
|
||||
kill_timeout: 5000,
|
||||
listen_timeout: 10_000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
// Memory management
|
||||
max_memory_restart: '2G',
|
||||
max_restarts: 10,
|
||||
merge_logs: true,
|
||||
min_uptime: '10s',
|
||||
name: 'lingjingwanxiang',
|
||||
out_file: './logs/pm2-out.log',
|
||||
restart_delay: 4000,
|
||||
script: './start-production.sh',
|
||||
wait_ready: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
# Kubernetes Secret Configuration Template
|
||||
# Copy this file to .env.k8s and fill in your actual values
|
||||
|
||||
# ============ Database Configuration ============
|
||||
# PostgreSQL connection string
|
||||
# Format: postgresql://username:password@host:port/database
|
||||
DATABASE_URL=postgresql://username:password@postgres-host:5432/database
|
||||
|
||||
# Database Encryption Key (generate with: openssl rand -base64 32)
|
||||
KEY_VAULTS_SECRET=
|
||||
|
||||
# ============ NextAuth Configuration ============
|
||||
# NextAuth Secret (generate with: openssl rand -base64 32)
|
||||
NEXTAUTH_SECRET=
|
||||
NEXT_AUTH_SECRET=
|
||||
|
||||
# ============ S3/MinIO Configuration ============
|
||||
# S3 Endpoint (internal service name or external URL)
|
||||
S3_ENDPOINT=http://minio-service:9000
|
||||
|
||||
# S3 Public Domain (accessible from outside the cluster)
|
||||
S3_PUBLIC_DOMAIN=https://s3.omniverseai.net
|
||||
|
||||
# S3 Credentials
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
# ============ Optional: AI Provider Keys ============
|
||||
# OpenAI API Key (required for embeddings)
|
||||
# OPENAI_API_KEY=sk-
|
||||
|
||||
# Anthropic API Key
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
# Google AI API Key
|
||||
# GOOGLE_API_KEY=
|
||||
|
||||
# ============ Optional: Email Configuration ============
|
||||
# Email service credentials (if using email verification)
|
||||
# EMAIL_FROM=noreply@omniverseai.net
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
@@ -0,0 +1,4 @@
|
||||
# K8s deployment files - DO NOT COMMIT SECRETS
|
||||
.env.k8s
|
||||
secret.yaml
|
||||
*.secret.yaml
|
||||
@@ -0,0 +1,493 @@
|
||||
# LobeChat K8s 部署架构说明
|
||||
|
||||
## 🏗️ 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Internet (Clients) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
│ chat.omniverseai.net
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ External Nginx Server (85.239.233.16) │
|
||||
│ │
|
||||
│ • SSL/TLS Termination │
|
||||
│ • Domain: *.omniverseai.net │
|
||||
│ • Certificate Management │
|
||||
│ • DDoS Protection / Rate Limiting │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTP (decrypted)
|
||||
│ proxy_pass http://10.7.0.2:31080
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Kubernetes Cluster - Ingress Controller │
|
||||
│ (NodePort 31080) │
|
||||
│ │
|
||||
│ • Receives traffic from external nginx │
|
||||
│ • Routes by Host header (chat.omniverseai.net) │
|
||||
│ • No TLS (already decrypted) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
│ Route to chat namespace
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Namespace: chat │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Service: lobechat-service (ClusterIP) │ │
|
||||
│ │ Port: 80 → 3210 │ │
|
||||
│ └────────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────┼───────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ Pod 1 │ │ Pod 2 │ │ Pod N │ │
|
||||
│ │ :3210 │ │ :3210 │ │ :3210 │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ PostgreSQL │ S3/MinIO
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Database │ │ Storage │
|
||||
│ (PGVector) │ │ (Files) │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## 📊 流量路径
|
||||
|
||||
### 1. 用户访问流程
|
||||
|
||||
```
|
||||
User Browser
|
||||
↓ DNS Lookup: chat.omniverseai.net → 85.239.233.16
|
||||
↓
|
||||
↓ HTTPS Request (TLS encrypted)
|
||||
↓
|
||||
External Nginx (85.239.233.16)
|
||||
↓ SSL Termination (decrypt)
|
||||
↓ Add X-Forwarded-* headers
|
||||
↓ HTTP Request
|
||||
↓
|
||||
K8s Ingress Controller (10.7.0.2:31080)
|
||||
↓ Check Host header: chat.omniverseai.net
|
||||
↓ Route to chat namespace
|
||||
↓
|
||||
Service: lobechat-service
|
||||
↓ Load balance across pods
|
||||
↓
|
||||
LobeChat Pod
|
||||
↓ Process request
|
||||
↓ Return response
|
||||
↓
|
||||
(Response follows reverse path)
|
||||
```
|
||||
|
||||
### 2. Header 传递链
|
||||
|
||||
```
|
||||
Client → External Nginx → K8s Ingress → App
|
||||
|
||||
Original Headers:
|
||||
Host: chat.omniverseai.net
|
||||
User-Agent: Mozilla/5.0...
|
||||
|
||||
External Nginx adds:
|
||||
X-Real-IP: <client-ip>
|
||||
X-Forwarded-For: <client-ip>
|
||||
X-Forwarded-Proto: https
|
||||
X-Forwarded-Host: chat.omniverseai.net
|
||||
|
||||
K8s Ingress preserves these headers and forwards to app
|
||||
```
|
||||
|
||||
## 🔧 关键组件配置
|
||||
|
||||
### External Nginx (85.239.233.16)
|
||||
|
||||
**职责**:
|
||||
|
||||
- SSL/TLS 终止
|
||||
- 域名路由
|
||||
- 安全防护 (DDoS, Rate Limiting)
|
||||
- 请求转发到 K8s
|
||||
|
||||
**配置要点**:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name omniverseai.net *.omniverseai.net;
|
||||
|
||||
# SSL certs
|
||||
ssl_certificate /path/to/cert;
|
||||
ssl_certificate_key /path/to/key;
|
||||
|
||||
# Forward headers (IMPORTANT!)
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Proxy to K8s
|
||||
proxy_pass http://10.7.0.2:31080;
|
||||
}
|
||||
```
|
||||
|
||||
### K8s Ingress Controller
|
||||
|
||||
**职责**:
|
||||
|
||||
- 接收来自外部 Nginx 的流量
|
||||
- 基于 Host header 路由到不同 namespace
|
||||
- 不处理 SSL (已由外部 Nginx 处理)
|
||||
|
||||
**配置要点**:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 80
|
||||
nodePort: 31080 # 固定端口,匹配外部 nginx 配置
|
||||
```
|
||||
|
||||
### Chat Namespace Ingress
|
||||
|
||||
**职责**:
|
||||
|
||||
- 定义 chat.omniverseai.net 的路由规则
|
||||
- 信任并转发 X-Forwarded-\* headers
|
||||
|
||||
**配置要点**:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: 'false' # 不重定向,已是 HTTPS
|
||||
nginx.ingress.kubernetes.io/use-forwarded-headers: 'true' # 信任转发的 headers
|
||||
```
|
||||
|
||||
### LobeChat Application
|
||||
|
||||
**配置要点**:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: NEXT_PUBLIC_BASE_URL
|
||||
value: 'https://chat.omniverseai.net' # 使用公网域名
|
||||
- name: NEXTAUTH_URL
|
||||
value: 'https://chat.omniverseai.net' # NextAuth 回调 URL
|
||||
- name: AUTH_TRUST_HOST
|
||||
value: 'true' # 信任 X-Forwarded-Host
|
||||
```
|
||||
|
||||
## 🚀 部署流程
|
||||
|
||||
### Phase 1: 准备 K8s Ingress Controller
|
||||
|
||||
1. **检查 Ingress Controller 是否已安装**:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n ingress-nginx
|
||||
```
|
||||
|
||||
2. **如果未安装,安装 nginx ingress controller**:
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml
|
||||
```
|
||||
|
||||
3. **配置 NodePort 31080**:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用提供的配置文件
|
||||
kubectl apply -f k8s/ingress-nodeport.yaml
|
||||
|
||||
# 方法 2: 手动 patch 现有服务
|
||||
kubectl patch svc ingress-nginx-controller -n ingress-nginx -p '{"spec":{"type":"NodePort","ports":[{"port":80,"targetPort":80,"protocol":"TCP","nodePort":31080,"name":"http"}]}}'
|
||||
```
|
||||
|
||||
4. **验证 NodePort**:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n ingress-nginx
|
||||
# 应该显示 80:31080/TCP
|
||||
|
||||
# 测试端口
|
||||
curl http://10.7.0.2:31080
|
||||
```
|
||||
|
||||
### Phase 2: 部署 LobeChat
|
||||
|
||||
1. **构建镜像**:
|
||||
|
||||
```bash
|
||||
export DOCKER_REGISTRY="your-registry.com"
|
||||
./k8s/build.sh
|
||||
```
|
||||
|
||||
2. **配置环境变量**:
|
||||
|
||||
```bash
|
||||
cp k8s/.env.template k8s/.env.k8s
|
||||
vim k8s/.env.k8s
|
||||
./k8s/generate-secret.sh
|
||||
```
|
||||
|
||||
3. **部署应用**:
|
||||
|
||||
```bash
|
||||
./k8s/deploy.sh
|
||||
```
|
||||
|
||||
4. **验证部署**:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n chat
|
||||
kubectl get svc -n chat
|
||||
kubectl get ingress -n chat
|
||||
|
||||
# 测试从 K8s 节点访问
|
||||
curl -H "Host: chat.omniverseai.net" http://localhost:31080
|
||||
```
|
||||
|
||||
### Phase 3: 配置外部 Nginx
|
||||
|
||||
1. **SSH 到外部服务器**:
|
||||
|
||||
```bash
|
||||
ssh user@85.239.233.16
|
||||
```
|
||||
|
||||
2. **配置 Nginx**:
|
||||
参考 `k8s/EXTERNAL-NGINX.md` 中的完整配置
|
||||
|
||||
3. **测试并重载**:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo nginx -s reload
|
||||
```
|
||||
|
||||
4. **验证**:
|
||||
|
||||
```bash
|
||||
curl -v https://chat.omniverseai.net
|
||||
```
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### 问题 1: 502 Bad Gateway
|
||||
|
||||
**症状**: 访问 chat.omniverseai.net 返回 502
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. **检查外部 Nginx 到 K8s 的连通性**:
|
||||
|
||||
```bash
|
||||
# 在 85.239.233.16 上
|
||||
curl http://10.7.0.2:31080
|
||||
telnet 10.7.0.2 31080
|
||||
```
|
||||
|
||||
2. **检查 K8s Ingress Controller**:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n ingress-nginx
|
||||
kubectl logs -n ingress-nginx <ingress-controller-pod>
|
||||
```
|
||||
|
||||
3. **检查 LobeChat Pods**:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n chat
|
||||
kubectl logs -n chat <lobechat-pod>
|
||||
```
|
||||
|
||||
### 问题 2: NextAuth 重定向错误
|
||||
|
||||
**症状**: 登录后重定向到错误的 URL (如 http 而非 https)
|
||||
|
||||
**原因**: X-Forwarded-Proto header 未正确传递
|
||||
|
||||
**解决**:
|
||||
|
||||
1. **检查外部 Nginx 配置**:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 必须是 https
|
||||
```
|
||||
|
||||
2. **检查应用环境变量**:
|
||||
|
||||
```bash
|
||||
kubectl exec -n chat env < pod > -- | grep NEXTAUTH_URL
|
||||
# 应该是 https://chat.omniverseai.net
|
||||
```
|
||||
|
||||
3. **检查 Ingress annotations**:
|
||||
|
||||
```yaml
|
||||
nginx.ingress.kubernetes.io/use-forwarded-headers: 'true'
|
||||
```
|
||||
|
||||
### 问题 3: WebSocket 连接失败
|
||||
|
||||
**症状**: 实时功能不工作,浏览器控制台显示 WebSocket 错误
|
||||
|
||||
**解决**:
|
||||
|
||||
1. **检查外部 Nginx WebSocket 配置**:
|
||||
|
||||
```nginx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
|
||||
2. **检查 Ingress WebSocket 支持**:
|
||||
|
||||
```yaml
|
||||
nginx.ingress.kubernetes.io/websocket-services: 'lobechat-service'
|
||||
```
|
||||
|
||||
### 问题 4: 文件上传失败
|
||||
|
||||
**症状**: 上传大文件时超时或失败
|
||||
|
||||
**解决**:
|
||||
|
||||
1. **检查外部 Nginx 上传限制**:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 100m;
|
||||
```
|
||||
|
||||
2. **检查超时设置**:
|
||||
|
||||
```nginx
|
||||
proxy_read_timeout 600s;
|
||||
```
|
||||
|
||||
3. **检查 Ingress 配置**:
|
||||
|
||||
```yaml
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: '100m'
|
||||
```
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
### 查看外部 Nginx 日志
|
||||
|
||||
```bash
|
||||
ssh user@85.239.233.16
|
||||
tail -f /var/log/nginx/omniverseai.net-access.log
|
||||
tail -f /var/log/nginx/omniverseai.net-error.log
|
||||
```
|
||||
|
||||
### 查看 Ingress Controller 日志
|
||||
|
||||
```bash
|
||||
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx -f
|
||||
```
|
||||
|
||||
### 查看 LobeChat 日志
|
||||
|
||||
```bash
|
||||
kubectl logs -n chat -l app=lobechat -f
|
||||
```
|
||||
|
||||
### 追踪完整请求链
|
||||
|
||||
```bash
|
||||
# 1. 检查外部 Nginx 是否收到请求
|
||||
ssh user@85.239.233.16 "tail -n 50 /var/log/nginx/omniverseai.net-access.log"
|
||||
|
||||
# 2. 检查 Ingress Controller 是否收到请求
|
||||
kubectl logs -n ingress-nginx < controller-pod > --tail=50
|
||||
|
||||
# 3. 检查应用是否收到请求
|
||||
kubectl logs -n chat -l app=lobechat --tail=50
|
||||
```
|
||||
|
||||
## 🔐 安全考虑
|
||||
|
||||
### 1. Network Policies
|
||||
|
||||
限制 chat namespace 的网络访问:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: lobechat-network-policy
|
||||
namespace: chat
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: lobechat
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: ingress-nginx
|
||||
egress:
|
||||
- to:
|
||||
- podSelector: {} # Same namespace
|
||||
- to:
|
||||
- namespaceSelector: {} # Database, etc
|
||||
```
|
||||
|
||||
### 2. 外部 Nginx 防护
|
||||
|
||||
```nginx
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=chat_limit:10m rate=10r/s;
|
||||
limit_req zone=chat_limit burst=20;
|
||||
|
||||
# IP 黑名单
|
||||
deny 1.2.3.4;
|
||||
|
||||
# 只允许特定方法
|
||||
if ($request_method !~ ^(GET|POST|PUT|DELETE|HEAD|OPTIONS)$ ) {
|
||||
return 444;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 定期更新
|
||||
|
||||
- [ ] 定期更新 SSL 证书
|
||||
- [ ] 定期更新 Kubernetes 版本
|
||||
- [ ] 定期更新 LobeChat 镜像
|
||||
- [ ] 监控安全漏洞
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [外部 Nginx 配置详解](./EXTERNAL-NGINX.md)
|
||||
- [快速部署指南](./QUICKSTART.md)
|
||||
- [完整部署文档](./README.md)
|
||||
- [Ingress Controller 说明](./ingress-nodeport.yaml)
|
||||
|
||||
## 🆘 联系支持
|
||||
|
||||
如遇到问题,请按以下顺序检查:
|
||||
|
||||
1. 查看本文档的故障排查部分
|
||||
2. 检查各层日志 (Nginx → Ingress → App)
|
||||
3. 验证网络连通性
|
||||
4. 检查配置文件语法
|
||||
5. 查看 K8s 事件: `kubectl get events -n chat`
|
||||
@@ -0,0 +1,308 @@
|
||||
# 🚀 部署到现有架构 - 快速指南
|
||||
|
||||
适用于已有外部 Nginx (85.239.233.16) + K8s 集群的场景
|
||||
|
||||
## 📋 前提条件
|
||||
|
||||
✅ 外部 Nginx 服务器: 85.239.233.16
|
||||
✅ K8s 节点: 10.7.0.2
|
||||
✅ 域名: \*.omniverseai.net 已指向 85.239.233.16
|
||||
✅ Nginx 反向代理: proxy_pass <http://10.7.0.2:31080>
|
||||
|
||||
## ⚡ 3 步快速部署
|
||||
|
||||
### 步骤 1: 配置 K8s Ingress Controller (5 分钟)
|
||||
|
||||
```bash
|
||||
# 1. 检查 ingress controller 是否已安装
|
||||
kubectl get svc -n ingress-nginx
|
||||
|
||||
# 2. 配置 NodePort 31080 (使用提供的配置文件)
|
||||
kubectl apply -f k8s/ingress-nodeport.yaml
|
||||
|
||||
# 或者手动 patch 现有服务
|
||||
kubectl patch svc ingress-nginx-controller -n ingress-nginx \
|
||||
-p '{"spec":{"type":"NodePort","ports":[{"port":80,"nodePort":31080,"name":"http"}]}}'
|
||||
|
||||
# 3. 验证配置
|
||||
kubectl get svc -n ingress-nginx | grep 31080
|
||||
# 应该看到: 80:31080/TCP
|
||||
|
||||
# 4. 测试端口
|
||||
curl http://10.7.0.2:31080
|
||||
# 应该返回 404 (表示 ingress 正在工作,只是还没有路由规则)
|
||||
```
|
||||
|
||||
### 步骤 2: 部署 LobeChat 到 K8s (10 分钟)
|
||||
|
||||
```bash
|
||||
# 1. 构建 Docker 镜像
|
||||
export DOCKER_REGISTRY="your-registry.com"
|
||||
export IMAGE_TAG="v1.0.0"
|
||||
./k8s/build.sh
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp k8s/.env.template k8s/.env.k8s
|
||||
|
||||
# 生成密钥
|
||||
openssl rand -base64 32 # 用于 KEY_VAULTS_SECRET
|
||||
openssl rand -base64 32 # 用于 NEXTAUTH_SECRET
|
||||
|
||||
# 编辑配置文件,填入必要的值
|
||||
vim k8s/.env.k8s
|
||||
|
||||
# 3. 生成 Secret
|
||||
./k8s/generate-secret.sh
|
||||
|
||||
# 4. 更新 deployment.yaml 中的镜像地址
|
||||
vim k8s/deployment.yaml
|
||||
# 修改: image: your-registry.com/lobechat:v1.0.0
|
||||
|
||||
# 5. 部署到 K8s
|
||||
./k8s/deploy.sh
|
||||
|
||||
# 6. 验证部署
|
||||
kubectl get pods -n chat
|
||||
kubectl get ingress -n chat
|
||||
|
||||
# 7. 测试 ingress 路由
|
||||
curl -H "Host: chat.omniverseai.net" http://10.7.0.2:31080
|
||||
# 应该返回 LobeChat 的 HTML
|
||||
```
|
||||
|
||||
### 步骤 3: 配置外部 Nginx (5 分钟)
|
||||
|
||||
**重要**: 外部 Nginx 需要正确转发 headers!
|
||||
|
||||
```bash
|
||||
# 1. SSH 到外部服务器
|
||||
ssh user@85.239.233.16
|
||||
|
||||
# 2. 编辑 nginx 配置
|
||||
sudo vim /etc/nginx/sites-available/omniverseai.net.conf
|
||||
```
|
||||
|
||||
**添加或更新以下配置**:
|
||||
|
||||
```nginx
|
||||
upstream k8s_chat {
|
||||
server 10.7.0.2:31080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name omniverseai.net *.omniverseai.net;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# 文件上传限制
|
||||
client_max_body_size 100m;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
|
||||
# WebSocket 支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# ⚠️ 重要: 必须转发这些 headers!
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 这个很重要!
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# 禁用缓冲 (提高上传性能)
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k8s_chat;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**测试并重载**:
|
||||
|
||||
```bash
|
||||
# 3. 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 4. 重载配置
|
||||
sudo nginx -s reload
|
||||
|
||||
# 5. 验证
|
||||
curl -v https://chat.omniverseai.net
|
||||
```
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 1. 检查各层状态
|
||||
|
||||
```bash
|
||||
# K8s Pods
|
||||
kubectl get pods -n chat
|
||||
# 应该显示: Running
|
||||
|
||||
# K8s Service
|
||||
kubectl get svc -n chat
|
||||
# 应该有 lobechat-service
|
||||
|
||||
# K8s Ingress
|
||||
kubectl get ingress -n chat
|
||||
# 应该显示 chat.omniverseai.net
|
||||
|
||||
# Ingress Controller
|
||||
kubectl get svc -n ingress-nginx
|
||||
# 应该显示 31080 NodePort
|
||||
```
|
||||
|
||||
### 2. 测试连通性
|
||||
|
||||
```bash
|
||||
# 从 K8s 节点测试
|
||||
curl -H "Host: chat.omniverseai.net" http://localhost:31080
|
||||
|
||||
# 从外部 Nginx 服务器测试
|
||||
curl -H "Host: chat.omniverseai.net" http://10.7.0.2:31080
|
||||
|
||||
# 从任何地方测试最终访问
|
||||
curl -v https://chat.omniverseai.net
|
||||
```
|
||||
|
||||
### 3. 检查日志
|
||||
|
||||
```bash
|
||||
# LobeChat 应用日志
|
||||
kubectl logs -n chat -l app=lobechat -f
|
||||
|
||||
# Ingress Controller 日志
|
||||
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx -f
|
||||
|
||||
# 外部 Nginx 日志
|
||||
ssh user@85.239.233.16 "tail -f /var/log/nginx/access.log"
|
||||
```
|
||||
|
||||
## 🔧 必须配置项检查清单
|
||||
|
||||
### K8s 层
|
||||
|
||||
- [ ] Ingress Controller 监听 NodePort 31080
|
||||
- [ ] LobeChat Deployment 已部署到 chat namespace
|
||||
- [ ] Service 类型为 ClusterIP
|
||||
- [ ] Ingress 配置了 `chat.omniverseai.net`
|
||||
- [ ] Ingress 不配置 TLS (由外部 Nginx 处理)
|
||||
- [ ] Ingress 配置了 `use-forwarded-headers: "true"`
|
||||
- [ ] ConfigMap 中 URL 为 `https://chat.omniverseai.net`
|
||||
|
||||
### 外部 Nginx 层
|
||||
|
||||
- [ ] 监听 443 端口 (HTTPS)
|
||||
- [ ] SSL 证书已配置
|
||||
- [ ] proxy_pass 指向 `http://10.7.0.2:31080`
|
||||
- [ ] **X-Forwarded-Proto** 设置为 `$scheme` ⚠️ 重要!
|
||||
- [ ] **X-Forwarded-For** 设置为 `$proxy_add_x_forwarded_for`
|
||||
- [ ] **Host** header 正确转发
|
||||
- [ ] WebSocket headers (Upgrade, Connection) 已配置
|
||||
- [ ] client_max_body_size 至少 100m
|
||||
- [ ] 超时时间至少 600s
|
||||
|
||||
## 🐛 常见问题速查
|
||||
|
||||
### 问题: 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# 检查 K8s ingress controller
|
||||
kubectl get pods -n ingress-nginx
|
||||
kubectl get svc -n ingress-nginx | grep 31080
|
||||
|
||||
# 从外部 nginx 测试连通性
|
||||
telnet 10.7.0.2 31080
|
||||
```
|
||||
|
||||
### 问题:登录后重定向错误 (http instead of https)
|
||||
|
||||
**原因**: X-Forwarded-Proto 未设置
|
||||
|
||||
**解决**: 在外部 Nginx 添加:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
### 问题: WebSocket 连接失败
|
||||
|
||||
**解决**: 在外部 Nginx 添加:
|
||||
|
||||
```nginx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
|
||||
### 问题:文件上传失败
|
||||
|
||||
**解决**: 增加上传限制:
|
||||
|
||||
```nginx
|
||||
# 外部 Nginx
|
||||
client_max_body_size 100m;
|
||||
|
||||
# K8s Ingress (已在配置中)
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
```
|
||||
|
||||
## 📊 架构流程图
|
||||
|
||||
```
|
||||
User Browser
|
||||
↓ HTTPS
|
||||
↓ chat.omniverseai.net
|
||||
↓
|
||||
85.239.233.16 (External Nginx)
|
||||
↓ SSL Termination
|
||||
↓ HTTP + X-Forwarded-* headers
|
||||
↓
|
||||
10.7.0.2:31080 (K8s Ingress NodePort)
|
||||
↓ Route by Host header
|
||||
↓
|
||||
chat namespace → lobechat-service → pods
|
||||
```
|
||||
|
||||
## 📚 详细文档
|
||||
|
||||
- **架构说明**: `k8s/ARCHITECTURE.md`
|
||||
- **外部 Nginx 配置**: `k8s/EXTERNAL-NGINX.md`
|
||||
- **完整部署文档**: `k8s/README.md`
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
部署成功后:
|
||||
|
||||
1. ✅ 访问 <https://chat.omniverseai.net>
|
||||
2. ✅ 创建管理员账号
|
||||
3. ✅ 配置 AI 模型
|
||||
4. ✅ 测试文件上传
|
||||
5. ✅ 配置监控和日志
|
||||
|
||||
## 💡 关键配置对比
|
||||
|
||||
| 配置项 | 标准部署 | 当前架构 |
|
||||
| ---------- | ---------------------- | --------------- |
|
||||
| TLS 终止 | Ingress (cert-manager) | 外部 Nginx |
|
||||
| 外部访问 | LoadBalancer | NodePort 31080 |
|
||||
| SSL 重定向 | Ingress 层 | 外部 Nginx 层 |
|
||||
| Headers | Ingress 添加 | 外部 Nginx 添加 |
|
||||
|
||||
---
|
||||
|
||||
**重要提示**:
|
||||
|
||||
- X-Forwarded-Proto 是最关键的配置,缺少会导致 HTTPS 重定向问题
|
||||
- 确保外部 Nginx 和 K8s Ingress 都支持 WebSocket
|
||||
- 文件上传限制需要在两层都配置
|
||||
@@ -0,0 +1,364 @@
|
||||
# 外部 Nginx 配置说明
|
||||
|
||||
## 架构概述
|
||||
|
||||
```
|
||||
Internet (Client)
|
||||
↓ HTTPS
|
||||
85.239.233.16 (External Nginx with SSL)
|
||||
↓ HTTP (decrypted)
|
||||
10.7.0.2:31080 (K8s Ingress Controller NodePort)
|
||||
↓ HTTP
|
||||
chat namespace (lobechat-service)
|
||||
↓ HTTP
|
||||
lobechat pods
|
||||
```
|
||||
|
||||
## 外部 Nginx 配置 (85.239.233.16)
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
在你的 nginx 配置中添加或更新:
|
||||
|
||||
```nginx
|
||||
# chat.omniverseai.net upstream
|
||||
upstream k8s_chat {
|
||||
# K8s node with ingress controller
|
||||
server 10.7.0.2:31080;
|
||||
|
||||
# If you have multiple K8s nodes, add them here for load balancing
|
||||
# server 10.7.0.3:31080;
|
||||
# server 10.7.0.4:31080;
|
||||
|
||||
# Health check settings
|
||||
keepalive 32;
|
||||
keepalive_timeout 60s;
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name omniverseai.net *.omniverseai.net;
|
||||
|
||||
# Force HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name omniverseai.net *.omniverseai.net;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /path/to/omniverseai.net/fullchain.pem;
|
||||
ssl_certificate_key /path/to/omniverseai.net/privkey.pem;
|
||||
|
||||
# SSL Security Settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/omniverseai.net-access.log;
|
||||
error_log /var/log/nginx/omniverseai.net-error.log;
|
||||
|
||||
# Client upload size (match ingress settings)
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Proxy timeouts (match ingress settings)
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
send_timeout 600s;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Important: Forward original client information
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Proxy buffering settings
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Proxy pass to K8s ingress
|
||||
location / {
|
||||
proxy_pass http://k8s_chat;
|
||||
|
||||
# Additional headers for better debugging
|
||||
add_header X-Upstream-Server $upstream_addr always;
|
||||
}
|
||||
|
||||
# Health check endpoint (optional)
|
||||
location /healthz {
|
||||
proxy_pass http://k8s_chat/healthz;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键配置说明
|
||||
|
||||
#### 1. \*_X-Forwarded-_ Headers\*\* (重要!)
|
||||
|
||||
这些 header 让后端应用知道原始请求信息:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议 (https)
|
||||
proxy_set_header X-Forwarded-Host $host; # 原始域名
|
||||
```
|
||||
|
||||
**为什么重要?**
|
||||
|
||||
- NextAuth 需要知道原始协议 (https) 来生成正确的回调 URL
|
||||
- 应用需要客户端真实 IP 用于日志和安全
|
||||
- Session cookies 需要正确的域名
|
||||
|
||||
#### 2. **WebSocket 支持**
|
||||
|
||||
```nginx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
|
||||
这对于实时聊天功能很重要。
|
||||
|
||||
#### 3. **文件上传**
|
||||
|
||||
```nginx
|
||||
client_max_body_size 100m;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
```
|
||||
|
||||
允许上传大文件,并禁用缓冲以提高上传性能。
|
||||
|
||||
#### 4. **超时设置**
|
||||
|
||||
```nginx
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
```
|
||||
|
||||
AI 响应可能需要较长时间,所以超时设置要足够大。
|
||||
|
||||
## 验证配置
|
||||
|
||||
### 1. 测试 Nginx 配置
|
||||
|
||||
```bash
|
||||
# SSH 到 85.239.233.16
|
||||
sudo nginx -t
|
||||
sudo nginx -s reload
|
||||
```
|
||||
|
||||
### 2. 检查端口监听
|
||||
|
||||
```bash
|
||||
# 在 K8s 节点 (10.7.0.2) 上
|
||||
netstat -tuln | grep 31080
|
||||
# 应该显示 0.0.0.0:31080 或 :::31080
|
||||
|
||||
# 或使用 ss
|
||||
ss -tuln | grep 31080
|
||||
```
|
||||
|
||||
### 3. 测试连通性
|
||||
|
||||
```bash
|
||||
# 从外部 nginx 服务器测试
|
||||
curl -v http://10.7.0.2:31080
|
||||
|
||||
# 测试 chat 子域名
|
||||
curl -H "Host: chat.omniverseai.net" http://10.7.0.2:31080
|
||||
```
|
||||
|
||||
### 4. 检查 Headers 转发
|
||||
|
||||
```bash
|
||||
# 测试 headers 是否正确转发
|
||||
curl -v https://chat.omniverseai.net/ -H "X-Debug: test"
|
||||
```
|
||||
|
||||
查看响应头中是否有 `X-Upstream-Server`,这表明请求成功到达 K8s。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 502 Bad Gateway
|
||||
|
||||
**原因**: Nginx 无法连接到 K8s ingress
|
||||
|
||||
**检查**:
|
||||
|
||||
```bash
|
||||
# 在外部 nginx 上
|
||||
telnet 10.7.0.2 31080
|
||||
|
||||
# 检查防火墙
|
||||
iptables -L | grep 31080
|
||||
|
||||
# 检查 K8s ingress controller
|
||||
kubectl get svc -n ingress-nginx
|
||||
kubectl get pods -n ingress-nginx
|
||||
```
|
||||
|
||||
### 2. 504 Gateway Timeout
|
||||
|
||||
**原因**: 响应时间超过超时设置
|
||||
|
||||
**解决**: 增加超时时间(已在上面配置中设置为 600s)
|
||||
|
||||
### 3. WebSocket 连接失败
|
||||
|
||||
**原因**: 缺少 WebSocket 相关 headers
|
||||
|
||||
**解决**: 确保配置了 `Upgrade` 和 `Connection` headers
|
||||
|
||||
### 4. NextAuth 重定向错误
|
||||
|
||||
**原因**: `X-Forwarded-Proto` 未正确设置
|
||||
|
||||
**解决**: 确保配置了 `proxy_set_header X-Forwarded-Proto $scheme;`
|
||||
|
||||
## 安全加固 (可选)
|
||||
|
||||
### 1. 限制访问源 IP
|
||||
|
||||
如果你想只允许特定 IP 访问管理功能:
|
||||
|
||||
```nginx
|
||||
location /admin {
|
||||
allow 1.2.3.4; # 你的 IP
|
||||
deny all;
|
||||
proxy_pass http://k8s_chat;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加安全 Headers
|
||||
|
||||
```nginx
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
```
|
||||
|
||||
### 3. 速率限制
|
||||
|
||||
```nginx
|
||||
# 在 http 块中定义
|
||||
limit_req_zone $binary_remote_addr zone=chat_limit:10m rate=10r/s;
|
||||
|
||||
# 在 server 块中使用
|
||||
location / {
|
||||
limit_req zone=chat_limit burst=20 nodelay;
|
||||
proxy_pass http://k8s_chat;
|
||||
}
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 查看访问日志
|
||||
|
||||
```bash
|
||||
tail -f /var/log/nginx/omniverseai.net-access.log
|
||||
```
|
||||
|
||||
### 查看错误日志
|
||||
|
||||
```bash
|
||||
tail -f /var/log/nginx/omniverseai.net-error.log
|
||||
```
|
||||
|
||||
### 有用的日志格式
|
||||
|
||||
在 nginx.conf 的 http 块中添加:
|
||||
|
||||
```nginx
|
||||
log_format detailed '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" '
|
||||
'upstream: $upstream_addr '
|
||||
'upstream_status: $upstream_status '
|
||||
'request_time: $request_time '
|
||||
'upstream_response_time: $upstream_response_time';
|
||||
|
||||
# 然后在 server 块中使用
|
||||
access_log /var/log/nginx/omniverseai.net-access.log detailed;
|
||||
```
|
||||
|
||||
## 完整部署检查清单
|
||||
|
||||
- [ ] 外部 Nginx (85.239.233.16) 已配置并重载
|
||||
- [ ] SSL 证书已配置且有效
|
||||
- [ ] K8s Ingress Controller 已暴露在 NodePort 31080
|
||||
- [ ] 可以从外部 Nginx 访问 10.7.0.2:31080
|
||||
- [ ] DNS 解析 chat.omniverseai.net 指向 85.239.233.16
|
||||
- [ ] X-Forwarded-\* headers 已正确配置
|
||||
- [ ] WebSocket 支持已启用
|
||||
- [ ] 文件上传限制已设置
|
||||
- [ ] 超时设置足够大
|
||||
- [ ] 日志正常记录
|
||||
|
||||
## 更新 Nginx 配置步骤
|
||||
|
||||
```bash
|
||||
# 1. SSH 到外部服务器
|
||||
ssh user@85.239.233.16
|
||||
|
||||
# 2. 编辑配置文件
|
||||
sudo vim /etc/nginx/sites-available/omniverseai.net.conf
|
||||
# 或
|
||||
sudo vim /etc/nginx/conf.d/omniverseai.net.conf
|
||||
|
||||
# 3. 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 4. 如果测试通过,重载配置
|
||||
sudo nginx -s reload
|
||||
|
||||
# 5. 查看日志确认
|
||||
sudo tail -f /var/log/nginx/omniverseai.net-access.log
|
||||
```
|
||||
|
||||
## 后续优化
|
||||
|
||||
### 1. 添加缓存
|
||||
|
||||
对于静态资源可以添加缓存:
|
||||
|
||||
```nginx
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
|
||||
proxy_pass http://k8s_chat;
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_cache_use_stale error timeout invalid_header updating;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 启用 HTTP/2
|
||||
|
||||
已在配置中启用:`listen 443 ssl http2;`
|
||||
|
||||
### 3. 启用 gzip 压缩
|
||||
|
||||
```nginx
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
|
||||
```
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
# 📚 Kubernetes 部署文档索引
|
||||
|
||||
欢迎使用 LobeChat Kubernetes 部署套件!
|
||||
|
||||
## 🎯 快速导航
|
||||
|
||||
### 新手入门
|
||||
|
||||
- **[开始这里](./DEPLOY-TO-EXISTING.md)** - 3 步快速部署到现有架构 ⭐ 推荐
|
||||
- **[快速开始](./QUICKSTART.md)** - 5 分钟快速部署指南
|
||||
|
||||
### 详细文档
|
||||
|
||||
- **[架构说明](./ARCHITECTURE.md)** - 了解整体架构和流量路径
|
||||
- **[外部 Nginx 配置](./EXTERNAL-NGINX.md)** - 外部 Nginx 详细配置指南
|
||||
- **[完整部署文档](./README.md)** - 详尽的部署和故障排查指南
|
||||
|
||||
## 📋 文件清单
|
||||
|
||||
### 配置文件 (YAML)
|
||||
|
||||
| 文件 | 说明 | 用途 |
|
||||
| ----------------------- | ------------------ | ----------------------------- |
|
||||
| `namespace.yaml` | Namespace 定义 | 创建 chat namespace |
|
||||
| `configmap.yaml` | 环境变量配置 | 非敏感配置 |
|
||||
| `secret.yaml` | 敏感信息 | 数据库、密钥等 (由脚本生成) |
|
||||
| `deployment.yaml` | 应用部署 | Pod 定义、资源限制、健康检查 |
|
||||
| `service.yaml` | Service 定义 | ClusterIP 服务 |
|
||||
| `ingress.yaml` | Ingress 路由 | chat.omniverseai.net 路由规则 |
|
||||
| `ingress-nodeport.yaml` | Ingress Controller | NodePort 31080 配置 |
|
||||
| `kustomization.yaml` | Kustomize 配置 | 一键部署 |
|
||||
|
||||
### 脚本工具 (Shell)
|
||||
|
||||
| 文件 | 说明 | 用法 |
|
||||
| -------------------- | ---------------- | -------------------------- |
|
||||
| `build.sh` | 构建 Docker 镜像 | `./k8s/build.sh` |
|
||||
| `generate-secret.sh` | 生成 Secret | `./k8s/generate-secret.sh` |
|
||||
| `deploy.sh` | 一键部署 | `./k8s/deploy.sh` |
|
||||
|
||||
### 文档 (Markdown)
|
||||
|
||||
| 文件 | 内容 | 适合场景 |
|
||||
| ----------------------- | -------------- | ----------------------- |
|
||||
| `DEPLOY-TO-EXISTING.md` | 部署到现有架构 | 已有外部 Nginx + K8s ⭐ |
|
||||
| `QUICKSTART.md` | 快速开始 | 快速部署 |
|
||||
| `ARCHITECTURE.md` | 架构说明 | 了解系统架构 |
|
||||
| `EXTERNAL-NGINX.md` | Nginx 配置 | 配置外部 Nginx |
|
||||
| `README.md` | 完整文档 | 详细参考 |
|
||||
| `INDEX.md` | 本文档 | 文档导航 |
|
||||
|
||||
### 配置模板
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --------------- | ------------ |
|
||||
| `.env.template` | 环境变量模板 |
|
||||
| `.gitignore` | Git 忽略规则 |
|
||||
|
||||
## 🚀 快速开始流程
|
||||
|
||||
### 场景 1: 部署到现有架构 (推荐)
|
||||
|
||||
你的环境:
|
||||
|
||||
- ✅ 外部 Nginx: 85.239.233.16
|
||||
- ✅ K8s 节点: 10.7.0.2
|
||||
- ✅ Nginx 反向代理到 10.7.0.2:31080
|
||||
|
||||
**跟随这个文档**: [DEPLOY-TO-EXISTING.md](./DEPLOY-TO-EXISTING.md)
|
||||
|
||||
### 场景 2: 全新部署
|
||||
|
||||
从零开始部署到 Kubernetes
|
||||
|
||||
**跟随这个文档**: [QUICKSTART.md](./QUICKSTART.md)
|
||||
|
||||
## 📊 部署架构
|
||||
|
||||
### 当前架构 (外部 Nginx + K8s)
|
||||
|
||||
```
|
||||
Internet → 85.239.233.16 (Nginx + SSL)
|
||||
→ 10.7.0.2:31080 (K8s Ingress)
|
||||
→ chat namespace
|
||||
→ LobeChat Pods
|
||||
```
|
||||
|
||||
关键配置:
|
||||
|
||||
- TLS 在外部 Nginx 终止
|
||||
- K8s Ingress 使用 NodePort 31080
|
||||
- 必须正确转发 X-Forwarded-\* headers
|
||||
|
||||
**详细说明**: [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
|
||||
## 🔧 部署步骤概览
|
||||
|
||||
### Step 1: 配置 K8s Ingress Controller
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/ingress-nodeport.yaml
|
||||
```
|
||||
|
||||
**参考**: [DEPLOY-TO-EXISTING.md# 步骤 1](./DEPLOY-TO-EXISTING.md)
|
||||
|
||||
### Step 2: 部署 LobeChat
|
||||
|
||||
```bash
|
||||
./k8s/build.sh
|
||||
./k8s/generate-secret.sh
|
||||
./k8s/deploy.sh
|
||||
```
|
||||
|
||||
**参考**: [DEPLOY-TO-EXISTING.md# 步骤 2](./DEPLOY-TO-EXISTING.md)
|
||||
|
||||
### Step 3: 配置外部 Nginx
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://10.7.0.2:31080;
|
||||
```
|
||||
|
||||
**参考**: [EXTERNAL-NGINX.md](./EXTERNAL-NGINX.md)
|
||||
|
||||
## 📖 按任务查找文档
|
||||
|
||||
| 任务 | 查看文档 |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| 我想快速部署 | [DEPLOY-TO-EXISTING.md](./DEPLOY-TO-EXISTING.md) |
|
||||
| 了解系统架构 | [ARCHITECTURE.md](./ARCHITECTURE.md) |
|
||||
| 配置外部 Nginx | [EXTERNAL-NGINX.md](./EXTERNAL-NGINX.md) |
|
||||
| 构建 Docker 镜像 | [QUICKSTART.md# 构建镜像](./QUICKSTART.md) |
|
||||
| 配置环境变量 | [.env.template](./.env.template) |
|
||||
| 故障排查 | [ARCHITECTURE.md# 故障排查](./ARCHITECTURE.md) |
|
||||
| 更新部署 | [README.md# 更新部署](./README.md) |
|
||||
| 查看日志 | [ARCHITECTURE.md# 监控和日志](./ARCHITECTURE.md) |
|
||||
| 安全加固 | [EXTERNAL-NGINX.md# 安全加固](./EXTERNAL-NGINX.md) |
|
||||
|
||||
## 🔍 常见问题快速查找
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| ------------------- | ------------------------------------------------------------------------ |
|
||||
| 502 Bad Gateway | [ARCHITECTURE.md - 问题 1](./ARCHITECTURE.md#问题-1-502-bad-gateway) |
|
||||
| NextAuth 重定向错误 | [ARCHITECTURE.md - 问题 2](./ARCHITECTURE.md#问题-2-nextauth-重定向错误) |
|
||||
| WebSocket 失败 | [ARCHITECTURE.md - 问题 3](./ARCHITECTURE.md#问题-3-websocket-连接失败) |
|
||||
| 文件上传失败 | [ARCHITECTURE.md - 问题 4](./ARCHITECTURE.md#问题-4-文件上传失败) |
|
||||
|
||||
## ⚠️ 重要注意事项
|
||||
|
||||
### 必须配置
|
||||
|
||||
1. **X-Forwarded-Proto** (外部 Nginx)
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
缺少会导致 HTTPS 重定向错误!
|
||||
|
||||
2. **NodePort 31080** (K8s)
|
||||
|
||||
```bash
|
||||
kubectl get svc -n ingress-nginx | grep 31080
|
||||
```
|
||||
|
||||
必须匹配外部 Nginx 的 proxy_pass 端口
|
||||
|
||||
3. **环境变量**
|
||||
```bash
|
||||
NEXT_PUBLIC_BASE_URL=https://chat.omniverseai.net
|
||||
NEXTAUTH_URL=https://chat.omniverseai.net
|
||||
```
|
||||
必须使用公网域名
|
||||
|
||||
### 不要提交到 Git
|
||||
|
||||
- `k8s/.env.k8s` - 包含敏感信息
|
||||
- `k8s/secret.yaml` - 由脚本生成,包含密钥
|
||||
- `k8s/*.secret.yaml` - 任何包含 secret 的文件
|
||||
|
||||
已在 `.gitignore` 中配置
|
||||
|
||||
## 🎯 推荐阅读顺序
|
||||
|
||||
### 第一次部署
|
||||
|
||||
1. [DEPLOY-TO-EXISTING.md](./DEPLOY-TO-EXISTING.md) - 快速部署
|
||||
2. [ARCHITECTURE.md](./ARCHITECTURE.md) - 理解架构
|
||||
3. [EXTERNAL-NGINX.md](./EXTERNAL-NGINX.md) - Nginx 配置
|
||||
|
||||
### 遇到问题
|
||||
|
||||
1. [ARCHITECTURE.md - 故障排查](./ARCHITECTURE.md#故障排查)
|
||||
2. 检查日志(文档中有详细说明)
|
||||
3. [README.md](./README.md) - 查看完整文档
|
||||
|
||||
### 深入了解
|
||||
|
||||
1. [ARCHITECTURE.md](./ARCHITECTURE.md) - 完整架构
|
||||
2. [README.md](./README.md) - 高级配置
|
||||
3. [EXTERNAL-NGINX.md](./EXTERNAL-NGINX.md) - Nginx 优化
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
1. 查看对应文档的故障排查部分
|
||||
2. 检查日志: `kubectl logs -n chat -l app=lobechat`
|
||||
3. 查看事件: `kubectl get events -n chat`
|
||||
4. 验证配置: `kubectl describe ingress -n chat`
|
||||
|
||||
## 🔄 版本信息
|
||||
|
||||
- Kubernetes: 1.20+
|
||||
- Nginx Ingress Controller: v1.8+
|
||||
- LobeChat: Latest
|
||||
- Node.js: 24 (in Docker)
|
||||
|
||||
---
|
||||
|
||||
**快速链接**:
|
||||
|
||||
- 🚀 [立即开始部署](./DEPLOY-TO-EXISTING.md)
|
||||
- 📖 [查看架构](./ARCHITECTURE.md)
|
||||
- 🔧 [配置 Nginx](./EXTERNAL-NGINX.md)
|
||||
- 📚 [完整文档](./README.md)
|
||||
@@ -0,0 +1,185 @@
|
||||
# 🚀 快速部署到 Kubernetes
|
||||
|
||||
本指南帮助你快速将 LobeChat 部署到 Kubernetes 的 chat namespace,并通过 <https://chat.omniverseai.net> 访问。
|
||||
|
||||
## 📋 准备工作检查清单
|
||||
|
||||
- [ ] Kubernetes 集群已就绪
|
||||
- [ ] kubectl 已安装并配置
|
||||
- [ ] Docker 已安装 (用于构建镜像)
|
||||
- [ ] Nginx Ingress Controller 已部署
|
||||
- [ ] cert-manager 已部署 (用于 TLS 证书)
|
||||
- [ ] PostgreSQL 数据库已准备就绪
|
||||
- [ ] S3/MinIO 存储已配置
|
||||
- [ ] 域名 chat.omniverseai.net 已指向 Ingress IP
|
||||
|
||||
## ⚡ 快速部署 (5 分钟)
|
||||
|
||||
### 1️⃣ 构建 Docker 镜像
|
||||
|
||||
```bash
|
||||
# 设置你的 Docker Registry
|
||||
export DOCKER_REGISTRY="your-registry.com"
|
||||
export IMAGE_TAG="v1.0.0"
|
||||
|
||||
# 构建并推送
|
||||
./k8s/build.sh
|
||||
```
|
||||
|
||||
### 2️⃣ 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制模板
|
||||
cp k8s/.env.template k8s/.env.k8s
|
||||
|
||||
# 生成密钥
|
||||
echo "KEY_VAULTS_SECRET=$(openssl rand -base64 32)"
|
||||
echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)"
|
||||
|
||||
# 编辑配置 (填入上面生成的密钥和其他配置)
|
||||
vim k8s/.env.k8s
|
||||
```
|
||||
|
||||
**必填配置项**:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
KEY_VAULTS_SECRET=<刚生成的密钥>
|
||||
NEXTAUTH_SECRET=<刚生成的密钥>
|
||||
S3_ENDPOINT=http://minio-service:9000
|
||||
S3_PUBLIC_DOMAIN=https://s3.omniverseai.net
|
||||
S3_ACCESS_KEY_ID=<你的 Access Key>
|
||||
S3_SECRET_ACCESS_KEY=<你的 Secret Key>
|
||||
```
|
||||
|
||||
### 3️⃣ 生成 Secret
|
||||
|
||||
```bash
|
||||
./k8s/generate-secret.sh
|
||||
```
|
||||
|
||||
### 4️⃣ 更新部署配置
|
||||
|
||||
编辑 `k8s/deployment.yaml`,更新镜像地址:
|
||||
|
||||
```yaml
|
||||
image: your-registry.com/lobechat:v1.0.0
|
||||
```
|
||||
|
||||
### 5️⃣ 部署到 Kubernetes
|
||||
|
||||
```bash
|
||||
# 一键部署
|
||||
./k8s/deploy.sh
|
||||
|
||||
# 或手动部署
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
### 6️⃣ 验证部署
|
||||
|
||||
```bash
|
||||
# 查看 Pod 状态 (应该显示 Running)
|
||||
kubectl get pods -n chat
|
||||
|
||||
# 查看服务
|
||||
kubectl get svc,ingress -n chat
|
||||
|
||||
# 查看日志
|
||||
kubectl logs -n chat -l app=lobechat -f
|
||||
```
|
||||
|
||||
### 7️⃣ 访问应用
|
||||
|
||||
打开浏览器访问: <https://chat.omniverseai.net>
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Pod 启动失败?
|
||||
|
||||
```bash
|
||||
kubectl describe pod -n chat <pod-name>
|
||||
kubectl logs -n chat <pod-name>
|
||||
```
|
||||
|
||||
### 证书未签发?
|
||||
|
||||
```bash
|
||||
# 检查证书状态
|
||||
kubectl describe certificate -n chat lobechat-tls
|
||||
|
||||
# 查看 cert-manager 日志
|
||||
kubectl logs -n cert-manager <cert-manager-pod>
|
||||
```
|
||||
|
||||
### 无法访问应用?
|
||||
|
||||
1. 检查 DNS: `nslookup chat.omniverseai.net`
|
||||
2. 检查 Ingress: `kubectl get ingress -n chat`
|
||||
3. 检查证书: `kubectl get certificate -n chat`
|
||||
4. 检查 Pod: `kubectl get pods -n chat`
|
||||
|
||||
### 数据库连接失败?
|
||||
|
||||
1. 验证 DATABASE_URL 格式正确
|
||||
2. 确保数据库可从集群内访问
|
||||
3. 检查数据库凭证是否正确
|
||||
|
||||
## 📊 查看状态
|
||||
|
||||
```bash
|
||||
# 完整状态概览
|
||||
kubectl get all -n chat
|
||||
|
||||
# 实时日志
|
||||
kubectl logs -n chat -l app=lobechat -f --tail=50
|
||||
|
||||
# 资源使用
|
||||
kubectl top pods -n chat
|
||||
```
|
||||
|
||||
## 🔄 更新部署
|
||||
|
||||
```bash
|
||||
# 构建新版本
|
||||
export IMAGE_TAG="v1.1.0"
|
||||
./k8s/build.sh
|
||||
|
||||
# 更新部署
|
||||
kubectl set image deployment/lobechat \
|
||||
lobechat=your-registry.com/lobechat:v1.1.0 \
|
||||
-n chat
|
||||
|
||||
# 查看更新状态
|
||||
kubectl rollout status deployment/lobechat -n chat
|
||||
```
|
||||
|
||||
## 🗑️ 清理部署
|
||||
|
||||
```bash
|
||||
# 删除所有资源 (保留 namespace)
|
||||
kubectl delete deployment,service,ingress,configmap,secret -n chat --all
|
||||
|
||||
# 删除 namespace
|
||||
kubectl delete namespace chat
|
||||
```
|
||||
|
||||
## 📚 详细文档
|
||||
|
||||
查看完整文档: [k8s/README.md](./README.md)
|
||||
|
||||
## 🆘 需要帮助?
|
||||
|
||||
1. 查看详细文档: `k8s/README.md`
|
||||
2. 查看 Pod 日志
|
||||
3. 检查 Kubernetes 事件
|
||||
4. 访问项目 Issues
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 安全提示**:
|
||||
|
||||
- 不要将 `k8s/.env.k8s` 和 `k8s/secret.yaml` 提交到 Git
|
||||
- 定期更新密钥和凭证
|
||||
- 使用强密码
|
||||
- 启用 HTTPS (已在 Ingress 中配置)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Kubernetes 部署文档结构
|
||||
|
||||
k8s/
|
||||
├── 📖 文档索引
|
||||
│ └── INDEX.md ← 从这里开始!文档导航
|
||||
│
|
||||
├── 🚀 快速部署指南
|
||||
│ ├── DEPLOY-TO-EXISTING.md ← 推荐:部署到现有架构(3步)
|
||||
│ └── QUICKSTART.md ← 5分钟快速开始
|
||||
│
|
||||
├── 📚 详细文档
|
||||
│ ├── ARCHITECTURE.md ← 系统架构和故障排查
|
||||
│ ├── EXTERNAL-NGINX.md ← 外部 Nginx 完整配置
|
||||
│ └── README.md ← 完整部署文档
|
||||
│
|
||||
├── ⚙️ 配置文件
|
||||
│ ├── namespace.yaml ← 创建 chat namespace
|
||||
│ ├── configmap.yaml ← 环境变量配置
|
||||
│ ├── secret.yaml ← 敏感信息(由脚本生成)
|
||||
│ ├── deployment.yaml ← Pod 定义
|
||||
│ ├── service.yaml ← ClusterIP Service
|
||||
│ ├── ingress.yaml ← Ingress 路由规则
|
||||
│ ├── ingress-nodeport.yaml ← Ingress Controller NodePort
|
||||
│ └── kustomization.yaml ← Kustomize 配置
|
||||
│
|
||||
├── 🔧 工具脚本
|
||||
│ ├── build.sh ← 构建 Docker 镜像
|
||||
│ ├── generate-secret.sh ← 生成 K8s Secret
|
||||
│ └── deploy.sh ← 一键部署
|
||||
│
|
||||
└── 📝 配置模板
|
||||
├── .env.template ← 环境变量模板
|
||||
└── .gitignore ← Git 忽略规则
|
||||
|
||||
推荐阅读顺序:
|
||||
1️⃣ INDEX.md - 了解文档结构
|
||||
2️⃣ DEPLOY-TO-EXISTING.md - 快速部署(适合当前架构)
|
||||
3️⃣ ARCHITECTURE.md - 理解系统架构
|
||||
4️⃣ EXTERNAL-NGINX.md - 配置外部 Nginx
|
||||
|
||||
遇到问题?
|
||||
→ 查看 ARCHITECTURE.md 的故障排查部分
|
||||
→ 检查日志: kubectl logs -n chat -l app=lobechat
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
# LobeChat Kubernetes 部署指南
|
||||
|
||||
本指南将帮助你将 LobeChat 部署到 Kubernetes 集群,并通过 `https://chat.omniverseai.net` 访问。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
### 1. 基础设施
|
||||
|
||||
- Kubernetes 集群 (v1.20+)
|
||||
- `kubectl` 命令行工具
|
||||
- Nginx Ingress Controller
|
||||
- cert-manager (用于自动 TLS 证书管理)
|
||||
- PostgreSQL 数据库 (带 pgvector 扩展)
|
||||
- S3 兼容的对象存储 (如 MinIO)
|
||||
|
||||
### 2. DNS 配置
|
||||
|
||||
确保域名 `chat.omniverseai.net` 已经指向你的 Kubernetes Ingress Controller 的外部 IP。
|
||||
|
||||
```bash
|
||||
# 检查 DNS 解析
|
||||
nslookup chat.omniverseai.net
|
||||
```
|
||||
|
||||
### 3. 所需工具
|
||||
|
||||
- Docker (用于构建镜像)
|
||||
- openssl (用于生成密钥)
|
||||
|
||||
## 🚀 快速部署
|
||||
|
||||
### 步骤 1: 构建并推送 Docker 镜像
|
||||
|
||||
```bash
|
||||
# 设置你的 Docker Registry
|
||||
export DOCKER_REGISTRY="your-registry.com"
|
||||
export IMAGE_TAG="v1.0.0"
|
||||
|
||||
# 构建并推送镜像
|
||||
./k8s/build.sh
|
||||
```
|
||||
|
||||
或者手动构建:
|
||||
|
||||
```bash
|
||||
docker build -t your-registry.com/lobechat:latest .
|
||||
docker push your-registry.com/lobechat:latest
|
||||
|
||||
# 更新 k8s/deployment.yaml 中的镜像地址
|
||||
```
|
||||
|
||||
### 步骤 2: 配置环境变量和密钥
|
||||
|
||||
1. 复制环境变量模板:
|
||||
|
||||
```bash
|
||||
cp k8s/.env.template k8s/.env.k8s
|
||||
```
|
||||
|
||||
2. 编辑 `k8s/.env.k8s` 并填入实际值:
|
||||
|
||||
```bash
|
||||
# 生成密钥
|
||||
openssl rand -base64 32 # 用于 KEY_VAULTS_SECRET
|
||||
openssl rand -base64 32 # 用于 NEXTAUTH_SECRET
|
||||
|
||||
# 编辑配置文件
|
||||
vim k8s/.env.k8s
|
||||
```
|
||||
|
||||
必填项:
|
||||
|
||||
- `DATABASE_URL`: PostgreSQL 连接字符串
|
||||
- `KEY_VAULTS_SECRET`: 数据库加密密钥
|
||||
- `NEXTAUTH_SECRET`: NextAuth 密钥
|
||||
- `S3_ACCESS_KEY_ID`: S3 访问密钥
|
||||
- `S3_SECRET_ACCESS_KEY`: S3 秘密密钥
|
||||
- `S3_ENDPOINT`: S3 端点 (内部)
|
||||
- `S3_PUBLIC_DOMAIN`: S3 公共访问域名
|
||||
|
||||
3. 生成 Kubernetes Secret:
|
||||
|
||||
```bash
|
||||
./k8s/generate-secret.sh
|
||||
```
|
||||
|
||||
### 步骤 3: 检查并更新配置
|
||||
|
||||
1. 检查 `k8s/configmap.yaml`:
|
||||
- 验证 `OLLAMA_PROXY_URL` 指向正确的 Ollama 服务
|
||||
- 确认公共 URL 配置正确
|
||||
|
||||
2. 检查 `k8s/ingress.yaml`:
|
||||
- 验证域名 `chat.omniverseai.net`
|
||||
- 确认 cert-manager issuer 名称正确 (默认: `letsencrypt-prod`)
|
||||
|
||||
3. 检查 `k8s/deployment.yaml`:
|
||||
- 验证镜像地址
|
||||
- 根据需要调整资源配置 (CPU / 内存)
|
||||
- 调整副本数量 (默认: 2)
|
||||
|
||||
### 步骤 4: 部署到 Kubernetes
|
||||
|
||||
```bash
|
||||
# 一键部署
|
||||
./k8s/deploy.sh
|
||||
```
|
||||
|
||||
或者手动部署:
|
||||
|
||||
```bash
|
||||
# 应用所有配置
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
kubectl apply -f k8s/secret.yaml
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
kubectl apply -f k8s/service.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# 或使用 kustomize
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
### 步骤 5: 验证部署
|
||||
|
||||
```bash
|
||||
# 查看 Pod 状态
|
||||
kubectl get pods -n chat
|
||||
|
||||
# 查看 Service
|
||||
kubectl get svc -n chat
|
||||
|
||||
# 查看 Ingress
|
||||
kubectl get ingress -n chat
|
||||
|
||||
# 查看 Pod 日志
|
||||
kubectl logs -n chat -l app=lobechat -f
|
||||
|
||||
# 检查 TLS 证书
|
||||
kubectl get certificate -n chat
|
||||
```
|
||||
|
||||
## 📦 配置详解
|
||||
|
||||
### ConfigMap (非敏感配置)
|
||||
|
||||
`k8s/configmap.yaml` 包含应用的非敏感配置:
|
||||
|
||||
- Node 环境配置
|
||||
- 数据库驱动类型
|
||||
- Ollama 服务地址
|
||||
- 认证设置
|
||||
- 公共 URL
|
||||
|
||||
### Secret (敏感信息)
|
||||
|
||||
`k8s/secret.yaml` 包含敏感信息:
|
||||
|
||||
- 数据库连接字符串
|
||||
- 加密密钥
|
||||
- S3 凭证
|
||||
- API 密钥
|
||||
|
||||
**⚠️ 重要**: 不要将 `k8s/secret.yaml` 提交到 Git!
|
||||
|
||||
### Deployment
|
||||
|
||||
- **副本数**: 默认 2 个副本,提供高可用
|
||||
- **资源限制**:
|
||||
- Requests: 512Mi 内存,250m CPU
|
||||
- Limits: 2Gi 内存,1000m CPU
|
||||
- **健康检查**: 配置了 liveness 和 readiness 探针
|
||||
- **反亲和性**: 尽量将 Pod 分散到不同节点
|
||||
|
||||
### Service
|
||||
|
||||
- **类型**: ClusterIP (集群内部访问)
|
||||
- **端口**: 80 -> 3210
|
||||
- **会话保持**: 启用 ClientIP 会话保持 (3 小时)
|
||||
|
||||
### Ingress
|
||||
|
||||
- **域名**: chat.omniverseai.net
|
||||
- **TLS**: 由 cert-manager 自动管理
|
||||
- **特性**:
|
||||
- 强制 HTTPS 重定向
|
||||
- WebSocket 支持
|
||||
- 文件上传限制: 100MB
|
||||
- 代理超时: 600 秒
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 自定义域名
|
||||
|
||||
编辑 `k8s/ingress.yaml`:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- your-domain.com
|
||||
rules:
|
||||
- host: your-domain.com
|
||||
```
|
||||
|
||||
同时更新 `k8s/configmap.yaml` 中的 URL:
|
||||
|
||||
```yaml
|
||||
data:
|
||||
NEXT_PUBLIC_BASE_URL: 'https://your-domain.com'
|
||||
NEXTAUTH_URL: 'https://your-domain.com'
|
||||
```
|
||||
|
||||
### 调整资源配置
|
||||
|
||||
根据实际负载调整资源:
|
||||
|
||||
```yaml
|
||||
# k8s/deployment.yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: '1Gi'
|
||||
cpu: '500m'
|
||||
limits:
|
||||
memory: '4Gi'
|
||||
cpu: '2000m'
|
||||
```
|
||||
|
||||
### 配置持久化存储
|
||||
|
||||
如果需要持久化数据:
|
||||
|
||||
```yaml
|
||||
# 添加 PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: lobechat-data
|
||||
namespace: chat
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
### 水平自动扩展
|
||||
|
||||
创建 HPA 配置:
|
||||
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: lobechat-hpa
|
||||
namespace: chat
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: lobechat
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### 1. Pod 无法启动
|
||||
|
||||
```bash
|
||||
# 查看 Pod 状态
|
||||
kubectl describe pod -n chat <pod-name>
|
||||
|
||||
# 查看日志
|
||||
kubectl logs -n chat <pod-name>
|
||||
|
||||
# 检查事件
|
||||
kubectl get events -n chat --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
常见问题:
|
||||
|
||||
- 镜像拉取失败:检查 imagePullSecrets 配置
|
||||
- 环境变量错误:检查 ConfigMap 和 Secret
|
||||
- 数据库连接失败:验证 DATABASE_URL
|
||||
|
||||
### 2. Ingress 无法访问
|
||||
|
||||
```bash
|
||||
# 检查 Ingress 状态
|
||||
kubectl describe ingress -n chat lobechat-ingress
|
||||
|
||||
# 查看 Ingress Controller 日志
|
||||
kubectl logs -n ingress-nginx <controller-pod>
|
||||
|
||||
# 检查证书状态
|
||||
kubectl describe certificate -n chat lobechat-tls
|
||||
```
|
||||
|
||||
常见问题:
|
||||
|
||||
- DNS 未解析:检查域名 DNS 配置
|
||||
- 证书未就绪:等待 cert-manager 签发证书
|
||||
- 后端服务不可用:检查 Service 和 Pod 状态
|
||||
|
||||
### 3. 数据库连接问题
|
||||
|
||||
```bash
|
||||
# 进入 Pod 测试连接
|
||||
kubectl exec -it -n chat sh < pod-name > --
|
||||
|
||||
# 测试数据库连接 (如果有 psql)
|
||||
# psql $DATABASE_URL
|
||||
```
|
||||
|
||||
### 4. 查看完整日志
|
||||
|
||||
```bash
|
||||
# 实时查看所有 Pod 日志
|
||||
kubectl logs -n chat -l app=lobechat -f --tail=100
|
||||
|
||||
# 查看特定 Pod 的日志
|
||||
kubectl logs -n chat < pod-name > --previous
|
||||
```
|
||||
|
||||
## 🔄 更新部署
|
||||
|
||||
### 滚动更新
|
||||
|
||||
```bash
|
||||
# 构建新镜像
|
||||
export IMAGE_TAG="v1.1.0"
|
||||
./k8s/build.sh
|
||||
|
||||
# 更新部署
|
||||
kubectl set image deployment/lobechat \
|
||||
lobechat=your-registry.com/lobechat:v1.1.0 \
|
||||
-n chat
|
||||
|
||||
# 查看滚动更新状态
|
||||
kubectl rollout status deployment/lobechat -n chat
|
||||
```
|
||||
|
||||
### 回滚
|
||||
|
||||
```bash
|
||||
# 查看部署历史
|
||||
kubectl rollout history deployment/lobechat -n chat
|
||||
|
||||
# 回滚到上一个版本
|
||||
kubectl rollout undo deployment/lobechat -n chat
|
||||
|
||||
# 回滚到特定版本
|
||||
kubectl rollout undo deployment/lobechat -n chat --to-revision=2
|
||||
```
|
||||
|
||||
### 更新配置
|
||||
|
||||
```bash
|
||||
# 更新 ConfigMap
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
|
||||
# 更新 Secret
|
||||
./k8s/generate-secret.sh
|
||||
kubectl apply -f k8s/secret.yaml
|
||||
|
||||
# 重启 Pod 使配置生效
|
||||
kubectl rollout restart deployment/lobechat -n chat
|
||||
```
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
### 查看资源使用
|
||||
|
||||
```bash
|
||||
# 查看 Pod 资源使用
|
||||
kubectl top pods -n chat
|
||||
|
||||
# 查看节点资源使用
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
### 集成 Prometheus (可选)
|
||||
|
||||
添加 ServiceMonitor:
|
||||
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: lobechat
|
||||
namespace: chat
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: lobechat
|
||||
endpoints:
|
||||
- port: http
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
## 🗑️ 清理部署
|
||||
|
||||
```bash
|
||||
# 删除所有资源
|
||||
kubectl delete -k k8s/
|
||||
|
||||
# 或逐个删除
|
||||
kubectl delete -f k8s/ingress.yaml
|
||||
kubectl delete -f k8s/service.yaml
|
||||
kubectl delete -f k8s/deployment.yaml
|
||||
kubectl delete -f k8s/secret.yaml
|
||||
kubectl delete -f k8s/configmap.yaml
|
||||
kubectl delete -f k8s/namespace.yaml
|
||||
```
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
k8s/
|
||||
├── .env.template # 环境变量模板
|
||||
├── .env.k8s # 实际环境变量 (不提交到 Git)
|
||||
├── namespace.yaml # Namespace 定义
|
||||
├── configmap.yaml # 非敏感配置
|
||||
├── secret.yaml # 敏感信息 (由脚本生成)
|
||||
├── deployment.yaml # Deployment 配置
|
||||
├── service.yaml # Service 配置
|
||||
├── ingress.yaml # Ingress 配置
|
||||
├── kustomization.yaml # Kustomize 配置
|
||||
├── generate-secret.sh # Secret 生成脚本
|
||||
├── build.sh # 镜像构建脚本
|
||||
├── deploy.sh # 部署脚本
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 🔐 安全建议
|
||||
|
||||
1. **不要提交敏感信息到 Git**:
|
||||
|
||||
```bash
|
||||
# 添加到 .gitignore
|
||||
k8s/.env.k8s
|
||||
k8s/secret.yaml
|
||||
```
|
||||
|
||||
2. **使用 RBAC 限制访问**:
|
||||
- 为应用创建专用 ServiceAccount
|
||||
- 配置最小权限原则
|
||||
|
||||
3. **启用 Network Policies**:
|
||||
- 限制 Pod 间通信
|
||||
- 只允许必要的入站 / 出站流量
|
||||
|
||||
4. **定期更新镜像**:
|
||||
- 使用特定版本标签而非 `latest`
|
||||
- 定期扫描镜像漏洞
|
||||
|
||||
5. **保护 Secret**:
|
||||
- 考虑使用外部密钥管理系统 (如 HashiCorp Vault)
|
||||
- 启用 Kubernetes Secret 加密
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
如果遇到问题:
|
||||
|
||||
1. 查看本文档的故障排查部分
|
||||
2. 检查 Pod 日志: `kubectl logs -n chat -l app=lobechat`
|
||||
3. 检查事件: `kubectl get events -n chat`
|
||||
4. 访问项目 GitHub Issues
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||
- [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
|
||||
- [cert-manager 文档](https://cert-manager.io/docs/)
|
||||
- [LobeChat 官方文档](https://github.com/lobehub/lobe-chat)
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build and Push Docker Image Script for LobeChat
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
IMAGE_NAME="lobechat"
|
||||
REGISTRY="${DOCKER_REGISTRY:-your-registry.com}"
|
||||
TAG="${IMAGE_TAG:-latest}"
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
|
||||
# Function to print colored messages
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to check if Docker is installed
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker is not installed. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
success "Docker is installed"
|
||||
}
|
||||
|
||||
# Function to build Docker image
|
||||
build_image() {
|
||||
info "Building Docker image: $FULL_IMAGE"
|
||||
echo ""
|
||||
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_ENABLE_NEXT_AUTH=1 \
|
||||
--build-arg NEXT_PUBLIC_ENABLE_LOCAL_AUTH=1 \
|
||||
-t $FULL_IMAGE \
|
||||
.
|
||||
|
||||
success "Image built successfully"
|
||||
}
|
||||
|
||||
# Function to push Docker image
|
||||
push_image() {
|
||||
info "Pushing Docker image to registry..."
|
||||
docker push $FULL_IMAGE
|
||||
success "Image pushed successfully"
|
||||
}
|
||||
|
||||
# Function to update deployment YAML
|
||||
update_deployment() {
|
||||
info "Updating deployment.yaml with new image..."
|
||||
|
||||
# Update the image in deployment.yaml
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
sed -i '' "s|image:.*|image: $FULL_IMAGE|g" k8s/deployment.yaml
|
||||
else
|
||||
# Linux
|
||||
sed -i "s|image:.*|image: $FULL_IMAGE|g" k8s/deployment.yaml
|
||||
fi
|
||||
|
||||
success "Deployment YAML updated"
|
||||
}
|
||||
|
||||
# Main build flow
|
||||
main() {
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ LobeChat Docker Build & Push Script ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
info "Registry: $REGISTRY"
|
||||
info "Image: $IMAGE_NAME"
|
||||
info "Tag: $TAG"
|
||||
info "Full Image: $FULL_IMAGE"
|
||||
echo ""
|
||||
|
||||
check_docker
|
||||
|
||||
# Prompt for confirmation
|
||||
read -p "Do you want to proceed with the build? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
warning "Build cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_image
|
||||
|
||||
echo ""
|
||||
read -p "Do you want to push the image to registry? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
push_image
|
||||
update_deployment
|
||||
else
|
||||
warning "Image push skipped"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
success "Build process completed!"
|
||||
echo ""
|
||||
info "Next steps:"
|
||||
echo " 1. Run './k8s/deploy.sh' to deploy to Kubernetes"
|
||||
echo " 2. Or manually apply: kubectl apply -k k8s/"
|
||||
}
|
||||
|
||||
# Show usage if help is requested
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
echo "Usage: $0"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " DOCKER_REGISTRY - Docker registry URL (default: your-registry.com)"
|
||||
echo " IMAGE_TAG - Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " DOCKER_REGISTRY=registry.example.com IMAGE_TAG=v1.0.0 $0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -0,0 +1,38 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: lobechat-config
|
||||
namespace: chat
|
||||
data:
|
||||
# Node Configuration
|
||||
NODE_ENV: "production"
|
||||
PORT: "3210"
|
||||
HOSTNAME: "0.0.0.0"
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_DRIVER: "node"
|
||||
|
||||
# Ollama Configuration
|
||||
ENABLED_OLLAMA: "1"
|
||||
OLLAMA_PROXY_URL: "http://ollama-service:11434"
|
||||
|
||||
# Authentication
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH: "1"
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH: "1"
|
||||
NEXT_AUTH_SSO_SESSION_STRATEGY: "jwt"
|
||||
NEXT_AUTH_SSO_PROVIDERS: ""
|
||||
AUTH_TRUST_HOST: "true"
|
||||
|
||||
# Email Service
|
||||
EMAIL_ENABLED: "false"
|
||||
|
||||
# Public URLs
|
||||
NEXT_PUBLIC_BASE_URL: "https://chat.omniverseai.net"
|
||||
NEXTAUTH_URL: "https://chat.omniverseai.net"
|
||||
|
||||
# S3 Storage Configuration
|
||||
S3_BUCKET: "lobechat"
|
||||
S3_ENABLE_PATH_STYLE: "true"
|
||||
|
||||
# Middleware Settings
|
||||
MIDDLEWARE_REWRITE_THROUGH_LOCAL: "1"
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Kubernetes Deployment Script for LobeChat
|
||||
# Deploy to chat namespace at https://chat.omniverseai.net
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
NAMESPACE="chat"
|
||||
APP_NAME="lobechat"
|
||||
|
||||
# Function to print colored messages
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to check if kubectl is installed
|
||||
check_kubectl() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
error "kubectl is not installed. Please install kubectl first."
|
||||
exit 1
|
||||
fi
|
||||
success "kubectl is installed"
|
||||
}
|
||||
|
||||
# Function to check cluster connectivity
|
||||
check_cluster() {
|
||||
info "Checking Kubernetes cluster connectivity..."
|
||||
if ! kubectl cluster-info &> /dev/null; then
|
||||
error "Cannot connect to Kubernetes cluster. Please check your kubeconfig."
|
||||
exit 1
|
||||
fi
|
||||
success "Connected to Kubernetes cluster"
|
||||
}
|
||||
|
||||
# Function to create namespace
|
||||
create_namespace() {
|
||||
info "Creating namespace: $NAMESPACE"
|
||||
if kubectl get namespace $NAMESPACE &> /dev/null; then
|
||||
warning "Namespace $NAMESPACE already exists"
|
||||
else
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
success "Namespace created"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to apply ConfigMap
|
||||
apply_configmap() {
|
||||
info "Applying ConfigMap..."
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
success "ConfigMap applied"
|
||||
}
|
||||
|
||||
# Function to apply Secret
|
||||
apply_secret() {
|
||||
info "Applying Secret..."
|
||||
if [ ! -f "k8s/secret.yaml" ]; then
|
||||
error "Secret file not found. Run './k8s/generate-secret.sh' first."
|
||||
exit 1
|
||||
fi
|
||||
kubectl apply -f k8s/secret.yaml
|
||||
success "Secret applied"
|
||||
}
|
||||
|
||||
# Function to apply Deployment
|
||||
apply_deployment() {
|
||||
info "Applying Deployment..."
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
success "Deployment applied"
|
||||
}
|
||||
|
||||
# Function to apply Service
|
||||
apply_service() {
|
||||
info "Applying Service..."
|
||||
kubectl apply -f k8s/service.yaml
|
||||
success "Service applied"
|
||||
}
|
||||
|
||||
# Function to apply Ingress
|
||||
apply_ingress() {
|
||||
info "Applying Ingress..."
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
success "Ingress applied"
|
||||
}
|
||||
|
||||
# Function to wait for deployment
|
||||
wait_for_deployment() {
|
||||
info "Waiting for deployment to be ready..."
|
||||
kubectl rollout status deployment/$APP_NAME -n $NAMESPACE --timeout=300s
|
||||
success "Deployment is ready"
|
||||
}
|
||||
|
||||
# Function to show deployment status
|
||||
show_status() {
|
||||
echo ""
|
||||
info "Deployment Status:"
|
||||
echo "=================="
|
||||
kubectl get pods -n $NAMESPACE -l app=$APP_NAME
|
||||
echo ""
|
||||
kubectl get svc -n $NAMESPACE
|
||||
echo ""
|
||||
kubectl get ingress -n $NAMESPACE
|
||||
}
|
||||
|
||||
# Main deployment flow
|
||||
main() {
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ LobeChat Kubernetes Deployment Script ║${NC}"
|
||||
echo -e "${GREEN}║ Namespace: chat ║${NC}"
|
||||
echo -e "${GREEN}║ Domain: https://chat.omniverseai.net ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
check_kubectl
|
||||
check_cluster
|
||||
|
||||
echo ""
|
||||
info "Starting deployment..."
|
||||
echo ""
|
||||
|
||||
create_namespace
|
||||
apply_configmap
|
||||
apply_secret
|
||||
apply_deployment
|
||||
apply_service
|
||||
apply_ingress
|
||||
|
||||
echo ""
|
||||
wait_for_deployment
|
||||
|
||||
show_status
|
||||
|
||||
echo ""
|
||||
success "Deployment completed successfully!"
|
||||
echo ""
|
||||
info "Access your application at: https://chat.omniverseai.net"
|
||||
echo ""
|
||||
warning "Make sure:"
|
||||
echo " 1. DNS is configured to point to your ingress controller"
|
||||
echo " 2. TLS certificate is properly issued by cert-manager"
|
||||
echo " 3. PostgreSQL database is accessible"
|
||||
echo " 4. MinIO/S3 storage is accessible"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -0,0 +1,86 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lobechat
|
||||
namespace: chat
|
||||
labels:
|
||||
app: lobechat
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: lobechat
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: lobechat
|
||||
spec:
|
||||
containers:
|
||||
- name: lobechat
|
||||
# Replace with your actual image
|
||||
image: your-registry/lobechat:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3210
|
||||
name: http
|
||||
protocol: TCP
|
||||
|
||||
# Environment variables from ConfigMap
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: lobechat-config
|
||||
- secretRef:
|
||||
name: lobechat-secrets
|
||||
|
||||
# Health checks
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3210
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3210
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Resource limits
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
|
||||
# Security context
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
# Image pull secrets (if using private registry)
|
||||
# imagePullSecrets:
|
||||
# - name: registry-secret
|
||||
|
||||
# Pod anti-affinity for high availability
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values:
|
||||
- lobechat
|
||||
topologyKey: kubernetes.io/hostname
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Kubernetes Secret Generator Script
|
||||
# This script creates a Kubernetes secret from environment variables
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== LobeChat Kubernetes Secret Generator ===${NC}"
|
||||
|
||||
# Check if .env.k8s exists
|
||||
if [ ! -f "k8s/.env.k8s" ]; then
|
||||
echo -e "${YELLOW}Warning: k8s/.env.k8s not found${NC}"
|
||||
echo "Creating from template..."
|
||||
cp k8s/.env.template k8s/.env.k8s
|
||||
echo -e "${RED}Please edit k8s/.env.k8s with your actual values${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load environment variables from .env.k8s
|
||||
source k8s/.env.k8s
|
||||
|
||||
# Validate required variables
|
||||
REQUIRED_VARS=(
|
||||
"DATABASE_URL"
|
||||
"KEY_VAULTS_SECRET"
|
||||
"NEXTAUTH_SECRET"
|
||||
"S3_ACCESS_KEY_ID"
|
||||
"S3_SECRET_ACCESS_KEY"
|
||||
)
|
||||
|
||||
for var in "${REQUIRED_VARS[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo -e "${RED}Error: Required variable $var is not set${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate secret YAML
|
||||
echo -e "${GREEN}Generating secret.yaml...${NC}"
|
||||
|
||||
cat > k8s/secret.yaml << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lobechat-secrets
|
||||
namespace: chat
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Database Configuration
|
||||
DATABASE_URL: "${DATABASE_URL}"
|
||||
KEY_VAULTS_SECRET: "${KEY_VAULTS_SECRET}"
|
||||
|
||||
# NextAuth Configuration
|
||||
NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
|
||||
NEXT_AUTH_SECRET: "${NEXT_AUTH_SECRET:-$NEXTAUTH_SECRET}"
|
||||
|
||||
# S3/MinIO Configuration
|
||||
S3_ENDPOINT: "${S3_ENDPOINT}"
|
||||
S3_PUBLIC_DOMAIN: "${S3_PUBLIC_DOMAIN}"
|
||||
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID}"
|
||||
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY}"
|
||||
EOF
|
||||
|
||||
# Add optional variables if set
|
||||
if [ ! -z "${OPENAI_API_KEY}" ]; then
|
||||
echo " OPENAI_API_KEY: \"${OPENAI_API_KEY}\"" >> k8s/secret.yaml
|
||||
fi
|
||||
|
||||
if [ ! -z "${ANTHROPIC_API_KEY}" ]; then
|
||||
echo " ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY}\"" >> k8s/secret.yaml
|
||||
fi
|
||||
|
||||
if [ ! -z "${GOOGLE_API_KEY}" ]; then
|
||||
echo " GOOGLE_API_KEY: \"${GOOGLE_API_KEY}\"" >> k8s/secret.yaml
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Secret generated successfully at k8s/secret.yaml${NC}"
|
||||
echo -e "${YELLOW}Note: This file contains sensitive data. Do not commit it to git!${NC}"
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ingress Controller NodePort Service Configuration
|
||||
# This exposes the ingress controller on port 31080 to work with external nginx
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
labels:
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/component: controller
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
nodePort: 31080
|
||||
- name: https
|
||||
port: 443
|
||||
targetPort: 443
|
||||
protocol: TCP
|
||||
nodePort: 31443
|
||||
selector:
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/component: controller
|
||||
|
||||
---
|
||||
# Note: If you already have ingress-nginx installed, you may need to:
|
||||
# 1. Check the existing service name and namespace
|
||||
# 2. Update the existing service instead of creating a new one
|
||||
#
|
||||
# To update existing service:
|
||||
# kubectl patch svc ingress-nginx-controller -n ingress-nginx -p '{"spec":{"type":"NodePort","ports":[{"port":80,"targetPort":80,"protocol":"TCP","nodePort":31080,"name":"http"},{"port":443,"targetPort":443,"protocol":"TCP","nodePort":31443,"name":"https"}]}}'
|
||||
@@ -0,0 +1,49 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: lobechat-ingress
|
||||
namespace: chat
|
||||
annotations:
|
||||
# Nginx ingress controller annotations
|
||||
kubernetes.io/ingress.class: nginx
|
||||
|
||||
# TLS is handled by external nginx (85.239.233.16)
|
||||
# So we don't redirect to HTTPS at ingress level
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||
|
||||
# Trust X-Forwarded-* headers from external nginx
|
||||
nginx.ingress.kubernetes.io/use-forwarded-headers: "true"
|
||||
|
||||
# Preserve client IP and protocol from external nginx
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Proxy settings
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
|
||||
# WebSocket support
|
||||
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
||||
nginx.ingress.kubernetes.io/websocket-services: "lobechat-service"
|
||||
nginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_forwarded_for"
|
||||
|
||||
# Rate limiting (optional)
|
||||
# nginx.ingress.kubernetes.io/limit-rps: "100"
|
||||
|
||||
spec:
|
||||
# No TLS configuration needed - handled by external nginx
|
||||
rules:
|
||||
- host: chat.omniverseai.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lobechat-service
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: chat
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- configmap.yaml
|
||||
- secret.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
|
||||
# Common labels
|
||||
commonLabels:
|
||||
app.kubernetes.io/name: lobechat
|
||||
app.kubernetes.io/instance: production
|
||||
app.kubernetes.io/version: "1.0.0"
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/managed-by: kubectl
|
||||
|
||||
# Images (can be used to override image tags)
|
||||
images:
|
||||
- name: your-registry/lobechat
|
||||
newTag: latest
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: chat
|
||||
labels:
|
||||
name: chat
|
||||
app: lobechat
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: lobechat-service
|
||||
namespace: chat
|
||||
labels:
|
||||
app: lobechat
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3210
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: lobechat
|
||||
sessionAffinity: ClientIP
|
||||
sessionAffinityConfig:
|
||||
clientIP:
|
||||
timeoutSeconds: 10800
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "email_verification_tokens" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_verification_tokens_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "password_reset_tokens_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "email_verification_tokens_user_id_idx" ON "email_verification_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "email_verification_tokens_token_idx" ON "email_verification_tokens" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "email_verification_tokens_email_idx" ON "email_verification_tokens" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_token_idx" ON "password_reset_tokens" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -336,6 +336,13 @@
|
||||
"when": 1763987922211,
|
||||
"tag": "0047_add_slug_document",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1764501397655,
|
||||
"tag": "0048_dark_blue_blade",
|
||||
"breakpoints": true
|
||||
}
|
||||
],
|
||||
"version": "6"
|
||||
|
||||
@@ -797,5 +797,23 @@
|
||||
"bps": true,
|
||||
"folderMillis": 1763987922211,
|
||||
"hash": "f823b521f4d25e5dc5ab238b372727d2d2d7f0aed27b5eabc8a9608ce4e50568"
|
||||
},
|
||||
{
|
||||
"sql": [
|
||||
"CREATE TABLE \"email_verification_tokens\" (\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"user_id\" text NOT NULL,\n\t\"email\" text NOT NULL,\n\t\"token\" text NOT NULL,\n\t\"expires_at\" timestamp with time zone NOT NULL,\n\t\"accessed_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updated_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"email_verification_tokens_token_unique\" UNIQUE(\"token\")\n);\n",
|
||||
"\nCREATE TABLE \"password_reset_tokens\" (\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"user_id\" text NOT NULL,\n\t\"token\" text NOT NULL,\n\t\"expires_at\" timestamp with time zone NOT NULL,\n\t\"accessed_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updated_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"password_reset_tokens_token_unique\" UNIQUE(\"token\")\n);\n",
|
||||
"\nALTER TABLE \"users\" ADD COLUMN \"password_hash\" text;",
|
||||
"\nALTER TABLE \"email_verification_tokens\" ADD CONSTRAINT \"email_verification_tokens_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
|
||||
"\nALTER TABLE \"password_reset_tokens\" ADD CONSTRAINT \"password_reset_tokens_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
|
||||
"\nCREATE INDEX \"email_verification_tokens_user_id_idx\" ON \"email_verification_tokens\" USING btree (\"user_id\");",
|
||||
"\nCREATE INDEX \"email_verification_tokens_token_idx\" ON \"email_verification_tokens\" USING btree (\"token\");",
|
||||
"\nCREATE INDEX \"email_verification_tokens_email_idx\" ON \"email_verification_tokens\" USING btree (\"email\");",
|
||||
"\nCREATE INDEX \"password_reset_tokens_user_id_idx\" ON \"password_reset_tokens\" USING btree (\"user_id\");",
|
||||
"\nCREATE INDEX \"password_reset_tokens_token_idx\" ON \"password_reset_tokens\" USING btree (\"token\");",
|
||||
"\nCREATE INDEX \"users_email_idx\" ON \"users\" USING btree (\"email\");"
|
||||
],
|
||||
"bps": true,
|
||||
"folderMillis": 1764501397655,
|
||||
"hash": "b46e4c42ada1c48dd000870e155675d8e37ebb56e7eed33704daa813b6220210"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -16,8 +16,6 @@ import { LobeChatDatabase } from '../../type';
|
||||
import { KnowledgeBaseModel } from '../knowledgeBase';
|
||||
import { getTestDB } from './_util';
|
||||
|
||||
import { sleep } from '@/utils/sleep';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'session-group-model-test-user-id';
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment node
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { sleep } from '@/utils/sleep';
|
||||
|
||||
import { users } from '../../schemas';
|
||||
import { LobeChatDatabase } from '../../type';
|
||||
import { LocalAuthModel, TokenExpiredError, TokenNotFoundError } from '../localAuth';
|
||||
import { getTestDB } from './_util';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'local-auth-test-user-id';
|
||||
const localAuthModel = new LocalAuthModel(serverDB);
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users).where(eq(users.id, userId));
|
||||
});
|
||||
|
||||
describe('LocalAuthModel', () => {
|
||||
describe('Password Reset Tokens', () => {
|
||||
it('should create a password reset token', async () => {
|
||||
const result = await localAuthModel.createPasswordResetToken(userId);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should verify a valid password reset token', async () => {
|
||||
const { token } = await localAuthModel.createPasswordResetToken(userId);
|
||||
|
||||
const verifiedUserId = await localAuthModel.verifyPasswordResetToken(token);
|
||||
|
||||
expect(verifiedUserId).toBe(userId);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent token', async () => {
|
||||
await expect(localAuthModel.verifyPasswordResetToken('invalid-token')).rejects.toThrow(
|
||||
TokenNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for expired token', async () => {
|
||||
// Create token with 0.01 minute (0.6 seconds) expiry
|
||||
const { token } = await localAuthModel.createPasswordResetToken(userId, 0.01);
|
||||
|
||||
// Wait for token to expire
|
||||
await sleep(1000);
|
||||
|
||||
await expect(localAuthModel.verifyPasswordResetToken(token)).rejects.toThrow(
|
||||
TokenExpiredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should consume token after verification (one-time use)', async () => {
|
||||
const { token } = await localAuthModel.createPasswordResetToken(userId);
|
||||
|
||||
// First verification should succeed
|
||||
await localAuthModel.verifyPasswordResetToken(token);
|
||||
|
||||
// Second verification should fail
|
||||
await expect(localAuthModel.verifyPasswordResetToken(token)).rejects.toThrow(
|
||||
TokenNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete all expired password reset tokens', async () => {
|
||||
// Create expired token
|
||||
await localAuthModel.createPasswordResetToken(userId, 0.01);
|
||||
// Create valid token
|
||||
await localAuthModel.createPasswordResetToken(userId, 60);
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
await localAuthModel.cleanupExpiredPasswordResetTokens();
|
||||
|
||||
// Check that only valid token remains (this is indirect - we can't easily query count)
|
||||
// Instead, we verify that creating new tokens still works
|
||||
const result = await localAuthModel.createPasswordResetToken(userId);
|
||||
expect(result.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete all password reset tokens for a user', async () => {
|
||||
await localAuthModel.createPasswordResetToken(userId);
|
||||
await localAuthModel.createPasswordResetToken(userId);
|
||||
|
||||
await localAuthModel.deleteUserPasswordResetTokens(userId);
|
||||
|
||||
// Try to create a new token - should succeed
|
||||
const result = await localAuthModel.createPasswordResetToken(userId);
|
||||
expect(result.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Verification Tokens', () => {
|
||||
const testEmail = 'test@example.com';
|
||||
|
||||
it('should create an email verification token', async () => {
|
||||
const result = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should verify a valid email verification token', async () => {
|
||||
const { token } = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
|
||||
const result = await localAuthModel.verifyEmailToken(token);
|
||||
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.email).toBe(testEmail);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent email verification token', async () => {
|
||||
await expect(localAuthModel.verifyEmailToken('invalid-token')).rejects.toThrow(
|
||||
TokenNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for expired email verification token', async () => {
|
||||
// Create token with 0.01 minute (0.6 seconds) expiry
|
||||
const { token } = await localAuthModel.createEmailVerificationToken(userId, testEmail, 0.01);
|
||||
|
||||
// Wait for token to expire
|
||||
await sleep(1000);
|
||||
|
||||
await expect(localAuthModel.verifyEmailToken(token)).rejects.toThrow(TokenExpiredError);
|
||||
});
|
||||
|
||||
it('should consume email token after verification (one-time use)', async () => {
|
||||
const { token } = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
|
||||
// First verification should succeed
|
||||
await localAuthModel.verifyEmailToken(token);
|
||||
|
||||
// Second verification should fail
|
||||
await expect(localAuthModel.verifyEmailToken(token)).rejects.toThrow(TokenNotFoundError);
|
||||
});
|
||||
|
||||
it('should delete all expired email verification tokens', async () => {
|
||||
// Create expired token
|
||||
await localAuthModel.createEmailVerificationToken(userId, testEmail, 0.01);
|
||||
// Create valid token
|
||||
await localAuthModel.createEmailVerificationToken(userId, testEmail, 24 * 60);
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
await localAuthModel.cleanupExpiredEmailVerificationTokens();
|
||||
|
||||
// Verify that creating new tokens still works
|
||||
const result = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
expect(result.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete all email verification tokens for a user', async () => {
|
||||
await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
await localAuthModel.createEmailVerificationToken(userId, 'another@example.com');
|
||||
|
||||
await localAuthModel.deleteUserEmailVerificationTokens(userId);
|
||||
|
||||
// Try to create a new token - should succeed
|
||||
const result = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
expect(result.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete all email verification tokens for an email', async () => {
|
||||
await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
|
||||
await localAuthModel.deleteEmailVerificationTokensByEmail(testEmail);
|
||||
|
||||
// Try to create a new token - should succeed
|
||||
const result = await localAuthModel.createEmailVerificationToken(userId, testEmail);
|
||||
expect(result.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
NewEmailVerificationToken,
|
||||
NewPasswordResetToken,
|
||||
emailVerificationTokens,
|
||||
passwordResetTokens,
|
||||
} from '../schemas';
|
||||
import { LobeChatDatabase } from '../type';
|
||||
|
||||
export class TokenExpiredError extends TRPCError {
|
||||
constructor() {
|
||||
super({ code: 'BAD_REQUEST', message: 'token expired' });
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenNotFoundError extends TRPCError {
|
||||
constructor() {
|
||||
super({ code: 'NOT_FOUND', message: 'token not found' });
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalAuthModel {
|
||||
private db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
// ============= Password Reset Tokens =============
|
||||
|
||||
/**
|
||||
* Create a password reset token
|
||||
* @param userId - User ID
|
||||
* @param expiresInMinutes - Token expiration time in minutes (default: 60)
|
||||
* @returns Created token object
|
||||
*/
|
||||
createPasswordResetToken = async (
|
||||
userId: string,
|
||||
expiresInMinutes = 60,
|
||||
): Promise<{ id: string; token: string }> => {
|
||||
const id = nanoid();
|
||||
const token = nanoid(32);
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
const [result] = await this.db
|
||||
.insert(passwordResetTokens)
|
||||
.values({
|
||||
expiresAt,
|
||||
id,
|
||||
token,
|
||||
userId,
|
||||
} as NewPasswordResetToken)
|
||||
.returning();
|
||||
|
||||
return { id: result.id, token: result.token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify and consume a password reset token
|
||||
* @param token - Reset token string
|
||||
* @returns User ID if token is valid
|
||||
* @throws TokenNotFoundError if token doesn't exist
|
||||
* @throws TokenExpiredError if token is expired
|
||||
*/
|
||||
verifyPasswordResetToken = async (token: string): Promise<string> => {
|
||||
const tokenRecord = await this.db.query.passwordResetTokens.findFirst({
|
||||
where: eq(passwordResetTokens.token, token),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new TokenNotFoundError();
|
||||
}
|
||||
|
||||
if (new Date() > tokenRecord.expiresAt) {
|
||||
// Clean up expired token
|
||||
await this.db.delete(passwordResetTokens).where(eq(passwordResetTokens.token, token));
|
||||
throw new TokenExpiredError();
|
||||
}
|
||||
|
||||
const userId = tokenRecord.userId;
|
||||
|
||||
// Delete token after verification (one-time use)
|
||||
await this.db.delete(passwordResetTokens).where(eq(passwordResetTokens.token, token));
|
||||
|
||||
return userId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all expired password reset tokens
|
||||
*/
|
||||
cleanupExpiredPasswordResetTokens = async (): Promise<void> => {
|
||||
await this.db.delete(passwordResetTokens).where(lt(passwordResetTokens.expiresAt, new Date()));
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all password reset tokens for a user
|
||||
* @param userId - User ID
|
||||
*/
|
||||
deleteUserPasswordResetTokens = async (userId: string): Promise<void> => {
|
||||
await this.db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId));
|
||||
};
|
||||
|
||||
// ============= Email Verification Tokens =============
|
||||
|
||||
/**
|
||||
* Create an email verification token
|
||||
* @param userId - User ID
|
||||
* @param email - Email to verify
|
||||
* @param expiresInMinutes - Token expiration time in minutes (default: 24 hours)
|
||||
* @returns Created token object
|
||||
*/
|
||||
createEmailVerificationToken = async (
|
||||
userId: string,
|
||||
email: string,
|
||||
expiresInMinutes = 24 * 60,
|
||||
): Promise<{ id: string; token: string }> => {
|
||||
const id = nanoid();
|
||||
const token = nanoid(32);
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
const [result] = await this.db
|
||||
.insert(emailVerificationTokens)
|
||||
.values({
|
||||
email,
|
||||
expiresAt,
|
||||
id,
|
||||
token,
|
||||
userId,
|
||||
} as NewEmailVerificationToken)
|
||||
.returning();
|
||||
|
||||
return { id: result.id, token: result.token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify and consume an email verification token
|
||||
* @param token - Verification token string
|
||||
* @returns Object with userId and email if token is valid
|
||||
* @throws TokenNotFoundError if token doesn't exist
|
||||
* @throws TokenExpiredError if token is expired
|
||||
*/
|
||||
verifyEmailToken = async (token: string): Promise<{ email: string; userId: string }> => {
|
||||
const tokenRecord = await this.db.query.emailVerificationTokens.findFirst({
|
||||
where: eq(emailVerificationTokens.token, token),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new TokenNotFoundError();
|
||||
}
|
||||
|
||||
if (new Date() > tokenRecord.expiresAt) {
|
||||
// Clean up expired token
|
||||
await this.db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.token, token));
|
||||
throw new TokenExpiredError();
|
||||
}
|
||||
|
||||
const { email, userId } = tokenRecord;
|
||||
|
||||
// Delete token after verification (one-time use)
|
||||
await this.db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.token, token));
|
||||
|
||||
return { email, userId };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all expired email verification tokens
|
||||
*/
|
||||
cleanupExpiredEmailVerificationTokens = async (): Promise<void> => {
|
||||
await this.db
|
||||
.delete(emailVerificationTokens)
|
||||
.where(lt(emailVerificationTokens.expiresAt, new Date()));
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all email verification tokens for a user
|
||||
* @param userId - User ID
|
||||
*/
|
||||
deleteUserEmailVerificationTokens = async (userId: string): Promise<void> => {
|
||||
await this.db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all email verification tokens for an email
|
||||
* @param email - Email address
|
||||
*/
|
||||
deleteEmailVerificationTokensByEmail = async (email: string): Promise<void> => {
|
||||
await this.db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.email, email));
|
||||
};
|
||||
}
|
||||
@@ -222,6 +222,24 @@ export class UserModel {
|
||||
return db.query.users.findFirst({ where: eq(users.email, email) });
|
||||
};
|
||||
|
||||
static findByUsername = async (db: LobeChatDatabase, username: string) => {
|
||||
return db.query.users.findFirst({ where: eq(users.username, username) });
|
||||
};
|
||||
|
||||
static updatePassword = async (db: LobeChatDatabase, userId: string, passwordHash: string) => {
|
||||
return db
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
};
|
||||
|
||||
static verifyEmail = async (db: LobeChatDatabase, userId: string, email: string) => {
|
||||
return db
|
||||
.update(users)
|
||||
.set({ email, emailVerifiedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
};
|
||||
|
||||
static getUserApiKeys = async (
|
||||
db: LobeChatDatabase,
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { hashPassword } from '@/utils/server/password';
|
||||
|
||||
const password = 'Admin123!@#';
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
console.log('Password:', password);
|
||||
console.log('Hash:', hash);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
import ForgotPasswordBox from './ForgotPasswordBox';
|
||||
|
||||
export default () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ForgotPasswordBox />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { DOCUMENTS_REFER_URL, PRIVACY_URL, TERMS_URL } from '@lobechat/const';
|
||||
import { Button, Text } from '@lobehub/ui';
|
||||
import { LobeHub } from '@lobehub/ui/brand';
|
||||
import { Col, Flex, Form, Input, Row, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
min-width: 360px;
|
||||
border: 1px solid ${token.colorBorder};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
contentCard: css`
|
||||
padding-block: 2.5rem;
|
||||
padding-inline: 2rem;
|
||||
`,
|
||||
description: css`
|
||||
margin: 0;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
footer: css`
|
||||
padding: 1rem;
|
||||
border-block-start: 1px solid ${token.colorBorder};
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
color: ${token.colorTextDescription};
|
||||
|
||||
background: ${token.colorBgElevated};
|
||||
`,
|
||||
text: css`
|
||||
text-align: center;
|
||||
`,
|
||||
title: css`
|
||||
margin: 0;
|
||||
color: ${token.colorTextHeading};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ResetPasswordFormValues {
|
||||
confirmPassword: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('clerk');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordReset, setPasswordReset] = useState(false);
|
||||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
||||
|
||||
const token = searchParams.get('token');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
message.error('Invalid or missing reset token');
|
||||
setTimeout(() => {
|
||||
router.push('/next-auth/forgot-password');
|
||||
}, 2000);
|
||||
}
|
||||
}, [token, router]);
|
||||
|
||||
const { mutateAsync: resetPassword } = lambdaQuery.localAuth.resetPassword.useMutation();
|
||||
|
||||
const handleResetPassword = async (values: ResetPasswordFormValues) => {
|
||||
if (!token) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await resetPassword({
|
||||
password: values.password,
|
||||
token,
|
||||
});
|
||||
setPasswordReset(true);
|
||||
message.success('Password reset successfully! Redirecting to sign in...');
|
||||
setTimeout(() => {
|
||||
router.push('/next-auth/signin');
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
setLoading(false);
|
||||
message.error(error.message || 'Failed to reset password. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const footerBtns = [
|
||||
{ href: DOCUMENTS_REFER_URL, id: 0, label: t('footerPageLink__help') },
|
||||
{ href: PRIVACY_URL, id: 1, label: t('footerPageLink__privacy') },
|
||||
{ href: TERMS_URL, id: 2, label: t('footerPageLink__terms') },
|
||||
];
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.contentCard}>
|
||||
{/* Card Body */}
|
||||
<Flex gap="large" vertical>
|
||||
{/* Header */}
|
||||
<div className={styles.text}>
|
||||
<Text as={'h4'} className={styles.title}>
|
||||
<div>
|
||||
<LobeHub size={48} />
|
||||
</div>
|
||||
Reset Password
|
||||
</Text>
|
||||
<Text as={'p'} className={styles.description}>
|
||||
{passwordReset ? 'Password reset successful' : 'Enter your new password'}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Content */}
|
||||
{!passwordReset ? (
|
||||
<Form form={form} layout="vertical" onFinish={handleResetPassword}>
|
||||
<Form.Item
|
||||
label="New Password"
|
||||
name="password"
|
||||
rules={[
|
||||
{ message: 'Please enter a password', required: true },
|
||||
{
|
||||
message:
|
||||
'Password must be at least 8 characters with uppercase, lowercase, number, and special character',
|
||||
min: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
dependencies={['password']}
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
rules={[
|
||||
{ message: 'Please confirm your password', required: true },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button block htmlType="submit" loading={loading} size="large" type="primary">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<Flex gap="middle" vertical>
|
||||
<Text as={'p'} className={styles.description} style={{ textAlign: 'center' }}>
|
||||
Your password has been reset successfully. You will be redirected to the sign in
|
||||
page shortly.
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{/* Footer */}
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Flex justify="left" style={{ height: '100%' }}>
|
||||
<BrandWatermark />
|
||||
</Flex>
|
||||
</Col>
|
||||
<Col offset={4} span={8}>
|
||||
<Flex justify="right">
|
||||
{footerBtns.map((btn) => (
|
||||
<Button key={btn.id} onClick={() => router.push(btn.href)} size="small" type="text">
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
import ResetPasswordBox from './ResetPasswordBox';
|
||||
|
||||
export default () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ResetPasswordBox />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -3,16 +3,18 @@
|
||||
import { BRANDING_NAME, DOCUMENTS_REFER_URL, PRIVACY_URL, TERMS_URL } from '@lobechat/const';
|
||||
import { Button, Text } from '@lobehub/ui';
|
||||
import { LobeHub } from '@lobehub/ui/brand';
|
||||
import { Col, Flex, Row, Skeleton } from 'antd';
|
||||
import { Col, Divider, Flex, Form, Input, Row, Skeleton } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { AuthError } from 'next-auth';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import AuthIcons from '@/components/NextAuth/AuthIcons';
|
||||
import { authEnv } from '@/envs/auth';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
@@ -66,13 +68,21 @@ const BtnListLoading = memo(() => {
|
||||
* but using client components.
|
||||
* ref: https://authjs.dev/guides/pages/signin
|
||||
*/
|
||||
interface LocalAuthFormValues {
|
||||
identifier: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('clerk');
|
||||
const router = useRouter();
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
const [localAuthLoading, setLocalAuthLoading] = useState(false);
|
||||
const [form] = Form.useForm<LocalAuthFormValues>();
|
||||
|
||||
const oAuthSSOProviders = useUserStore((s) => s.oAuthSSOProviders);
|
||||
const enableLocalAuth = authEnv.NEXT_PUBLIC_ENABLE_LOCAL_AUTH;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -99,6 +109,28 @@ export default memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalAuthSignIn = async (values: LocalAuthFormValues) => {
|
||||
setLocalAuthLoading(true);
|
||||
try {
|
||||
// Use window.location.origin to ensure correct host in redirect
|
||||
const fullCallbackUrl = callbackUrl.startsWith('/')
|
||||
? `${window.location.origin}${callbackUrl}`
|
||||
: callbackUrl;
|
||||
|
||||
await signIn('credentials', {
|
||||
identifier: values.identifier,
|
||||
password: values.password,
|
||||
redirectTo: fullCallbackUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
setLocalAuthLoading(false);
|
||||
if (error instanceof AuthError) {
|
||||
return router.push(`/next-auth/?error=${error.type}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const footerBtns = [
|
||||
{ href: DOCUMENTS_REFER_URL, id: 0, label: t('footerPageLink__help') },
|
||||
{ href: PRIVACY_URL, id: 1, label: t('footerPageLink__privacy') },
|
||||
@@ -124,6 +156,53 @@ export default memo(() => {
|
||||
</div>
|
||||
{/* Content */}
|
||||
<Flex gap="small" vertical>
|
||||
{/* Local Authentication Form */}
|
||||
{enableLocalAuth && (
|
||||
<>
|
||||
<Form form={form} onFinish={handleLocalAuthSignIn}>
|
||||
<Form.Item
|
||||
name="identifier"
|
||||
rules={[{ message: 'Please enter your email or username', required: true }]}
|
||||
>
|
||||
<Input placeholder="Email or Username" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ message: 'Please enter your password', required: true }]}
|
||||
>
|
||||
<Input.Password placeholder="Password" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
block
|
||||
htmlType="submit"
|
||||
loading={localAuthLoading}
|
||||
size="large"
|
||||
type="primary"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Flex justify="space-between">
|
||||
<Link href="/next-auth/signup">
|
||||
<Button size="small" type="link">
|
||||
Create Account
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/next-auth/forgot-password">
|
||||
<Button size="small" type="link">
|
||||
Forgot Password?
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
{oAuthSSOProviders && oAuthSSOProviders.length > 0 && (
|
||||
<Divider plain>Or continue with</Divider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OAuth SSO Providers */}
|
||||
{oAuthSSOProviders ? (
|
||||
oAuthSSOProviders.map((provider) => (
|
||||
<Button
|
||||
@@ -136,9 +215,9 @@ export default memo(() => {
|
||||
{provider}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
) : !enableLocalAuth ? (
|
||||
<BtnListLoading />
|
||||
)}
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { BRANDING_NAME, DOCUMENTS_REFER_URL, PRIVACY_URL, TERMS_URL } from '@lobechat/const';
|
||||
import { Button, Text } from '@lobehub/ui';
|
||||
import { LobeHub } from '@lobehub/ui/brand';
|
||||
import { Col, Flex, Form, Input, Row, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
button: css`
|
||||
text-transform: capitalize;
|
||||
`,
|
||||
container: css`
|
||||
min-width: 360px;
|
||||
border: 1px solid ${token.colorBorder};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
contentCard: css`
|
||||
padding-block: 2.5rem;
|
||||
padding-inline: 2rem;
|
||||
`,
|
||||
description: css`
|
||||
margin: 0;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
footer: css`
|
||||
padding: 1rem;
|
||||
border-block-start: 1px solid ${token.colorBorder};
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
color: ${token.colorTextDescription};
|
||||
|
||||
background: ${token.colorBgElevated};
|
||||
`,
|
||||
text: css`
|
||||
text-align: center;
|
||||
`,
|
||||
title: css`
|
||||
margin: 0;
|
||||
color: ${token.colorTextHeading};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface SignUpFormValues {
|
||||
confirmPassword: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('clerk');
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm<SignUpFormValues>();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get('callbackUrl') ?? '/';
|
||||
|
||||
const { mutateAsync: registerWithUsername } =
|
||||
lambdaQuery.localAuth.registerWithUsername.useMutation();
|
||||
const { mutateAsync: registerWithEmail } = lambdaQuery.localAuth.registerWithEmail.useMutation();
|
||||
|
||||
const handleSignUp = async (values: SignUpFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Register with email if provided, otherwise with username
|
||||
if (values.email) {
|
||||
await registerWithEmail({
|
||||
email: values.email,
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
message.success('Account created! Please check your email to verify your account.');
|
||||
} else {
|
||||
await registerWithUsername({
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
message.success('Account created successfully! You can now sign in.');
|
||||
}
|
||||
|
||||
// Auto sign in after registration (only for username-based registration)
|
||||
if (!values.email) {
|
||||
await signIn('credentials', {
|
||||
identifier: values.username,
|
||||
password: values.password,
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
} else {
|
||||
// Redirect to sign in page for email-based registration
|
||||
setTimeout(() => {
|
||||
router.push(`/next-auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`);
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setLoading(false);
|
||||
message.error(error.message || 'Failed to create account. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const footerBtns = [
|
||||
{ href: DOCUMENTS_REFER_URL, id: 0, label: t('footerPageLink__help') },
|
||||
{ href: PRIVACY_URL, id: 1, label: t('footerPageLink__privacy') },
|
||||
{ href: TERMS_URL, id: 2, label: t('footerPageLink__terms') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.contentCard}>
|
||||
{/* Card Body */}
|
||||
<Flex gap="large" vertical>
|
||||
{/* Header */}
|
||||
<div className={styles.text}>
|
||||
<Text as={'h4'} className={styles.title}>
|
||||
<div>
|
||||
<LobeHub size={48} />
|
||||
</div>
|
||||
Create Account
|
||||
</Text>
|
||||
<Text as={'p'} className={styles.description}>
|
||||
Sign up for {BRANDING_NAME}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<Form form={form} layout="vertical" onFinish={handleSignUp}>
|
||||
<Form.Item
|
||||
label="Username"
|
||||
name="username"
|
||||
rules={[
|
||||
{ message: 'Please enter a username', required: true },
|
||||
{
|
||||
message: 'Username must be at least 3 characters',
|
||||
min: 3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="username" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Email (Optional)"
|
||||
name="email"
|
||||
rules={[{ message: 'Please enter a valid email', type: 'email' }]}
|
||||
>
|
||||
<Input placeholder="email@example.com" size="large" type="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="First Name (Optional)" name="firstName">
|
||||
<Input placeholder="John" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Last Name (Optional)" name="lastName">
|
||||
<Input placeholder="Doe" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
rules={[
|
||||
{ message: 'Please enter a password', required: true },
|
||||
{
|
||||
message:
|
||||
'Password must be at least 8 characters with uppercase, lowercase, number, and special character',
|
||||
min: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
dependencies={['password']}
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
rules={[
|
||||
{ message: 'Please confirm your password', required: true },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button block htmlType="submit" loading={loading} size="large" type="primary">
|
||||
Create Account
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Flex justify="center">
|
||||
<Text as={'span'} className={styles.description}>
|
||||
Already have an account?{' '}
|
||||
<Link href="/next-auth/signin">
|
||||
<Button size="small" type="link">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Flex>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{/* Footer */}
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Flex justify="left" style={{ height: '100%' }}>
|
||||
<BrandWatermark />
|
||||
</Flex>
|
||||
</Col>
|
||||
<Col offset={4} span={8}>
|
||||
<Flex justify="right">
|
||||
{footerBtns.map((btn) => (
|
||||
<Button key={btn.id} onClick={() => router.push(btn.href)} size="small" type="text">
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
import SignUpBox from './SignUpBox';
|
||||
|
||||
export default () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<SignUpBox />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -1,13 +1,11 @@
|
||||
|
||||
import Client from './Client';
|
||||
|
||||
|
||||
const MobileProfileUsagePage = async () => {
|
||||
const MobileProfileUsagePage = () => {
|
||||
return <Client mobile={true} />;
|
||||
};
|
||||
|
||||
const DesktopProfileUsagePage = async () => {
|
||||
const DesktopProfileUsagePage = () => {
|
||||
return <Client mobile={false} />;
|
||||
};
|
||||
|
||||
export { DesktopProfileUsagePage,MobileProfileUsagePage };
|
||||
export { DesktopProfileUsagePage, MobileProfileUsagePage };
|
||||
|
||||
@@ -37,7 +37,7 @@ const RootLayout = async ({ children, params }: RootLayoutProps) => {
|
||||
<script crossOrigin="anonymous" src="https://unpkg.com/react-scan/dist/auto.global.js" />
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
<body suppressHydrationWarning>
|
||||
<NuqsAdapter>
|
||||
<GlobalProvider
|
||||
appearance={theme}
|
||||
@@ -47,9 +47,7 @@ const RootLayout = async ({ children, params }: RootLayoutProps) => {
|
||||
primaryColor={primaryColor}
|
||||
variants={variants}
|
||||
>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<PWAInstall />
|
||||
</GlobalProvider>
|
||||
</NuqsAdapter>
|
||||
|
||||
@@ -47,6 +47,11 @@ export const getAuthConfig = () => {
|
||||
NEXT_PUBLIC_ENABLE_CLERK_AUTH: z.boolean().optional(),
|
||||
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* Whether to enable local authentication (username/email + password)
|
||||
*/
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH: z.boolean().optional(),
|
||||
},
|
||||
server: {
|
||||
// Clerk
|
||||
@@ -79,6 +84,7 @@ export const getAuthConfig = () => {
|
||||
|
||||
// Next Auth
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH: process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1',
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH: process.env.NEXT_PUBLIC_ENABLE_LOCAL_AUTH === '1',
|
||||
NEXT_AUTH_SSO_PROVIDERS: process.env.NEXT_AUTH_SSO_PROVIDERS,
|
||||
NEXT_AUTH_SECRET: process.env.NEXT_AUTH_SECRET,
|
||||
NEXT_AUTH_DEBUG: !!process.env.NEXT_AUTH_DEBUG,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextAuthConfig } from 'next-auth';
|
||||
import { getAuthConfig } from '@/envs/auth';
|
||||
|
||||
import { LobeNextAuthDbAdapter } from './adapter';
|
||||
import { credentialsProvider } from './providers/credentials';
|
||||
import { ssoProviders } from './sso-providers';
|
||||
|
||||
const {
|
||||
@@ -10,6 +11,7 @@ const {
|
||||
NEXT_AUTH_SECRET,
|
||||
NEXT_AUTH_SSO_SESSION_STRATEGY,
|
||||
NEXT_AUTH_SSO_PROVIDERS,
|
||||
NEXT_PUBLIC_ENABLE_LOCAL_AUTH,
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH,
|
||||
} = getAuthConfig();
|
||||
|
||||
@@ -28,6 +30,20 @@ export const initSSOProviders = () => {
|
||||
: [];
|
||||
};
|
||||
|
||||
export const initAuthProviders = () => {
|
||||
const providers = [];
|
||||
|
||||
// Add SSO providers
|
||||
providers.push(...initSSOProviders());
|
||||
|
||||
// Add credentials provider if local auth is enabled
|
||||
if (NEXT_PUBLIC_ENABLE_LOCAL_AUTH) {
|
||||
providers.push(credentialsProvider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
};
|
||||
|
||||
// Notice this is only an object, not a full Auth.js instance
|
||||
export default {
|
||||
adapter: NEXT_PUBLIC_ENABLE_NEXT_AUTH ? LobeNextAuthDbAdapter() : undefined,
|
||||
@@ -40,6 +56,50 @@ export default {
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async redirect(params) {
|
||||
const { url, baseUrl } = params;
|
||||
|
||||
console.log('[NextAuth] redirect callback:', { baseUrl, url });
|
||||
|
||||
// If url is relative, use baseUrl
|
||||
if (url.startsWith('/')) {
|
||||
const redirectUrl = `${baseUrl}${url}`;
|
||||
console.log('[NextAuth] Relative URL, redirecting to:', redirectUrl);
|
||||
return redirectUrl;
|
||||
}
|
||||
|
||||
// Parse the URL to check if it's safe to redirect
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const baseUrlObj = new URL(baseUrl);
|
||||
|
||||
// Allow redirect if:
|
||||
// 1. Same port (e.g., both on 3210)
|
||||
// 2. Both are localhost/127.0.0.1 or both are same network
|
||||
const isSamePort = urlObj.port === baseUrlObj.port;
|
||||
const isLocalhost =
|
||||
(urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1') &&
|
||||
(baseUrlObj.hostname === 'localhost' || baseUrlObj.hostname === '127.0.0.1');
|
||||
const isSameHost = urlObj.hostname === baseUrlObj.hostname;
|
||||
|
||||
if (isSamePort && (isSameHost || isLocalhost)) {
|
||||
console.log('[NextAuth] Safe redirect, returning url:', url);
|
||||
return url;
|
||||
}
|
||||
|
||||
// If ports match but hosts differ (localhost vs IP), trust the URL
|
||||
if (isSamePort) {
|
||||
console.log('[NextAuth] Same port, trusting url:', url);
|
||||
return url;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[NextAuth] Error parsing URL:', e);
|
||||
}
|
||||
|
||||
// Default to baseUrl for external URLs
|
||||
console.log('[NextAuth] External URL, returning baseUrl:', baseUrl);
|
||||
return baseUrl;
|
||||
},
|
||||
async session({ session, token, user }) {
|
||||
if (session.user) {
|
||||
// ref: https://authjs.dev/guides/extending-the-session#with-database
|
||||
@@ -57,7 +117,7 @@ export default {
|
||||
error: '/next-auth/error',
|
||||
signIn: '/next-auth/signin',
|
||||
},
|
||||
providers: initSSOProviders(),
|
||||
providers: initAuthProviders(),
|
||||
secret: NEXT_AUTH_SECRET ?? process.env.AUTH_SECRET,
|
||||
session: {
|
||||
// Force use JWT if server service is disabled
|
||||
|
||||
@@ -165,16 +165,20 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
|
||||
|
||||
if (enableNextAuth) {
|
||||
log('Attempting NextAuth authentication');
|
||||
console.log('[Context] NextAuth enabled, attempting authentication');
|
||||
try {
|
||||
const { default: NextAuth } = await import('@/libs/next-auth');
|
||||
|
||||
const session = await NextAuth.auth();
|
||||
console.log('[Context] NextAuth session:', session);
|
||||
if (session && session?.user?.id) {
|
||||
auth = session.user;
|
||||
userId = session.user.id;
|
||||
log('NextAuth authentication successful, userId: %s', userId);
|
||||
console.log('[Context] NextAuth authentication successful, userId:', userId);
|
||||
} else {
|
||||
log('NextAuth authentication failed, no valid session');
|
||||
console.log('[Context] NextAuth authentication failed, no valid session');
|
||||
}
|
||||
return createContextInner({
|
||||
nextAuth: auth,
|
||||
@@ -183,7 +187,7 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
|
||||
});
|
||||
} catch (e) {
|
||||
log('NextAuth authentication error: %O', e);
|
||||
console.error('next auth err', e);
|
||||
console.error('[Context] next auth err', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { pino } from '@/libs/logger';
|
||||
|
||||
export interface EmailOptions {
|
||||
html?: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email service for sending transactional emails
|
||||
* Currently uses console logging (development mode)
|
||||
* TODO: Integrate with actual email provider (SendGrid, AWS SES, etc.)
|
||||
*/
|
||||
export class EmailService {
|
||||
private readonly from: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
// Get configuration from environment variables
|
||||
this.from = process.env.EMAIL_FROM || 'noreply@lobechat.com';
|
||||
this.enabled = process.env.EMAIL_ENABLED === 'true';
|
||||
|
||||
if (!this.enabled) {
|
||||
pino.warn('Email service is disabled. Set EMAIL_ENABLED=true to enable.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*/
|
||||
async send(options: EmailOptions): Promise<boolean> {
|
||||
if (!this.enabled) {
|
||||
pino.info(
|
||||
{
|
||||
from: this.from,
|
||||
subject: options.subject,
|
||||
to: options.to,
|
||||
},
|
||||
'[EMAIL_DISABLED] Would send email',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement actual email sending
|
||||
// For now, just log the email content
|
||||
pino.info(
|
||||
{
|
||||
from: this.from,
|
||||
html: options.html?.slice(0, 100),
|
||||
subject: options.subject,
|
||||
to: options.to,
|
||||
},
|
||||
'[EMAIL] Sending email',
|
||||
);
|
||||
|
||||
// Simulate email sending
|
||||
return true;
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[EMAIL] Failed to send email');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
async sendVerificationEmail(to: string, token: string, username?: string): Promise<boolean> {
|
||||
const verifyUrl = `${this.getBaseUrl()}/verify-email?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Verify Your Email Address</h2>
|
||||
<p>Hi ${username || 'there'},</p>
|
||||
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="${verifyUrl}"
|
||||
style="background-color: #0070f3; color: white; padding: 12px 24px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="color: #666; word-break: break-all;">${verifyUrl}</p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
This is an automated email from LobeChat. Please do not reply.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
Hi ${username || 'there'},
|
||||
|
||||
Thank you for registering! Please verify your email address by visiting:
|
||||
${verifyUrl}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
`;
|
||||
|
||||
return this.send({
|
||||
html,
|
||||
subject: 'Verify your email address',
|
||||
text,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(to: string, token: string, username?: string): Promise<boolean> {
|
||||
const resetUrl = `${this.getBaseUrl()}/reset-password?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Hi ${username || 'there'},</p>
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="${resetUrl}"
|
||||
style="background-color: #0070f3; color: white; padding: 12px 24px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Reset Password
|
||||
</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="color: #666; word-break: break-all;">${resetUrl}</p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request a password reset, you can safely ignore this email.
|
||||
Your password will remain unchanged.</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
This is an automated email from LobeChat. Please do not reply.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
Hi ${username || 'there'},
|
||||
|
||||
We received a request to reset your password. Visit the link below to create a new password:
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
`;
|
||||
|
||||
return this.send({
|
||||
html,
|
||||
subject: 'Reset your password',
|
||||
text,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email after successful registration
|
||||
*/
|
||||
async sendWelcomeEmail(to: string, username?: string): Promise<boolean> {
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Welcome to LobeChat! 🤯</h2>
|
||||
<p>Hi ${username || 'there'},</p>
|
||||
<p>Your account has been successfully created. You can now start using LobeChat!</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="${this.getBaseUrl()}"
|
||||
style="background-color: #0070f3; color: white; padding: 12px 24px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
<p>If you have any questions, feel free to reach out to our support team.</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
This is an automated email from LobeChat. Please do not reply.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
Hi ${username || 'there'},
|
||||
|
||||
Welcome to LobeChat! Your account has been successfully created.
|
||||
|
||||
Visit ${this.getBaseUrl()} to get started.
|
||||
|
||||
If you have any questions, feel free to reach out to our support team.
|
||||
`;
|
||||
|
||||
return this.send({
|
||||
html,
|
||||
subject: 'Welcome to LobeChat!',
|
||||
text,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL from environment or default
|
||||
*/
|
||||
private getBaseUrl(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
process.env.VERCEL_URL ||
|
||||
'http://localhost:3210'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { groupRouter } from './group';
|
||||
import { imageRouter } from './image';
|
||||
import { importerRouter } from './importer';
|
||||
import { knowledgeBaseRouter } from './knowledgeBase';
|
||||
import { localAuthRouter } from './localAuth';
|
||||
import { marketRouter } from './market';
|
||||
import { messageRouter } from './message';
|
||||
import { pluginRouter } from './plugin';
|
||||
@@ -53,6 +54,7 @@ export const lambdaRouter = router({
|
||||
image: imageRouter,
|
||||
importer: importerRouter,
|
||||
knowledgeBase: knowledgeBaseRouter,
|
||||
localAuth: localAuthRouter,
|
||||
market: marketRouter,
|
||||
message: messageRouter,
|
||||
plugin: pluginRouter,
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LocalAuthModel } from '@/database/models/localAuth';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { pino } from '@/libs/logger';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { EmailService } from '@/server/modules/EmailService';
|
||||
import { hashPassword, validatePasswordStrength, verifyPassword } from '@/utils/server/password';
|
||||
|
||||
const localAuthProcedure = publicProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
emailService: new EmailService(),
|
||||
localAuthModel: new LocalAuthModel(ctx.serverDB),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Admin procedure - requires authentication and admin role
|
||||
const adminProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
|
||||
// Check if user has admin role
|
||||
const userRoleRecords = await ctx.serverDB.query.userRoles.findMany({
|
||||
where: (userRoles, { eq }) => eq(userRoles.userId, ctx.userId),
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isAdmin = userRoleRecords.some((ur) => {
|
||||
const roleName = (ur.role as any)?.name;
|
||||
return roleName === 'admin' || roleName === 'super_admin';
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admin access required',
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
emailService: new EmailService(),
|
||||
localAuthModel: new LocalAuthModel(ctx.serverDB),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const localAuthRouter = router({
|
||||
// ========== Admin Endpoints ==========
|
||||
/**
|
||||
* Admin: Create user with email and password
|
||||
*/
|
||||
adminCreateUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.boolean().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
password: z.string(),
|
||||
sendWelcomeEmail: z.boolean().optional(),
|
||||
username: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const {
|
||||
email,
|
||||
emailVerified = false,
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
sendWelcomeEmail = false,
|
||||
username,
|
||||
} = input;
|
||||
|
||||
// Validate that at least email or username is provided
|
||||
if (!email && !username) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Either email or username must be provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: passwordValidation.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
if (email) {
|
||||
const existingUserByEmail = await UserModel.findByEmail(ctx.serverDB, email);
|
||||
if (existingUserByEmail) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Email already registered',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
if (username) {
|
||||
const existingUserByUsername = await UserModel.findByUsername(ctx.serverDB, username);
|
||||
if (existingUserByUsername) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Username already taken',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const userId = nanoid();
|
||||
const result = await UserModel.createUser(ctx.serverDB, {
|
||||
email,
|
||||
emailVerifiedAt: emailVerified && email ? new Date() : undefined,
|
||||
firstName,
|
||||
fullName: firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName,
|
||||
id: userId,
|
||||
lastName,
|
||||
passwordHash,
|
||||
username,
|
||||
});
|
||||
|
||||
if (result.duplicate) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'User already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Send welcome email if requested and email is provided
|
||||
if (sendWelcomeEmail && email) {
|
||||
try {
|
||||
await ctx.emailService.sendWelcomeEmail(email, username || firstName);
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send welcome email');
|
||||
// Don't fail user creation if email fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'User created successfully',
|
||||
success: true,
|
||||
user: {
|
||||
email: result.user?.email,
|
||||
firstName: result.user?.firstName,
|
||||
id: userId,
|
||||
lastName: result.user?.lastName,
|
||||
username: result.user?.username,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: Delete user
|
||||
*/
|
||||
adminDeleteUser: adminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { userId } = input;
|
||||
|
||||
// Check if user exists
|
||||
const user = await UserModel.findById(ctx.serverDB, userId);
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent deleting yourself
|
||||
if (userId === ctx.userId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cannot delete your own account',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete user (CASCADE will handle related data)
|
||||
await UserModel.deleteUser(ctx.serverDB, userId);
|
||||
|
||||
return {
|
||||
message: 'User deleted successfully',
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: Update user password
|
||||
*/
|
||||
adminUpdateUserPassword: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
notifyUser: z.boolean().optional(),
|
||||
password: z.string(),
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { notifyUser = false, password, userId } = input;
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: passwordValidation.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await UserModel.findById(ctx.serverDB, userId);
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Update password
|
||||
await UserModel.updatePassword(ctx.serverDB, userId, passwordHash);
|
||||
|
||||
// Send notification email if requested
|
||||
if (notifyUser && user.email) {
|
||||
try {
|
||||
await ctx.emailService.send({
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Your Password Has Been Updated</h2>
|
||||
<p>Hi ${user.username || user.firstName || 'there'},</p>
|
||||
<p>Your password has been updated by an administrator.</p>
|
||||
<p>If you did not request this change, please contact support immediately.</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
This is an automated email from LobeChat. Please do not reply.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
subject: 'Your password has been updated',
|
||||
text: `Your password has been updated by an administrator. If you did not request this change, please contact support immediately.`,
|
||||
to: user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send password update notification');
|
||||
// Don't fail the operation if email fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Password updated successfully',
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Login with email/username and password
|
||||
* Returns user info if credentials are valid
|
||||
*/
|
||||
login: localAuthProcedure
|
||||
.input(
|
||||
z.object({
|
||||
identifier: z.string(), // email or username
|
||||
password: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { identifier, password } = input;
|
||||
|
||||
// Find user by email or username
|
||||
let user = await UserModel.findByEmail(ctx.serverDB, identifier);
|
||||
if (!user) {
|
||||
user = await UserModel.findByUsername(ctx.serverDB, identifier);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has a password (registered with local auth)
|
||||
if (!user.passwordHash) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'This account uses social login. Please use the appropriate login method.',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is verified (only for email-based accounts)
|
||||
if (user.email && !user.emailVerifiedAt) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Please verify your email before logging in',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
message: 'Login successful',
|
||||
success: true,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Register with email and password
|
||||
*/
|
||||
registerWithEmail: localAuthProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
password: z.string(),
|
||||
username: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, firstName, lastName, password, username } = input;
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: passwordValidation.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingUserByEmail = await UserModel.findByEmail(ctx.serverDB, email);
|
||||
if (existingUserByEmail) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Email already registered',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username already exists (if provided)
|
||||
if (username) {
|
||||
const existingUserByUsername = await UserModel.findByUsername(ctx.serverDB, username);
|
||||
if (existingUserByUsername) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Username already taken',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const userId = nanoid();
|
||||
const result = await UserModel.createUser(ctx.serverDB, {
|
||||
email,
|
||||
firstName,
|
||||
fullName: firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName,
|
||||
id: userId,
|
||||
lastName,
|
||||
passwordHash,
|
||||
username,
|
||||
});
|
||||
|
||||
if (result.duplicate) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'User already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Create email verification token
|
||||
const { token } = await ctx.localAuthModel.createEmailVerificationToken(userId, email);
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await ctx.emailService.sendVerificationEmail(email, token, username || firstName);
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send verification email');
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
success: true,
|
||||
userId,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Register with username and password (no email)
|
||||
*/
|
||||
registerWithUsername: localAuthProcedure
|
||||
.input(
|
||||
z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
password: z.string(),
|
||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { firstName, lastName, password, username } = input;
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: passwordValidation.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await UserModel.findByUsername(ctx.serverDB, username);
|
||||
if (existingUser) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Username already taken',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const userId = nanoid();
|
||||
const result = await UserModel.createUser(ctx.serverDB, {
|
||||
firstName,
|
||||
fullName: firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName,
|
||||
id: userId,
|
||||
lastName,
|
||||
passwordHash,
|
||||
username,
|
||||
});
|
||||
|
||||
if (result.duplicate) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'User already exists',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Registration successful. You can now log in.',
|
||||
success: true,
|
||||
userId,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*/
|
||||
requestPasswordReset: localAuthProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Find user by email
|
||||
const user = await UserModel.findByEmail(ctx.serverDB, email);
|
||||
|
||||
// Always return success even if user doesn't exist (security best practice)
|
||||
if (!user) {
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Only send reset email if user has a password (registered with local auth)
|
||||
if (!user.passwordHash) {
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
const { token } = await ctx.localAuthModel.createPasswordResetToken(user.id);
|
||||
|
||||
// Send reset email
|
||||
try {
|
||||
await ctx.emailService.sendPasswordResetEmail(
|
||||
email,
|
||||
token,
|
||||
user.username || user.firstName || undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send password reset email');
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to send password reset email',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
*/
|
||||
resendVerificationEmail: localAuthProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Find user by email
|
||||
const user = await UserModel.findByEmail(ctx.serverDB, email);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists
|
||||
return {
|
||||
message:
|
||||
'If the email is registered and unverified, a new verification email has been sent.',
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerifiedAt) {
|
||||
return {
|
||||
message: 'This email is already verified',
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete old verification tokens for this user
|
||||
await ctx.localAuthModel.deleteUserEmailVerificationTokens(user.id);
|
||||
|
||||
// Create new verification token
|
||||
const { token } = await ctx.localAuthModel.createEmailVerificationToken(user.id, email);
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await ctx.emailService.sendVerificationEmail(
|
||||
email,
|
||||
token,
|
||||
user.username || user.firstName || undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send verification email');
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to send verification email',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Verification email sent successfully',
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*/
|
||||
resetPassword: localAuthProcedure
|
||||
.input(
|
||||
z.object({
|
||||
password: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { password, token } = input;
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: passwordValidation.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token and get user ID
|
||||
const userId = await ctx.localAuthModel.verifyPasswordResetToken(token);
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Update password
|
||||
await UserModel.updatePassword(ctx.serverDB, userId, passwordHash);
|
||||
|
||||
return {
|
||||
message: 'Password reset successfully. You can now log in with your new password.',
|
||||
success: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: error.message || 'Invalid or expired reset token',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Verify email with token
|
||||
*/
|
||||
verifyEmail: localAuthProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { email, userId } = await ctx.localAuthModel.verifyEmailToken(input.token);
|
||||
|
||||
// Update user email verification status
|
||||
await UserModel.verifyEmail(ctx.serverDB, userId, email);
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const user = await UserModel.findById(ctx.serverDB, userId);
|
||||
await ctx.emailService.sendWelcomeEmail(
|
||||
email,
|
||||
user?.username || user?.firstName || undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
pino.error({ error }, '[LocalAuth] Failed to send welcome email');
|
||||
// Don't fail verification if welcome email fails
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Email verified successfully',
|
||||
success: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: error.message || 'Invalid or expired verification token',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
cd /Users/brunogao/work/codes/AI/lobe-chat
|
||||
exec pnpm start
|
||||
@@ -0,0 +1,37 @@
|
||||
// Quick test script for Ollama model configuration
|
||||
const { Ollama } = require('ollama/browser');
|
||||
|
||||
try {
|
||||
const client = new Ollama({ host: 'http://127.0.0.1:11434' });
|
||||
|
||||
console.log('Testing Ollama connection...');
|
||||
const list = await client.list();
|
||||
console.log(
|
||||
'Available models:',
|
||||
list.models.map((m) => m.name),
|
||||
);
|
||||
|
||||
const modelName = 'lingjingwanxiang:32b';
|
||||
const foundModel = list.models.find((m) => m.name === modelName);
|
||||
|
||||
if (foundModel) {
|
||||
console.log(`\n✓ Model ${modelName} found in Ollama`);
|
||||
console.log('Model details:', foundModel);
|
||||
|
||||
console.log(`\nTesting chat with ${modelName}...`);
|
||||
const response = await client.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: modelName,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
console.log('✓ Chat test successful');
|
||||
console.log('Response:', response.message.content);
|
||||
} else {
|
||||
console.error(`✗ Model ${modelName} not found in Ollama`);
|
||||
console.log('Available models:', list.models.map((m) => m.name).join(', '));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
console.error('Full error:', error);
|
||||
}
|
||||
Reference in New Issue
Block a user