Compare commits
10 Commits
4b0d786dff
...
0a0bcfe8ba
Author | SHA1 | Date |
---|---|---|
![]() |
0a0bcfe8ba | |
![]() |
7c76dda7f3 | |
![]() |
774ae000d6 | |
![]() |
bf2f718aa2 | |
![]() |
6aa7af73f6 | |
![]() |
bece0afb3d | |
![]() |
6e8489d641 | |
![]() |
89a633152c | |
![]() |
047e1fb80a | |
![]() |
8938337944 |
|
@ -1,133 +0,0 @@
|
|||
# OIDC 架构更新总结
|
||||
|
||||
## 🎯 更新目标
|
||||
|
||||
将项目从混合认证架构改为标准的 OIDC 架构,确保所有用户认证都在 OIDC Provider 中处理。
|
||||
|
||||
## 🔄 主要改动
|
||||
|
||||
### 1. 删除客户端登录页面
|
||||
- ❌ 删除了 `apps/web/app/auth/login/page.tsx`
|
||||
- ❌ 删除了客户端应用中的自定义认证逻辑
|
||||
|
||||
### 2. 修复回调页面
|
||||
- ✅ 更新 `apps/web/app/auth/callback/page.tsx` 中的错误链接
|
||||
- ✅ 移除对已删除登录页面的引用
|
||||
|
||||
### 3. 添加测试页面
|
||||
- ✅ 创建 `apps/web/app/test-oidc/page.tsx` 用于测试OIDC流程
|
||||
- ✅ 在首页添加测试页面链接
|
||||
|
||||
### 4. 更新文档
|
||||
- ✅ 更新 `apps/backend/README.md` 以反映正确的架构
|
||||
|
||||
## 🏗️ 当前架构
|
||||
|
||||
### 正确的 OIDC 流程
|
||||
```
|
||||
用户点击登录
|
||||
↓
|
||||
客户端重定向到 OIDC Provider 授权端点
|
||||
↓
|
||||
OIDC Provider 显示内置登录页面
|
||||
↓
|
||||
用户在 Provider 页面上登录
|
||||
↓
|
||||
Provider 生成授权码并重定向回客户端
|
||||
↓
|
||||
客户端用授权码换取令牌
|
||||
↓
|
||||
认证完成
|
||||
```
|
||||
|
||||
### 架构优势
|
||||
|
||||
#### ✅ 已实现的正确做法
|
||||
- OIDC Provider 包含登录页面
|
||||
- 标准授权码流程
|
||||
- PKCE 支持
|
||||
- 内置会话管理
|
||||
- 自动令牌刷新
|
||||
|
||||
#### ❌ 已移除的错误做法
|
||||
- 客户端应用的登录页面
|
||||
- 自定义认证逻辑
|
||||
- 重复的用户管理
|
||||
- 混合认证流程
|
||||
|
||||
## 🧪 测试方法
|
||||
|
||||
### 1. 访问测试页面
|
||||
访问 `http://localhost:3001/test-oidc` 进行完整的流程测试
|
||||
|
||||
### 2. 测试 Discovery 端点
|
||||
在测试页面点击"测试 Discovery 端点"按钮
|
||||
|
||||
### 3. 完整认证流程测试
|
||||
1. 在测试页面点击"开始 OIDC 认证流程"
|
||||
2. 将跳转到 OIDC Provider 的内置登录页面
|
||||
3. 使用演示账号登录:`demouser` / `demo123`
|
||||
4. 登录成功后会重定向回客户端应用
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### OIDC Provider 配置
|
||||
```typescript
|
||||
export const oidcApp = createOIDCProvider({
|
||||
config: oidcConfig,
|
||||
useBuiltInAuth: true,
|
||||
builtInAuthConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
loginPageTitle: 'OIDC Demo 登录',
|
||||
brandName: 'OIDC Demo Provider',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 客户端配置
|
||||
```typescript
|
||||
export const oidcConfig = {
|
||||
authority: 'http://localhost:3000/oidc',
|
||||
client_id: 'demo-client',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
// ... 其他标准OIDC配置
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 验证清单
|
||||
|
||||
- [x] 删除客户端登录页面
|
||||
- [x] 修复回调页面引用
|
||||
- [x] OIDC Provider 内置认证正常工作
|
||||
- [x] 标准 OIDC 流程可以完整运行
|
||||
- [x] Discovery 端点返回正确配置
|
||||
- [x] 文档已更新
|
||||
- [x] 测试页面可用
|
||||
|
||||
## 🚀 启动说明
|
||||
|
||||
1. 启动后端 OIDC Provider:
|
||||
```bash
|
||||
cd apps/backend
|
||||
bun run dev
|
||||
```
|
||||
|
||||
2. 启动前端客户端:
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 访问测试页面:
|
||||
http://localhost:3001/test-oidc
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在项目已经完全符合标准的 OIDC 架构:
|
||||
- **分离关注点**: OIDC Provider 专注于认证,客户端专注于业务逻辑
|
||||
- **标准合规**: 完全符合 OpenID Connect 规范
|
||||
- **简化维护**: 认证逻辑集中在 Provider 中
|
||||
- **更好的安全性**: 用户凭据只在 Provider 中处理
|
|
@ -10,7 +10,6 @@
|
|||
"@hono/zod-validator": "^0.5.0",
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/oidc-provider": "workspace:*",
|
||||
"@repo/tus": "workspace:*",
|
||||
"@repo/storage": "workspace:*",
|
||||
"@trpc/server": "11.1.2",
|
||||
"dayjs": "^1.11.12",
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './storage-adapter';
|
|
@ -0,0 +1,106 @@
|
|||
import { prisma } from '@repo/db';
|
||||
import type { Resource } from '@repo/db';
|
||||
import type { DatabaseAdapter, StorageType, ResourceData, CreateResourceData } from '@repo/storage';
|
||||
|
||||
// 将 Prisma Resource 转换为 ResourceData 的辅助函数
|
||||
function transformResource(resource: Resource): ResourceData {
|
||||
return {
|
||||
id: resource.id,
|
||||
fileId: resource.fileId,
|
||||
title: resource.title,
|
||||
type: resource.type,
|
||||
storageType: resource.storageType as StorageType,
|
||||
status: resource.status || 'unknown',
|
||||
meta: resource.meta,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export class PrismaDatabaseAdapter implements DatabaseAdapter {
|
||||
async getResourceByFileId(fileId: string): Promise<{ status: string; resource?: ResourceData }> {
|
||||
const resource = await prisma.resource.findFirst({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
|
||||
return {
|
||||
status: resource.status || 'unknown',
|
||||
resource: transformResource(resource),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteResource(id: string): Promise<ResourceData> {
|
||||
const resource = await prisma.resource.delete({
|
||||
where: { id },
|
||||
});
|
||||
return transformResource(resource);
|
||||
}
|
||||
|
||||
async deleteFailedUploadingResource(expirationPeriod: number): Promise<{ count: number }> {
|
||||
const deletedResources = await prisma.resource.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: new Date(Date.now() - expirationPeriod),
|
||||
},
|
||||
status: 'UPLOADING',
|
||||
},
|
||||
});
|
||||
return deletedResources;
|
||||
}
|
||||
|
||||
async updateResource(id: string, data: any): Promise<ResourceData> {
|
||||
const resource = await prisma.resource.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
return transformResource(resource);
|
||||
}
|
||||
|
||||
async migrateResourcesStorageType(
|
||||
fromStorageType: StorageType,
|
||||
toStorageType: StorageType,
|
||||
): Promise<{ count: number }> {
|
||||
const result = await prisma.resource.updateMany({
|
||||
where: {
|
||||
storageType: fromStorageType,
|
||||
},
|
||||
data: {
|
||||
storageType: toStorageType,
|
||||
},
|
||||
});
|
||||
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
async createResource(data: CreateResourceData): Promise<ResourceData> {
|
||||
const resource = await prisma.resource.create({
|
||||
data: {
|
||||
fileId: data.fileId,
|
||||
title: data.filename,
|
||||
type: data.mimeType,
|
||||
storageType: data.storageType,
|
||||
status: data.status || 'UPLOADING',
|
||||
meta: {
|
||||
size: data.size,
|
||||
hash: data.hash,
|
||||
},
|
||||
},
|
||||
});
|
||||
return transformResource(resource);
|
||||
}
|
||||
|
||||
async updateResourceStatus(fileId: string, status: string, additionalData?: any): Promise<ResourceData> {
|
||||
const resource = await prisma.resource.update({
|
||||
where: { fileId },
|
||||
data: {
|
||||
status,
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
return transformResource(resource);
|
||||
}
|
||||
}
|
|
@ -11,14 +11,17 @@ import minioClient from './minio';
|
|||
import { Client } from 'minio';
|
||||
|
||||
import { appRouter } from './trpc';
|
||||
|
||||
import { createBunWebSocket } from 'hono/bun';
|
||||
import { wsHandler, wsConfig } from './socket';
|
||||
|
||||
// 导入新的路由
|
||||
import userRest from './user/user.rest';
|
||||
// 使用新的 @repo/storage 包
|
||||
import { createStorageApp, startCleanupScheduler } from '@repo/storage';
|
||||
import { createStorageApp, startCleanupScheduler, adapterRegistry } from '@repo/storage';
|
||||
// 导入 Prisma 适配器实现
|
||||
import { PrismaDatabaseAdapter } from './adapters/storage-adapter';
|
||||
|
||||
// 注册数据库适配器
|
||||
adapterRegistry.setDatabaseAdapter(new PrismaDatabaseAdapter());
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
|
|
|
@ -1,134 +0,0 @@
|
|||
import { createOIDCProvider } from '@repo/oidc-provider';
|
||||
import { RedisStorageAdapter } from '@repo/oidc-provider';
|
||||
import type { OIDCClient, OIDCUser, OIDCProviderConfig } from '@repo/oidc-provider';
|
||||
import redis from './redis';
|
||||
|
||||
// 示例客户端数据
|
||||
const demoClients: OIDCClient[] = [
|
||||
{
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: [
|
||||
'http://localhost:3001/auth/callback',
|
||||
'http://localhost:8080/callback',
|
||||
'https://oauth.pstmn.io/v1/callback'
|
||||
],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
client_id: 'demo-public-client',
|
||||
client_name: 'Demo Public Application',
|
||||
client_type: 'public',
|
||||
redirect_uris: [
|
||||
'http://localhost:3000/callback',
|
||||
'myapp://callback'
|
||||
],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
];
|
||||
|
||||
// 示例用户数据
|
||||
const demoUsers: OIDCUser[] = [
|
||||
{
|
||||
sub: 'demo-user',
|
||||
username: 'demouser',
|
||||
email: 'demo@example.com',
|
||||
email_verified: true,
|
||||
name: 'Demo User',
|
||||
given_name: 'Demo',
|
||||
family_name: 'User',
|
||||
picture: 'https://via.placeholder.com/150',
|
||||
profile: 'https://example.com/demouser',
|
||||
website: 'https://example.com',
|
||||
gender: 'prefer_not_to_say',
|
||||
birthdate: '1990-01-01',
|
||||
zoneinfo: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
phone_number: '+86-123-4567-8901',
|
||||
phone_number_verified: true,
|
||||
address: {
|
||||
formatted: '北京市朝阳区建国门外大街1号',
|
||||
street_address: '建国门外大街1号',
|
||||
locality: '朝阳区',
|
||||
region: '北京市',
|
||||
postal_code: '100020',
|
||||
country: 'CN'
|
||||
},
|
||||
updated_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
];
|
||||
|
||||
// 查找客户端的函数
|
||||
async function findClient(clientId: string): Promise<OIDCClient | null> {
|
||||
return demoClients.find(client => client.client_id === clientId) || null;
|
||||
}
|
||||
|
||||
// 查找用户的函数
|
||||
async function findUser(userId: string): Promise<OIDCUser | null> {
|
||||
return demoUsers.find(user => user.sub === userId) || null;
|
||||
}
|
||||
|
||||
// 密码验证函数
|
||||
async function validatePassword(username: string, password: string): Promise<string | null> {
|
||||
// 查找用户并验证密码
|
||||
const user = demoUsers.find(u => u.username === username);
|
||||
if (!user || password !== 'demo123') {
|
||||
return null;
|
||||
}
|
||||
return user.sub; // 返回用户ID
|
||||
}
|
||||
|
||||
// OIDC Provider 配置
|
||||
const oidcConfig: OIDCProviderConfig = {
|
||||
issuer: 'http://localhost:3000/oidc',
|
||||
signingKey: 'your-super-secret-signing-key-at-least-32-characters-long',
|
||||
signingAlgorithm: 'HS256',
|
||||
storage: new RedisStorageAdapter(redis),
|
||||
findClient,
|
||||
findUser,
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
scopes: ['openid', 'profile', 'email', 'phone', 'address'],
|
||||
claims: [
|
||||
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
||||
],
|
||||
enablePKCE: true,
|
||||
requirePKCE: false,
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 使用新的内置认证处理器创建OIDC Provider
|
||||
export const oidcApp = createOIDCProvider({
|
||||
config: oidcConfig,
|
||||
useBuiltInAuth: true,
|
||||
builtInAuthConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
loginPageTitle: 'OIDC Demo 登录',
|
||||
brandName: 'OIDC Demo Provider',
|
||||
},
|
||||
});
|
||||
|
||||
// 导出示例数据用于测试
|
||||
export { demoClients, demoUsers, oidcConfig };
|
|
@ -0,0 +1,11 @@
|
|||
import { users } from './users';
|
||||
|
||||
// 密码验证函数
|
||||
export async function validatePassword(username: string, password: string): Promise<string | null> {
|
||||
// 查找用户并验证密码
|
||||
const user = users.demoUsers.find(u => u.username === username);
|
||||
if (!user || password !== 'demo123') {
|
||||
return null;
|
||||
}
|
||||
return user.sub; // 返回用户ID
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import type { OIDCClient } from '@repo/oidc-provider';
|
||||
|
||||
// 示例客户端数据
|
||||
const demoClients: OIDCClient[] = [
|
||||
{
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: [
|
||||
'http://localhost:3001/auth/callback',
|
||||
'http://localhost:8080/callback',
|
||||
'https://oauth.pstmn.io/v1/callback'
|
||||
],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
client_id: 'demo-public-client',
|
||||
client_name: 'Demo Public Application',
|
||||
client_type: 'public',
|
||||
redirect_uris: [
|
||||
'http://localhost:3000/callback',
|
||||
'myapp://callback'
|
||||
],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
];
|
||||
|
||||
// 查找客户端的函数
|
||||
async function findClient(clientId: string): Promise<OIDCClient | null> {
|
||||
return demoClients.find(client => client.client_id === clientId) || null;
|
||||
}
|
||||
|
||||
export const clients = {
|
||||
findClient,
|
||||
demoClients,
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { createOIDCProvider } from '@repo/oidc-provider';
|
||||
import { RedisStorageAdapter } from '@repo/oidc-provider';
|
||||
import type { OIDCProviderConfig } from '@repo/oidc-provider';
|
||||
import redis from '../redis';
|
||||
import { clients } from './clients';
|
||||
import { users } from './users';
|
||||
import { validatePassword } from './auth';
|
||||
|
||||
// OIDC Provider 配置
|
||||
const oidcConfig: OIDCProviderConfig = {
|
||||
issuer: 'http://localhost:3000/oidc',
|
||||
storage: new RedisStorageAdapter(redis),
|
||||
findClient: clients.findClient,
|
||||
findUser: users.findUser,
|
||||
authConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
pageConfig: {
|
||||
title: 'OIDC Provider 登录',
|
||||
brandName: 'Nice OIDC Provider',
|
||||
},
|
||||
},
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
scopes: ['openid', 'profile', 'email', 'phone', 'address'],
|
||||
claims: [
|
||||
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
||||
],
|
||||
enablePKCE: true,
|
||||
requirePKCE: false,
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 创建OIDC Provider应用
|
||||
export const oidcApp = createOIDCProvider(oidcConfig);
|
|
@ -0,0 +1,42 @@
|
|||
import type { OIDCUser } from '@repo/oidc-provider';
|
||||
|
||||
// 示例用户数据
|
||||
const demoUsers: OIDCUser[] = [
|
||||
{
|
||||
sub: 'demo-user',
|
||||
username: 'demouser',
|
||||
email: 'demo@example.com',
|
||||
email_verified: true,
|
||||
name: 'Demo User',
|
||||
given_name: 'Demo',
|
||||
family_name: 'User',
|
||||
picture: 'https://via.placeholder.com/150',
|
||||
profile: 'https://example.com/demouser',
|
||||
website: 'https://example.com',
|
||||
gender: 'prefer_not_to_say',
|
||||
birthdate: '1990-01-01',
|
||||
zoneinfo: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
phone_number: '+86-123-4567-8901',
|
||||
phone_number_verified: true,
|
||||
address: {
|
||||
formatted: '北京市朝阳区建国门外大街1号',
|
||||
street_address: '建国门外大街1号',
|
||||
locality: '朝阳区',
|
||||
region: '北京市',
|
||||
postal_code: '100020',
|
||||
country: 'CN'
|
||||
},
|
||||
updated_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
];
|
||||
|
||||
// 查找用户的函数
|
||||
async function findUser(userId: string): Promise<OIDCUser | null> {
|
||||
return demoUsers.find(user => user.sub === userId) || null;
|
||||
}
|
||||
|
||||
export const users = {
|
||||
findUser,
|
||||
demoUsers,
|
||||
};
|
|
@ -1,120 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { userManager } from '@/lib/oidc-config';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Alert, AlertDescription } from '@repo/ui/components/alert';
|
||||
|
||||
export default function CallbackPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
if (!userManager) {
|
||||
throw new Error('用户管理器未初始化');
|
||||
}
|
||||
|
||||
// 处理OIDC回调
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
|
||||
if (user) {
|
||||
setStatus('success');
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error('未收到用户信息');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('回调处理失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否有授权码或错误参数
|
||||
const code = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
if (error) {
|
||||
setError(`${error}: ${errorDescription || '授权失败'}`);
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
handleCallback();
|
||||
} else {
|
||||
setError('缺少授权码');
|
||||
setStatus('error');
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-8 w-8 animate-spin text-blue-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-8 w-8 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-8 w-8 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return '正在处理登录回调...';
|
||||
case 'success':
|
||||
return '登录成功!正在跳转...';
|
||||
case 'error':
|
||||
return '登录失败';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
||||
<CardTitle className="text-xl">{getStatusMessage()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'error' && error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>请等待,正在验证您的登录信息...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>登录成功!即将跳转到首页...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-center mt-4">
|
||||
<button onClick={() => router.push('/')} className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,172 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { UserProfile } from '@/components/user-profile';
|
||||
import { LoginButton } from '@/components/login-button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Separator } from '@repo/ui/components/separator';
|
||||
import { Shield, Key, Users, CheckCircle, Info } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const { isAuthenticated, isLoading, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">OIDC 认证演示</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
基于 OpenID Connect 协议的安全认证系统演示
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href="/test-oidc"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border border-blue-300 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:border-blue-600 dark:hover:bg-blue-900"
|
||||
>
|
||||
测试 OIDC 流程
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能特性卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Shield className="h-8 w-8 text-blue-600 mb-2" />
|
||||
<CardTitle className="text-lg">安全认证</CardTitle>
|
||||
<CardDescription>基于 OAuth 2.0 和 OpenID Connect 标准的安全认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Key className="h-8 w-8 text-green-600 mb-2" />
|
||||
<CardTitle className="text-lg">Token 管理</CardTitle>
|
||||
<CardDescription>自动管理访问令牌、刷新令牌和 ID 令牌的生命周期</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Users className="h-8 w-8 text-purple-600 mb-2" />
|
||||
<CardTitle className="text-lg">用户信息</CardTitle>
|
||||
<CardDescription>获取和展示完整的用户配置文件信息</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||
<Info className="h-5 w-5" />
|
||||
<span className="font-medium">错误:</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
{isLoading ? (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3 mx-auto"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isAuthenticated ? (
|
||||
<UserProfile />
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">欢迎使用</CardTitle>
|
||||
<CardDescription>请点击下方按钮开始 OIDC 认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
<LoginButton size="lg" className="w-full" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<p className="font-medium">演示账户信息:</p>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-md">
|
||||
<p>
|
||||
用户名: <code className="text-sm">demouser</code>
|
||||
</p>
|
||||
<p>
|
||||
密码: <code className="text-sm">demo123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 技术信息 */}
|
||||
<Card className="w-full max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
技术实现特性
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">前端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Next.js 15</Badge>
|
||||
<Badge variant="outline">React 19</Badge>
|
||||
<Badge variant="outline">TypeScript</Badge>
|
||||
<Badge variant="outline">oidc-client-ts</Badge>
|
||||
<Badge variant="outline">Tailwind CSS</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">后端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Hono</Badge>
|
||||
<Badge variant="outline">OIDC Provider</Badge>
|
||||
<Badge variant="outline">Redis</Badge>
|
||||
<Badge variant="outline">JWT</Badge>
|
||||
<Badge variant="outline">PKCE</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">安全特性</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">授权码流程</Badge>
|
||||
<Badge variant="secondary">PKCE 支持</Badge>
|
||||
<Badge variant="secondary">Token 轮换</Badge>
|
||||
<Badge variant="secondary">安全存储</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">支持的作用域</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">openid</Badge>
|
||||
<Badge variant="default">profile</Badge>
|
||||
<Badge variant="default">email</Badge>
|
||||
<Badge variant="default">phone</Badge>
|
||||
<Badge variant="default">address</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Alert, AlertDescription } from '@repo/ui/components/alert';
|
||||
import { CheckCircle, XCircle, Loader2, ArrowRight, Key, User, Shield } from 'lucide-react';
|
||||
|
||||
export default function TestOidcPage() {
|
||||
const [testResults, setTestResults] = useState<{
|
||||
discovery: 'idle' | 'loading' | 'success' | 'error';
|
||||
discoveryData?: any;
|
||||
discoveryError?: string;
|
||||
}>({
|
||||
discovery: 'idle',
|
||||
});
|
||||
|
||||
const testDiscoveryEndpoint = async () => {
|
||||
setTestResults((prev) => ({ ...prev, discovery: 'loading' }));
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/oidc/.well-known/openid_configuration');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
discovery: 'success',
|
||||
discoveryData: data,
|
||||
}));
|
||||
} catch (error) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
discovery: 'error',
|
||||
discoveryError: error instanceof Error ? error.message : '未知错误',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const startOidcFlow = () => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: 'demo-client',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
scope: 'openid profile email',
|
||||
state: `test-${Date.now()}`,
|
||||
});
|
||||
|
||||
window.location.href = `http://localhost:3000/oidc/auth?${params.toString()}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <div className="h-5 w-5 rounded-full border-2 border-gray-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">OIDC 流程测试</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300">测试和验证 OpenID Connect 认证流程的各个环节</p>
|
||||
</div>
|
||||
|
||||
{/* OIDC 流程步骤 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
标准 OIDC 认证流程
|
||||
</CardTitle>
|
||||
<CardDescription>按照正确的 OIDC 架构,所有登录都在 OIDC Provider 中处理</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 流程步骤图示 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-center">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">用户点击登录</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">重定向到 Provider</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
||||
<Key className="h-6 w-6 text-purple-600 dark:text-purple-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">返回授权码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试按钮 */}
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={startOidcFlow} size="lg" className="px-8">
|
||||
开始 OIDC 认证流程
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
点击上方按钮将重定向到 OIDC Provider 的登录页面。
|
||||
<br />
|
||||
<strong>演示账号:</strong> demouser / demo123
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discovery 端点测试 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{getStatusIcon(testResults.discovery)}
|
||||
Discovery 端点测试
|
||||
</CardTitle>
|
||||
<CardDescription>测试 OIDC Provider 的配置发现端点</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={testDiscoveryEndpoint} disabled={testResults.discovery === 'loading'}>
|
||||
{testResults.discovery === 'loading' ? '测试中...' : '测试 Discovery 端点'}
|
||||
</Button>
|
||||
|
||||
{testResults.discovery === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>错误:</strong> {testResults.discoveryError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testResults.discovery === 'success' && testResults.discoveryData && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>成功!</strong> OIDC Provider 配置已获取
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-3">Provider 信息</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Issuer:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.issuer}
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>授权端点:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.authorization_endpoint}
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>令牌端点:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.token_endpoint}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">支持的功能</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testResults.discoveryData.response_types_supported?.map((type: string) => (
|
||||
<Badge key={type} variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{testResults.discoveryData.scopes_supported?.map((scope: string) => (
|
||||
<Badge key={scope} variant="secondary">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 架构说明 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>正确的 OIDC 架构</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-600 mb-2">✅ 已实现</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• OIDC Provider 包含登录页面</li>
|
||||
<li>• 标准授权码流程</li>
|
||||
<li>• PKCE 支持</li>
|
||||
<li>• 内置会话管理</li>
|
||||
<li>• 自动令牌刷新</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-600 mb-2">❌ 已移除</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 客户端应用的登录页面</li>
|
||||
<li>• 自定义认证逻辑</li>
|
||||
<li>• 重复的用户管理</li>
|
||||
<li>• 混合认证流程</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
'use client';
|
||||
import { useHello, useTRPC, useWebSocket, MessageType } from '@repo/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
|
||||
export default function WebSocketPage() {
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { LogIn, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginButtonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function LoginButton({ className, variant = 'default', size = 'default' }: LoginButtonProps) {
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<Button onClick={login} disabled={isLoading} variant={variant} size={size} className={className}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -3,8 +3,6 @@
|
|||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import QueryProvider from '@/providers/query-provider';
|
||||
import { AuthProvider } from '@/providers/auth-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
@ -15,7 +13,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||
enableColorScheme
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
|
|
|
@ -1,251 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Separator } from '@repo/ui/components/separator';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/avatar';
|
||||
import { LogOut, User, Mail, Phone, MapPin, Calendar, Globe } from 'lucide-react';
|
||||
|
||||
export function UserProfile() {
|
||||
const { user, isAuthenticated, logout, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>未登录</CardTitle>
|
||||
<CardDescription>请先登录以查看用户信息</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const profile = user.profile;
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '未知';
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={profile.picture} alt={profile.name} />
|
||||
<AvatarFallback>{profile.name?.charAt(0) || profile.preferred_username?.charAt(0) || 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{profile.name || profile.preferred_username || '未知用户'}</CardTitle>
|
||||
<CardDescription>用户ID: {profile.sub}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={logout} className="gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
登出
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{profile.given_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">名</label>
|
||||
<p className="text-sm">{profile.given_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.family_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">姓</label>
|
||||
<p className="text-sm">{profile.family_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.nickname && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">昵称</label>
|
||||
<p className="text-sm">{profile.nickname}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.gender && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">性别</label>
|
||||
<p className="text-sm">{profile.gender}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 联系信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
联系信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{profile.email && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮箱</label>
|
||||
<p className="text-sm">{profile.email}</p>
|
||||
</div>
|
||||
<Badge variant={profile.email_verified ? 'default' : 'secondary'}>
|
||||
{profile.email_verified ? '已验证' : '未验证'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{profile.phone_number && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">电话</label>
|
||||
<p className="text-sm">{profile.phone_number}</p>
|
||||
</div>
|
||||
<Badge variant={profile.phone_number_verified ? 'default' : 'secondary'}>
|
||||
{profile.phone_number_verified ? '已验证' : '未验证'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.address && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
地址信息
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{profile.address.formatted && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">完整地址</label>
|
||||
<p className="text-sm">{profile.address.formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{profile.address.country && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">国家</label>
|
||||
<p className="text-sm">{profile.address.country}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.region && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">省/州</label>
|
||||
<p className="text-sm">{profile.address.region}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.locality && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">城市</label>
|
||||
<p className="text-sm">{profile.address.locality}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.postal_code && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮编</label>
|
||||
<p className="text-sm">{profile.address.postal_code}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
其他信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{profile.birthdate && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">生日</label>
|
||||
<p className="text-sm">{profile.birthdate}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.zoneinfo && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">时区</label>
|
||||
<p className="text-sm">{profile.zoneinfo}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.locale && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">语言</label>
|
||||
<p className="text-sm">{profile.locale}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.updated_at && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">更新时间</label>
|
||||
<p className="text-sm">{formatDate(profile.updated_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Token 信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Token 信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{user.expires_at && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">访问令牌过期时间</label>
|
||||
<p className="text-sm">{new Date(user.expires_at * 1000).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Token类型</label>
|
||||
<p className="text-sm">{user.token_type}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">作用域</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{user.scope?.split(' ').map((scope) => (
|
||||
<Badge key={scope} variant="outline" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
||||
|
||||
// 创建存储配置的函数,避免 SSR 问题
|
||||
const createUserStore = () => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return new WebStorageStateStore({ store: window.localStorage });
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// OIDC 客户端配置
|
||||
export const oidcConfig = {
|
||||
authority: 'http://localhost:3000/oidc', // 后端OIDC provider地址
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
post_logout_redirect_uri: 'http://localhost:3001',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
automaticSilentRenew: true,
|
||||
includeIdTokenInSilentRenew: true,
|
||||
revokeTokensOnSignout: true,
|
||||
...(typeof window !== 'undefined' && { userStore: createUserStore() }),
|
||||
};
|
||||
|
||||
// 创建用户管理器实例
|
||||
export const userManager = typeof window !== 'undefined' ? new UserManager(oidcConfig) : null;
|
||||
|
||||
// OIDC 相关的URL
|
||||
export const oidcUrls = {
|
||||
login: `${oidcConfig.authority}/auth`,
|
||||
logout: `${oidcConfig.authority}/logout`,
|
||||
token: `${oidcConfig.authority}/token`,
|
||||
userinfo: `${oidcConfig.authority}/userinfo`,
|
||||
};
|
|
@ -1,130 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { User } from 'oidc-client-ts';
|
||||
import { userManager } from '@/lib/oidc-config';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth必须在AuthProvider内部使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isAuthenticated = !!user && !user.expired;
|
||||
|
||||
useEffect(() => {
|
||||
if (!userManager) return;
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const currentUser = await userManager.getUser();
|
||||
setUser(currentUser);
|
||||
} catch (err) {
|
||||
console.error('初始化认证失败:', err);
|
||||
setError('认证初始化失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
||||
// 监听用户状态变化
|
||||
const handleUserLoaded = (user: User) => {
|
||||
setUser(user);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleUserUnloaded = () => {
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const handleAccessTokenExpired = () => {
|
||||
setUser(null);
|
||||
setError('访问令牌已过期');
|
||||
};
|
||||
|
||||
const handleSilentRenewError = (error: Error) => {
|
||||
console.error('静默续约失败:', error);
|
||||
setError('令牌续约失败');
|
||||
};
|
||||
|
||||
userManager.events.addUserLoaded(handleUserLoaded);
|
||||
userManager.events.addUserUnloaded(handleUserUnloaded);
|
||||
userManager.events.addAccessTokenExpired(handleAccessTokenExpired);
|
||||
userManager.events.addSilentRenewError(handleSilentRenewError);
|
||||
|
||||
return () => {
|
||||
if (userManager) {
|
||||
userManager.events.removeUserLoaded(handleUserLoaded);
|
||||
userManager.events.removeUserUnloaded(handleUserUnloaded);
|
||||
userManager.events.removeAccessTokenExpired(handleAccessTokenExpired);
|
||||
userManager.events.removeSilentRenewError(handleSilentRenewError);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async () => {
|
||||
if (!userManager) {
|
||||
setError('用户管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await userManager.signinRedirect();
|
||||
} catch (err) {
|
||||
console.error('登录失败:', err);
|
||||
setError('登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (!userManager) {
|
||||
setError('用户管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await userManager.signoutRedirect();
|
||||
} catch (err) {
|
||||
console.error('登出失败:', err);
|
||||
setError('登出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
121
debug-minio.js
121
debug-minio.js
|
@ -1,121 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MinIO连接调试脚本
|
||||
*/
|
||||
|
||||
const { S3 } = require('@aws-sdk/client-s3');
|
||||
|
||||
async function debugMinIO() {
|
||||
console.log('🔍 MinIO连接调试开始...\n');
|
||||
|
||||
const config = {
|
||||
endpoint: 'http://localhost:9000',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: '7Nt7OyHkwIoo3zvSKdnc',
|
||||
secretAccessKey: 'EZ0cyrjJAsabTLNSqWcU47LURMppBW2kka3LuXzb',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
console.log('配置信息:');
|
||||
console.log('- Endpoint:', config.endpoint);
|
||||
console.log('- Region:', config.region);
|
||||
console.log('- Access Key:', config.credentials.accessKeyId);
|
||||
console.log('- Force Path Style:', config.forcePathStyle);
|
||||
console.log();
|
||||
|
||||
const s3Client = new S3(config);
|
||||
|
||||
try {
|
||||
// 1. 测试基本连接
|
||||
console.log('📡 测试基本连接...');
|
||||
const buckets = await s3Client.listBuckets();
|
||||
console.log('✅ 连接成功!');
|
||||
console.log('📂 现有存储桶:', buckets.Buckets?.map((b) => b.Name) || []);
|
||||
console.log();
|
||||
|
||||
// 2. 检查test123存储桶
|
||||
const bucketName = 'test123';
|
||||
console.log(`🪣 检查存储桶 "${bucketName}"...`);
|
||||
|
||||
try {
|
||||
await s3Client.headBucket({ Bucket: bucketName });
|
||||
console.log(`✅ 存储桶 "${bucketName}" 存在`);
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFound') {
|
||||
console.log(`❌ 存储桶 "${bucketName}" 不存在,正在创建...`);
|
||||
try {
|
||||
await s3Client.createBucket({ Bucket: bucketName });
|
||||
console.log(`✅ 存储桶 "${bucketName}" 创建成功`);
|
||||
} catch (createError) {
|
||||
console.log(`❌ 创建存储桶失败:`, createError.message);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ 检查存储桶失败:`, error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 测试简单上传
|
||||
console.log('\n📤 测试简单上传...');
|
||||
const testKey = 'test-file.txt';
|
||||
const testContent = 'Hello MinIO!';
|
||||
|
||||
try {
|
||||
await s3Client.putObject({
|
||||
Bucket: bucketName,
|
||||
Key: testKey,
|
||||
Body: testContent,
|
||||
});
|
||||
console.log(`✅ 简单上传成功: ${testKey}`);
|
||||
} catch (error) {
|
||||
console.log(`❌ 简单上传失败:`, error.message);
|
||||
console.log('错误详情:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 测试分片上传初始化
|
||||
console.log('\n🔄 测试分片上传初始化...');
|
||||
const multipartKey = 'test-multipart.txt';
|
||||
|
||||
try {
|
||||
const multipartUpload = await s3Client.createMultipartUpload({
|
||||
Bucket: bucketName,
|
||||
Key: multipartKey,
|
||||
});
|
||||
console.log(`✅ 分片上传初始化成功: ${multipartUpload.UploadId}`);
|
||||
|
||||
// 立即取消这个分片上传
|
||||
await s3Client.abortMultipartUpload({
|
||||
Bucket: bucketName,
|
||||
Key: multipartKey,
|
||||
UploadId: multipartUpload.UploadId,
|
||||
});
|
||||
console.log('✅ 分片上传取消成功');
|
||||
} catch (error) {
|
||||
console.log(`❌ 分片上传初始化失败:`, error.message);
|
||||
console.log('错误详情:', error);
|
||||
if (error.$metadata) {
|
||||
console.log('HTTP状态码:', error.$metadata.httpStatusCode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🎉 所有测试通过!MinIO配置正确。');
|
||||
} catch (error) {
|
||||
console.log('❌ 连接失败:', error.message);
|
||||
console.log('错误详情:', error);
|
||||
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
console.log('\n💡 提示:');
|
||||
console.log('- 确保MinIO正在端口9000运行');
|
||||
console.log('- 检查docker容器状态: docker ps');
|
||||
console.log('- 重启MinIO: docker restart minio-container-name');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugMinIO().catch(console.error);
|
169
debug-s3.js
169
debug-s3.js
|
@ -1,169 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* S3存储调试脚本
|
||||
* 用于快速诊断S3存储连接问题
|
||||
*/
|
||||
|
||||
// 检查是否有.env文件,如果有就加载
|
||||
try {
|
||||
require('dotenv').config();
|
||||
} catch (e) {
|
||||
console.log('No dotenv found, using environment variables directly');
|
||||
}
|
||||
|
||||
async function debugS3() {
|
||||
console.log('🔍 S3存储调试开始...\n');
|
||||
|
||||
// 1. 检查环境变量
|
||||
console.log('📋 环境变量检查:');
|
||||
const requiredVars = {
|
||||
STORAGE_TYPE: process.env.STORAGE_TYPE,
|
||||
S3_BUCKET: process.env.S3_BUCKET,
|
||||
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
|
||||
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
S3_ENDPOINT: process.env.S3_ENDPOINT,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(requiredVars)) {
|
||||
if (key.includes('SECRET')) {
|
||||
console.log(` ${key}: ${value ? '✅ 已设置' : '❌ 未设置'}`);
|
||||
} else {
|
||||
console.log(` ${key}: ${value || '❌ 未设置'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.STORAGE_TYPE !== 's3') {
|
||||
console.log('\n❌ STORAGE_TYPE 不是 s3,无法测试S3连接');
|
||||
return;
|
||||
}
|
||||
|
||||
const missingVars = ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'].filter((key) => !process.env[key]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.log(`\n❌ 缺少必要的环境变量: ${missingVars.join(', ')}`);
|
||||
console.log('请设置这些环境变量后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n✅ 环境变量检查通过\n');
|
||||
|
||||
// 2. 测试AWS SDK加载
|
||||
console.log('📦 加载AWS SDK...');
|
||||
try {
|
||||
const { S3 } = require('@aws-sdk/client-s3');
|
||||
console.log('✅ AWS SDK加载成功\n');
|
||||
|
||||
// 3. 创建S3客户端
|
||||
console.log('🔧 创建S3客户端...');
|
||||
const config = {
|
||||
region: process.env.S3_REGION || 'auto',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.S3_ENDPOINT) {
|
||||
config.endpoint = process.env.S3_ENDPOINT;
|
||||
}
|
||||
|
||||
if (process.env.S3_FORCE_PATH_STYLE === 'true') {
|
||||
config.forcePathStyle = true;
|
||||
}
|
||||
|
||||
console.log('S3客户端配置:', {
|
||||
region: config.region,
|
||||
endpoint: config.endpoint || '默认AWS端点',
|
||||
forcePathStyle: config.forcePathStyle || false,
|
||||
});
|
||||
|
||||
const s3Client = new S3(config);
|
||||
console.log('✅ S3客户端创建成功\n');
|
||||
|
||||
// 4. 测试bucket访问
|
||||
console.log('🪣 测试bucket访问...');
|
||||
try {
|
||||
await s3Client.headBucket({ Bucket: process.env.S3_BUCKET });
|
||||
console.log('✅ Bucket访问成功');
|
||||
} catch (error) {
|
||||
console.log(`❌ Bucket访问失败: ${error.message}`);
|
||||
console.log('错误详情:', error);
|
||||
|
||||
if (error.name === 'NotFound') {
|
||||
console.log(' 💡 提示: Bucket不存在,请检查bucket名称');
|
||||
} else if (error.name === 'Forbidden') {
|
||||
console.log(' 💡 提示: 访问被拒绝,请检查访问密钥权限');
|
||||
} else if (error.message.includes('getaddrinfo ENOTFOUND')) {
|
||||
console.log(' 💡 提示: DNS解析失败,请检查endpoint设置');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 测试列出对象
|
||||
console.log('\n📂 测试列出对象...');
|
||||
try {
|
||||
const result = await s3Client.listObjectsV2({
|
||||
Bucket: process.env.S3_BUCKET,
|
||||
MaxKeys: 5,
|
||||
});
|
||||
console.log(`✅ 列出对象成功,共有 ${result.KeyCount || 0} 个对象`);
|
||||
|
||||
if (result.Contents && result.Contents.length > 0) {
|
||||
console.log(' 前几个对象:');
|
||||
result.Contents.slice(0, 3).forEach((obj, index) => {
|
||||
console.log(` ${index + 1}. ${obj.Key} (${obj.Size} bytes)`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ 列出对象失败: ${error.message}`);
|
||||
console.log('错误详情:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 测试创建multipart upload
|
||||
console.log('\n🚀 测试创建multipart upload...');
|
||||
const testKey = `test-multipart-${Date.now()}`;
|
||||
let uploadId;
|
||||
|
||||
try {
|
||||
const createResult = await s3Client.createMultipartUpload({
|
||||
Bucket: process.env.S3_BUCKET,
|
||||
Key: testKey,
|
||||
Metadata: { test: 'debug-script' },
|
||||
});
|
||||
uploadId = createResult.UploadId;
|
||||
console.log(`✅ Multipart upload创建成功,UploadId: ${uploadId}`);
|
||||
|
||||
// 清理测试upload
|
||||
await s3Client.abortMultipartUpload({
|
||||
Bucket: process.env.S3_BUCKET,
|
||||
Key: testKey,
|
||||
UploadId: uploadId,
|
||||
});
|
||||
console.log('✅ 测试upload已清理');
|
||||
} catch (error) {
|
||||
console.log(`❌ Multipart upload创建失败: ${error.message}`);
|
||||
console.log('错误详情:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🎉 S3连接测试全部通过!S3存储应该可以正常工作。');
|
||||
console.log('\n💡 如果上传仍然失败,请检查:');
|
||||
console.log('1. 网络连接是否稳定');
|
||||
console.log('2. 防火墙是否阻止了连接');
|
||||
console.log('3. S3服务是否有临时问题');
|
||||
console.log('4. 查看应用日志中的详细错误信息');
|
||||
} catch (error) {
|
||||
console.log(`❌ AWS SDK加载失败: ${error.message}`);
|
||||
console.log('请确保已安装 @aws-sdk/client-s3 包:');
|
||||
console.log('npm install @aws-sdk/client-s3');
|
||||
}
|
||||
}
|
||||
|
||||
// 运行调试
|
||||
debugS3().catch((error) => {
|
||||
console.error('调试脚本出错:', error);
|
||||
process.exit(1);
|
||||
});
|
|
@ -1,235 +0,0 @@
|
|||
# 环境变量配置指南
|
||||
|
||||
本文档详细说明了项目中所有环境变量的配置方法和用途。
|
||||
|
||||
## 存储配置 (@repo/storage)
|
||||
|
||||
### 基础配置
|
||||
|
||||
```bash
|
||||
# 存储类型选择
|
||||
STORAGE_TYPE=local # 可选值: local | s3
|
||||
|
||||
# 上传文件过期时间(毫秒),0表示不过期
|
||||
UPLOAD_EXPIRATION_MS=0
|
||||
```
|
||||
|
||||
### 本地存储配置
|
||||
|
||||
当 `STORAGE_TYPE=local` 时需要配置:
|
||||
|
||||
```bash
|
||||
# 本地存储目录路径
|
||||
UPLOAD_DIR=./uploads
|
||||
```
|
||||
|
||||
### S3 存储配置
|
||||
|
||||
当 `STORAGE_TYPE=s3` 时需要配置:
|
||||
|
||||
```bash
|
||||
# S3 存储桶名称 (必需)
|
||||
S3_BUCKET=my-app-uploads
|
||||
|
||||
# S3 区域 (必需)
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# S3 访问密钥 ID (必需)
|
||||
S3_ACCESS_KEY_ID=your-access-key-id
|
||||
|
||||
# S3 访问密钥 (必需)
|
||||
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
|
||||
# 自定义 S3 端点 (可选,用于 MinIO、阿里云 OSS 等)
|
||||
S3_ENDPOINT=
|
||||
|
||||
# 是否强制使用路径样式 (可选)
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# 分片上传大小,单位字节 (可选,默认 8MB)
|
||||
S3_PART_SIZE=8388608
|
||||
|
||||
# 最大并发上传数 (可选)
|
||||
S3_MAX_CONCURRENT_UPLOADS=60
|
||||
```
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 开发环境 - 本地存储
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
STORAGE_TYPE=local
|
||||
UPLOAD_DIR=./uploads
|
||||
```
|
||||
|
||||
### 生产环境 - AWS S3
|
||||
|
||||
```bash
|
||||
# .env.production
|
||||
STORAGE_TYPE=s3
|
||||
S3_BUCKET=prod-app-uploads
|
||||
S3_REGION=us-west-2
|
||||
S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
|
||||
S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
```
|
||||
|
||||
### MinIO 本地开发
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
STORAGE_TYPE=s3
|
||||
S3_BUCKET=uploads
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
### 阿里云 OSS
|
||||
|
||||
```bash
|
||||
# .env.aliyun
|
||||
STORAGE_TYPE=s3
|
||||
S3_BUCKET=my-oss-bucket
|
||||
S3_REGION=oss-cn-hangzhou
|
||||
S3_ACCESS_KEY_ID=your-access-key-id
|
||||
S3_SECRET_ACCESS_KEY=your-access-key-secret
|
||||
S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
```
|
||||
|
||||
### 腾讯云 COS
|
||||
|
||||
```bash
|
||||
# .env.tencent
|
||||
STORAGE_TYPE=s3
|
||||
S3_BUCKET=my-cos-bucket-1234567890
|
||||
S3_REGION=ap-beijing
|
||||
S3_ACCESS_KEY_ID=your-secret-id
|
||||
S3_SECRET_ACCESS_KEY=your-secret-key
|
||||
S3_ENDPOINT=https://cos.ap-beijing.myqcloud.com
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
```
|
||||
|
||||
## 其他配置
|
||||
|
||||
### 数据库配置
|
||||
|
||||
```bash
|
||||
# PostgreSQL 数据库连接字符串
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/database"
|
||||
```
|
||||
|
||||
### Redis 配置
|
||||
|
||||
```bash
|
||||
# Redis 连接字符串
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
```
|
||||
|
||||
### 应用配置
|
||||
|
||||
```bash
|
||||
# 应用端口
|
||||
PORT=3000
|
||||
|
||||
# 应用环境
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS 允许的源
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **敏感信息保护**:
|
||||
|
||||
- 永远不要将包含敏感信息的 `.env` 文件提交到版本控制系统
|
||||
- 使用 `.env.example` 文件作为模板
|
||||
|
||||
2. **生产环境**:
|
||||
|
||||
- 使用环境变量管理服务(如 AWS Secrets Manager、Azure Key Vault)
|
||||
- 定期轮换访问密钥
|
||||
|
||||
3. **权限控制**:
|
||||
- S3 存储桶应配置适当的访问策略
|
||||
- 使用最小权限原则
|
||||
|
||||
## 验证配置
|
||||
|
||||
可以使用以下 API 端点验证存储配置:
|
||||
|
||||
```bash
|
||||
# 验证存储配置
|
||||
curl -X POST http://localhost:3000/api/storage/storage/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "s3",
|
||||
"s3": {
|
||||
"bucket": "my-bucket",
|
||||
"region": "us-east-1",
|
||||
"accessKeyId": "your-key",
|
||||
"secretAccessKey": "your-secret"
|
||||
}
|
||||
}'
|
||||
|
||||
# 获取当前存储信息
|
||||
curl http://localhost:3000/api/storage/storage/info
|
||||
```
|
||||
|
||||
## 文件访问
|
||||
|
||||
### 统一下载接口
|
||||
|
||||
无论使用哪种存储类型,都通过统一的下载接口访问文件:
|
||||
|
||||
```bash
|
||||
# 统一下载接口(推荐)
|
||||
GET http://localhost:3000/download/2024/01/01/abc123/example.jpg
|
||||
```
|
||||
|
||||
### 本地存储
|
||||
|
||||
当使用本地存储时:
|
||||
|
||||
- 下载接口会直接读取本地文件并返回
|
||||
- 支持内联显示(图片、PDF等)和下载
|
||||
|
||||
### S3 存储
|
||||
|
||||
当使用 S3 存储时:
|
||||
|
||||
- 下载接口会重定向到 S3 URL
|
||||
- 也可以直接访问 S3 URL(如果存储桶是公开的)
|
||||
|
||||
```bash
|
||||
# 直接访问 S3 URL
|
||||
GET https://bucket.s3.region.amazonaws.com/2024/01/01/abc123/example.jpg
|
||||
```
|
||||
|
||||
### 文件 URL 生成
|
||||
|
||||
```typescript
|
||||
import { StorageUtils } from '@repo/storage';
|
||||
|
||||
const storageUtils = StorageUtils.getInstance();
|
||||
|
||||
// 生成下载 URL(推荐方式)
|
||||
const fileUrl = storageUtils.generateFileUrl('file-id');
|
||||
// 结果: http://localhost:3000/download/file-id
|
||||
|
||||
// 生成完整的公开访问 URL
|
||||
const publicUrl = storageUtils.generateFileUrl('file-id', 'https://yourdomain.com');
|
||||
// 结果: https://yourdomain.com/download/file-id
|
||||
|
||||
// 生成 S3 直接访问 URL(仅 S3 存储)
|
||||
try {
|
||||
const directUrl = storageUtils.generateDirectUrl('file-id');
|
||||
// 结果: https://bucket.s3.region.amazonaws.com/file-id
|
||||
} catch (error) {
|
||||
// 本地存储会抛出错误
|
||||
}
|
||||
```
|
|
@ -1,279 +0,0 @@
|
|||
# 文件访问使用指南
|
||||
|
||||
本文档说明如何使用 `@repo/storage` 包提供的文件访问功能。
|
||||
|
||||
## 功能概述
|
||||
|
||||
存储包提供统一的文件访问接口:
|
||||
|
||||
- **统一下载接口** (`/download/:fileId`) - 适用于所有存储类型,提供统一的文件访问
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基础配置
|
||||
|
||||
```typescript
|
||||
import { createStorageApp } from '@repo/storage';
|
||||
|
||||
// 创建包含所有功能的存储应用
|
||||
const storageApp = createStorageApp({
|
||||
apiBasePath: '/api/storage', // API 管理接口
|
||||
uploadPath: '/upload', // TUS 上传接口
|
||||
downloadPath: '/download', // 文件下载接口
|
||||
});
|
||||
|
||||
app.route('/', storageApp);
|
||||
```
|
||||
|
||||
### 2. 分别配置功能
|
||||
|
||||
```typescript
|
||||
import { createStorageRoutes, createTusUploadRoutes, createFileDownloadRoutes } from '@repo/storage';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 存储管理 API
|
||||
app.route('/api/storage', createStorageRoutes());
|
||||
|
||||
// 文件上传
|
||||
app.route('/upload', createTusUploadRoutes());
|
||||
|
||||
// 文件下载(所有存储类型)
|
||||
app.route('/download', createFileDownloadRoutes());
|
||||
```
|
||||
|
||||
## 文件访问方式
|
||||
|
||||
### 统一下载接口
|
||||
|
||||
无论使用哪种存储类型,都通过统一的下载接口访问文件:
|
||||
|
||||
```bash
|
||||
# 访问文件(支持内联显示和下载)
|
||||
GET http://localhost:3000/download/2024/01/01/abc123/image.jpg
|
||||
GET http://localhost:3000/download/2024/01/01/abc123/document.pdf
|
||||
```
|
||||
|
||||
### 本地存储
|
||||
|
||||
当 `STORAGE_TYPE=local` 时:
|
||||
|
||||
- 下载接口直接读取本地文件
|
||||
- 自动设置正确的 Content-Type
|
||||
- 支持内联显示(`Content-Disposition: inline`)
|
||||
|
||||
### S3 存储
|
||||
|
||||
当 `STORAGE_TYPE=s3` 时:
|
||||
|
||||
- 下载接口重定向到 S3 URL
|
||||
- 也可以直接访问 S3 URL(如果存储桶是公开的)
|
||||
|
||||
```bash
|
||||
# 直接访问 S3 URL(如果存储桶是公开的)
|
||||
GET https://bucket.s3.region.amazonaws.com/2024/01/01/abc123/file.jpg
|
||||
```
|
||||
|
||||
## 代码示例
|
||||
|
||||
### 生成文件访问 URL
|
||||
|
||||
```typescript
|
||||
import { StorageUtils } from '@repo/storage';
|
||||
|
||||
const storageUtils = StorageUtils.getInstance();
|
||||
|
||||
// 生成文件访问 URL
|
||||
function getFileUrl(fileId: string) {
|
||||
// 结果: http://localhost:3000/download/2024/01/01/abc123/file.jpg
|
||||
return storageUtils.generateFileUrl(fileId);
|
||||
}
|
||||
|
||||
// 生成完整的公开访问 URL
|
||||
function getPublicFileUrl(fileId: string) {
|
||||
// 结果: https://yourdomain.com/download/2024/01/01/abc123/file.jpg
|
||||
return storageUtils.generateFileUrl(fileId, 'https://yourdomain.com');
|
||||
}
|
||||
|
||||
// 生成 S3 直接访问 URL(仅 S3 存储)
|
||||
function getDirectUrl(fileId: string) {
|
||||
try {
|
||||
// S3 存储: https://bucket.s3.region.amazonaws.com/2024/01/01/abc123/file.jpg
|
||||
return storageUtils.generateDirectUrl(fileId);
|
||||
} catch (error) {
|
||||
// 本地存储会抛出错误,使用下载接口
|
||||
return storageUtils.generateFileUrl(fileId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 在 React 组件中使用
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function FileDisplay({ fileId }: { fileId: string }) {
|
||||
const [fileUrl, setFileUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// 获取文件访问 URL
|
||||
fetch(`/api/storage/resource/${fileId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'ready' && data.resource) {
|
||||
// 生成文件访问 URL
|
||||
const url = `/download/${fileId}`;
|
||||
setFileUrl(url);
|
||||
}
|
||||
});
|
||||
}, [fileId]);
|
||||
|
||||
if (!fileUrl) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 图片会内联显示 */}
|
||||
<img src={fileUrl} alt="Uploaded file" />
|
||||
|
||||
{/* 下载链接 */}
|
||||
<a href={fileUrl} download>
|
||||
下载文件
|
||||
</a>
|
||||
|
||||
{/* PDF 等文档可以在新窗口打开 */}
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
在新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 文件类型处理
|
||||
|
||||
```typescript
|
||||
function getFileDisplayUrl(fileId: string, mimeType: string) {
|
||||
const baseUrl = `/download/${fileId}`;
|
||||
|
||||
// 根据文件类型决定显示方式
|
||||
if (mimeType.startsWith('image/')) {
|
||||
// 图片直接显示
|
||||
return baseUrl;
|
||||
} else if (mimeType === 'application/pdf') {
|
||||
// PDF 可以内联显示
|
||||
return baseUrl;
|
||||
} else {
|
||||
// 其他文件类型强制下载
|
||||
return `${baseUrl}?download=true`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 1. 访问控制
|
||||
|
||||
如需要权限验证,可以添加认证中间件:
|
||||
|
||||
```typescript
|
||||
import { createFileDownloadRoutes } from '@repo/storage';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 添加认证中间件
|
||||
app.use('/download/*', async (c, next) => {
|
||||
// 检查用户权限
|
||||
const token = c.req.header('Authorization');
|
||||
if (!isValidToken(token)) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// 添加文件下载服务
|
||||
app.route('/download', createFileDownloadRoutes());
|
||||
```
|
||||
|
||||
### 2. 文件类型限制
|
||||
|
||||
```typescript
|
||||
app.use('/download/*', async (c, next) => {
|
||||
const fileId = c.req.param('fileId');
|
||||
|
||||
// 从数据库获取文件信息
|
||||
const { resource } = await getResourceByFileId(fileId);
|
||||
if (!resource) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
if (!allowedTypes.includes(resource.mimeType)) {
|
||||
return c.json({ error: 'File type not allowed' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 缓存设置
|
||||
|
||||
```typescript
|
||||
app.use('/download/*', async (c, next) => {
|
||||
await next();
|
||||
|
||||
// 设置缓存头
|
||||
c.header('Cache-Control', 'public, max-age=31536000'); // 1年
|
||||
c.header('ETag', generateETag(c.req.path));
|
||||
});
|
||||
```
|
||||
|
||||
### 2. CDN 配置
|
||||
|
||||
对于生产环境,建议使用 CDN:
|
||||
|
||||
```typescript
|
||||
import { StorageUtils } from '@repo/storage';
|
||||
|
||||
const storageUtils = StorageUtils.getInstance();
|
||||
|
||||
// 使用 CDN 域名
|
||||
const cdnUrl = 'https://cdn.yourdomain.com';
|
||||
const fileUrl = storageUtils.generateFileUrl(fileId, cdnUrl);
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **404 文件未找到**
|
||||
|
||||
- 检查文件是否存在于数据库
|
||||
- 确认文件路径是否正确
|
||||
- 检查文件权限(本地存储)
|
||||
|
||||
2. **下载接口不工作**
|
||||
|
||||
- 检查路由配置
|
||||
- 确认存储配置正确
|
||||
- 查看服务器日志
|
||||
|
||||
3. **S3 文件无法访问**
|
||||
- 检查 S3 存储桶权限
|
||||
- 确认文件是否上传成功
|
||||
- 验证 S3 配置是否正确
|
||||
|
||||
### 调试方法
|
||||
|
||||
```bash
|
||||
# 检查文件是否存在
|
||||
curl -I http://localhost:3000/download/2024/01/01/abc123/file.jpg
|
||||
|
||||
# 检查存储配置
|
||||
curl http://localhost:3000/api/storage/storage/info
|
||||
|
||||
# 检查文件信息
|
||||
curl http://localhost:3000/api/storage/resource/2024/01/01/abc123/file.jpg
|
||||
```
|
71
env.example
71
env.example
|
@ -1,71 +0,0 @@
|
|||
# ===========================================
|
||||
# 存储配置 (@repo/storage)
|
||||
# ===========================================
|
||||
|
||||
# 存储类型: local | s3
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# 上传文件过期时间(毫秒),0表示不过期
|
||||
UPLOAD_EXPIRATION_MS=0
|
||||
|
||||
# ===========================================
|
||||
# 本地存储配置 (当 STORAGE_TYPE=local 时)
|
||||
# ===========================================
|
||||
|
||||
# 本地存储目录路径
|
||||
UPLOAD_DIR=./uploads
|
||||
|
||||
# ===========================================
|
||||
# S3 存储配置 (当 STORAGE_TYPE=s3 时)
|
||||
# ===========================================
|
||||
|
||||
# S3 存储桶名称 (必需)
|
||||
S3_BUCKET=
|
||||
|
||||
# S3 区域 (必需)
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# S3 访问密钥 ID (必需)
|
||||
S3_ACCESS_KEY_ID=
|
||||
|
||||
# S3 访问密钥 (必需)
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
# 自定义 S3 端点 (可选,用于 MinIO、阿里云 OSS 等)
|
||||
S3_ENDPOINT=
|
||||
|
||||
# 是否强制使用路径样式 (可选)
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# 分片上传大小,单位字节 (可选,默认 8MB)
|
||||
S3_PART_SIZE=8388608
|
||||
|
||||
# 最大并发上传数 (可选)
|
||||
S3_MAX_CONCURRENT_UPLOADS=60
|
||||
|
||||
# ===========================================
|
||||
# 数据库配置
|
||||
# ===========================================
|
||||
|
||||
# 数据库连接字符串
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/database"
|
||||
|
||||
# ===========================================
|
||||
# Redis 配置
|
||||
# ===========================================
|
||||
|
||||
# Redis 连接字符串
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ===========================================
|
||||
# 应用配置
|
||||
# ===========================================
|
||||
|
||||
# 应用端口
|
||||
PORT=3000
|
||||
|
||||
# 应用环境
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS 允许的源
|
||||
CORS_ORIGIN=http://localhost:3001
|
|
@ -1,3 +1,5 @@
|
|||
// 优化后的架构 - 数据中台 v4.0 (完全通用化)
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
|
@ -9,128 +11,394 @@ datasource db {
|
|||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============= 数据源层 =============
|
||||
model DataSource {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
type SourceType
|
||||
config Json // 连接配置
|
||||
description String?
|
||||
status Status @default(ACTIVE)
|
||||
|
||||
// 元数据版本管理
|
||||
schemaVersion String? @default("1.0")
|
||||
lastSynced DateTime?
|
||||
|
||||
// 关联
|
||||
pipelines Pipeline[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("data_sources")
|
||||
}
|
||||
|
||||
// ============= 统一流水线层 =============
|
||||
model Pipeline {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type PipelineType // SYNC | TRANSFORM | STREAM | HYBRID
|
||||
schedule String? // Cron表达式
|
||||
config Json // 流水线配置
|
||||
description String?
|
||||
status Status @default(ACTIVE)
|
||||
|
||||
// 关联数据源(可选)
|
||||
dataSource DataSource? @relation(fields: [dataSourceId], references: [id])
|
||||
dataSourceId String?
|
||||
|
||||
// 执行记录
|
||||
executions PipelineExecution[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("pipelines")
|
||||
}
|
||||
|
||||
// 统一执行记录
|
||||
model PipelineExecution {
|
||||
id String @id @default(cuid())
|
||||
executionId String @unique
|
||||
status ExecutionStatus @default(PENDING)
|
||||
|
||||
// 统计信息
|
||||
inputRecords Int @default(0)
|
||||
outputRecords Int @default(0)
|
||||
errorRecords Int @default(0)
|
||||
|
||||
// 时间信息
|
||||
startedAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
duration Int? // 毫秒
|
||||
|
||||
// 元数据
|
||||
metadata Json? // 灵活的元数据存储
|
||||
errorMsg String?
|
||||
|
||||
// 关联
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id])
|
||||
pipelineId String
|
||||
|
||||
// 产生的数据资产
|
||||
dataAssets DataAsset[]
|
||||
|
||||
// 数据血缘
|
||||
lineageRecords LineageRecord[]
|
||||
|
||||
@@map("pipeline_executions")
|
||||
}
|
||||
|
||||
// ============= 数据资产层 =============
|
||||
model DataAsset {
|
||||
id String @id @default(cuid())
|
||||
assetId String @unique
|
||||
name String
|
||||
type AssetType @default(BATCH)
|
||||
format DataFormat @default(PARQUET)
|
||||
|
||||
// 存储信息(支持多种存储)
|
||||
storageConfig Json // 统一存储配置
|
||||
|
||||
// 元数据管理
|
||||
schema Json?
|
||||
partitions Json? // 分区信息
|
||||
size BigInt? // 数据大小
|
||||
recordCount BigInt? // 记录数
|
||||
|
||||
// 数据质量
|
||||
qualityScore Float? // 0-1之间的质量分数
|
||||
|
||||
// 关联
|
||||
execution PipelineExecution? @relation(fields: [executionId], references: [id])
|
||||
executionId String?
|
||||
|
||||
// 查询记录
|
||||
queries Query[]
|
||||
|
||||
status AssetStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([type, status])
|
||||
@@index([createdAt])
|
||||
@@map("data_assets")
|
||||
}
|
||||
|
||||
// ============= 查询层 =============
|
||||
model Query {
|
||||
id String @id @default(cuid())
|
||||
queryId String @unique
|
||||
sql String
|
||||
engine QueryEngine @default(DUCKDB)
|
||||
|
||||
// 执行信息
|
||||
status QueryStatus @default(PENDING)
|
||||
resultCount BigInt?
|
||||
resultPath String?
|
||||
duration Int? // 毫秒
|
||||
errorMsg String?
|
||||
|
||||
// 查询标签
|
||||
tags Json?
|
||||
|
||||
// 关联资产
|
||||
dataAsset DataAsset @relation(fields: [assetId], references: [id])
|
||||
assetId String
|
||||
|
||||
// 审计信息
|
||||
userId String? // 执行用户
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@map("queries")
|
||||
}
|
||||
|
||||
// ============= 通用实体层 =============
|
||||
|
||||
// 通用实体模型(支持优雅的树形层级)
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
type EntityType // PERSON, EQUIPMENT, FACILITY, MATERIAL, ORGANIZATION等
|
||||
attributes Json? // 灵活的属性存储
|
||||
status Status @default(ACTIVE)
|
||||
|
||||
// 树形层级关系(直接支持)
|
||||
parentId String?
|
||||
parent Entity? @relation("EntityTree", fields: [parentId], references: [id])
|
||||
children Entity[] @relation("EntityTree")
|
||||
|
||||
// 层级路径(用于快速查询)
|
||||
path String? // 如: "/org1/dept1/team1" 便于层级查询
|
||||
level Int? // 层级深度,根节点为0
|
||||
|
||||
// 作为源实体的关系(非树形关系)
|
||||
sourceRelations EntityRelation[] @relation("SourceEntity")
|
||||
// 作为目标实体的关系
|
||||
targetRelations EntityRelation[] @relation("TargetEntity")
|
||||
|
||||
// 数据血缘
|
||||
lineageRecords LineageRecord[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([type, status])
|
||||
@@index([code])
|
||||
@@index([type, code])
|
||||
@@index([parentId])
|
||||
@@index([path])
|
||||
@@index([type, parentId])
|
||||
@@index([level])
|
||||
@@map("entities")
|
||||
}
|
||||
|
||||
// ============= 通用关系层 =============
|
||||
|
||||
// 通用实体关系表(处理非树形的复杂关系)
|
||||
model EntityRelation {
|
||||
id String @id @default(cuid())
|
||||
sourceId String // 源实体ID
|
||||
targetId String // 目标实体ID
|
||||
relationship String // 关系类型
|
||||
|
||||
// 关系属性
|
||||
attributes Json? // 关系属性:权限级别、时间范围等
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
|
||||
// 关系元数据
|
||||
metadata Json? // 其他元数据
|
||||
|
||||
// 关联实体
|
||||
sourceEntity Entity @relation("SourceEntity", fields: [sourceId], references: [id])
|
||||
targetEntity Entity @relation("TargetEntity", fields: [targetId], references: [id])
|
||||
|
||||
status Status @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sourceId, targetId, relationship])
|
||||
@@index([sourceId])
|
||||
@@index([targetId])
|
||||
@@index([relationship])
|
||||
@@index([sourceId, relationship])
|
||||
@@index([targetId, relationship])
|
||||
@@index([relationship, status])
|
||||
@@map("entity_relations")
|
||||
}
|
||||
|
||||
// ============= 优化的血缘层 =============
|
||||
model LineageRecord {
|
||||
id String @id @default(cuid())
|
||||
sourceType String // 来源类型
|
||||
sourceId String // 来源ID
|
||||
targetType String // 目标类型
|
||||
targetId String // 目标ID
|
||||
relationship String // 关系类型: CREATE, UPDATE, DERIVE
|
||||
|
||||
// 元数据
|
||||
metadata Json? // 血缘元数据
|
||||
|
||||
// 关联执行(可选)
|
||||
execution PipelineExecution? @relation(fields: [executionId], references: [id])
|
||||
executionId String?
|
||||
|
||||
// 关联实体(可选)
|
||||
entity Entity? @relation(fields: [entityId], references: [id])
|
||||
entityId String?
|
||||
|
||||
status LineageStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sourceType, sourceId, targetType, targetId])
|
||||
@@index([relationship])
|
||||
@@map("lineage_records")
|
||||
}
|
||||
|
||||
// ============= 数据治理层 =============
|
||||
model QualityRule {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
rule Json // 质量规则定义
|
||||
threshold Float? // 阈值
|
||||
|
||||
// 应用范围
|
||||
entityType String? // 应用的实体类型
|
||||
entityId String? // 特定实体ID
|
||||
|
||||
status Status @default(ACTIVE)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("quality_rules")
|
||||
}
|
||||
|
||||
model DataCatalog {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
tags Json? // 标签
|
||||
owner String? // 数据负责人
|
||||
|
||||
// 分类
|
||||
category String?
|
||||
sensitivity String? // 敏感度级别
|
||||
|
||||
// 关联资产或实体
|
||||
assetType String // 资产类型:DataAsset, Entity等
|
||||
assetId String // 资产ID
|
||||
|
||||
status Status @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([assetType, assetId])
|
||||
@@index([category])
|
||||
@@map("data_catalogs")
|
||||
}
|
||||
|
||||
// ============= 精简的权限层 =============
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
password String?
|
||||
salt String?
|
||||
phone String? @unique
|
||||
email String @unique
|
||||
avatar String?
|
||||
isSystem Boolean? @map("is_system")
|
||||
isAdmin Boolean? @map("is_admin")
|
||||
lastSignTime DateTime? @map("last_sign_time")
|
||||
deactivatedTime DateTime? @map("deactivated_time")
|
||||
createdTime DateTime @default(now()) @map("created_time")
|
||||
deletedTime DateTime? @map("deleted_time")
|
||||
lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
name String
|
||||
roles Json? // 简化为JSON存储角色
|
||||
status Status @default(ACTIVE)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Attachments {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
hash String
|
||||
size Int
|
||||
mimetype String
|
||||
path String
|
||||
width Int?
|
||||
height Int?
|
||||
deletedTime DateTime? @map("deleted_time")
|
||||
createdTime DateTime @default(now()) @map("created_time")
|
||||
createdBy String @map("created_by")
|
||||
lastModifiedBy String? @map("last_modified_by")
|
||||
thumbnailPath String? @map("thumbnail_path")
|
||||
|
||||
@@map("attachments")
|
||||
// ============= 枚举定义 =============
|
||||
enum Status {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
fromUserId String @map("from_user_id")
|
||||
toUserId String @map("to_user_id")
|
||||
type String @map("type")
|
||||
message String @map("message")
|
||||
urlPath String? @map("url_path")
|
||||
isRead Boolean @default(false) @map("is_read")
|
||||
createdTime DateTime @default(now()) @map("created_time")
|
||||
createdBy String @map("created_by")
|
||||
|
||||
@@index([toUserId, isRead, createdTime])
|
||||
@@map("notification")
|
||||
enum SourceType {
|
||||
DATABASE
|
||||
API
|
||||
FILE
|
||||
ERP
|
||||
MES
|
||||
IOT
|
||||
STREAM
|
||||
}
|
||||
|
||||
model Setting {
|
||||
instanceId String @id @default(cuid()) @map("instance_id")
|
||||
disallowSignUp Boolean? @map("disallow_sign_up")
|
||||
disallowSpaceCreation Boolean? @map("disallow_space_creation")
|
||||
disallowSpaceInvitation Boolean? @map("disallow_space_invitation")
|
||||
enableEmailVerification Boolean? @map("enable_email_verification")
|
||||
aiConfig String? @map("ai_config")
|
||||
brandName String? @map("brand_name")
|
||||
brandLogo String? @map("brand_logo")
|
||||
|
||||
@@map("setting")
|
||||
enum PipelineType {
|
||||
SYNC
|
||||
TRANSFORM
|
||||
STREAM
|
||||
HYBRID
|
||||
}
|
||||
|
||||
model Trash {
|
||||
id String @id @default(cuid())
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
parentId String? @map("parent_id")
|
||||
deletedTime DateTime @default(now()) @map("deleted_time")
|
||||
deletedBy String @map("deleted_by")
|
||||
|
||||
@@unique([resourceType, resourceId])
|
||||
@@map("trash")
|
||||
enum ExecutionStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model UserLastVisit {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
parentResourceId String @map("parent_resource_id")
|
||||
lastVisitTime DateTime @default(now()) @map("last_visit_time")
|
||||
|
||||
@@unique([userId, resourceType, parentResourceId])
|
||||
@@index([userId, resourceType])
|
||||
@@map("user_last_visit")
|
||||
enum DataFormat {
|
||||
PARQUET
|
||||
JSON
|
||||
CSV
|
||||
AVRO
|
||||
DELTA
|
||||
}
|
||||
|
||||
model OidcClient {
|
||||
id String @id @default(cuid())
|
||||
clientId String @unique
|
||||
clientSecret String
|
||||
redirectUris String // 存储为JSON字符串
|
||||
grantTypes String // 存储为JSON字符串
|
||||
responseTypes String // 存储为JSON字符串
|
||||
scope String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("oidc_clients")
|
||||
enum AssetType {
|
||||
BATCH
|
||||
STREAM
|
||||
TABLE
|
||||
VIEW
|
||||
MODEL
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
DEPRECATED
|
||||
}
|
||||
|
||||
enum QueryEngine {
|
||||
DUCKDB
|
||||
ATHENA
|
||||
SPARK
|
||||
TRINO
|
||||
}
|
||||
|
||||
enum QueryStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
PERSON
|
||||
EQUIPMENT
|
||||
FACILITY
|
||||
MATERIAL
|
||||
ORGANIZATION
|
||||
PROJECT
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
enum LineageStatus {
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
model Resource {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
title String? @map("title")
|
||||
description String? @map("description")
|
||||
type String? @map("type")
|
||||
fileId String? @unique
|
||||
url String?
|
||||
meta Json? @map("meta")
|
||||
status String?
|
||||
createdAt DateTime? @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
isPublic Boolean? @default(true) @map("is_public")
|
||||
storageType String? @map("storage_type")
|
||||
|
||||
// 索引
|
||||
@@index([type])
|
||||
@@index([createdAt])
|
||||
@@map("resource")
|
||||
}
|
|
@ -1,158 +0,0 @@
|
|||
# OIDC Provider
|
||||
|
||||
OpenID Connect Provider 实现,支持标准的 OIDC 协议流程。
|
||||
|
||||
## 特性
|
||||
|
||||
- 完整的 OIDC 协议支持
|
||||
- 密码认证策略
|
||||
- 会话管理
|
||||
- 令牌管理(访问令牌、刷新令牌、ID令牌)
|
||||
- PKCE 支持
|
||||
- 可自定义的存储适配器
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
npm install @nice/oidc-provider
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
|
||||
```typescript
|
||||
import { createOIDCProvider } from '@nice/oidc-provider/middleware/hono';
|
||||
import { MemoryStorageAdapter } from '@nice/oidc-provider/storage';
|
||||
|
||||
const config = {
|
||||
issuer: 'https://your-domain.com',
|
||||
signingKey: 'your-signing-key',
|
||||
storage: new MemoryStorageAdapter(),
|
||||
|
||||
// 用户和客户端查找函数
|
||||
findUser: async (userId: string) => {
|
||||
// 从数据库查找用户
|
||||
return await db.user.findUnique({ where: { id: userId } });
|
||||
},
|
||||
|
||||
findClient: async (clientId: string) => {
|
||||
// 从数据库查找客户端
|
||||
return await db.client.findUnique({ where: { id: clientId } });
|
||||
},
|
||||
|
||||
// 认证配置
|
||||
authConfig: {
|
||||
// 密码验证器
|
||||
passwordValidator: async (username: string, password: string) => {
|
||||
const user = await db.user.findUnique({ where: { username } });
|
||||
if (user && await bcrypt.compare(password, user.hashedPassword)) {
|
||||
return user.id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// 会话配置
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
rememberMeMaxAge: 30 * 24 * 60 * 60, // 30天
|
||||
|
||||
// 页面配置
|
||||
pageConfig: {
|
||||
title: '用户登录',
|
||||
brandName: '我的应用',
|
||||
logoUrl: '/logo.png'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 OIDC Provider Hono 应用
|
||||
const oidcApp = createOIDCProvider(config);
|
||||
```
|
||||
|
||||
### 3. 集成到 Hono 应用
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 挂载 OIDC Provider
|
||||
app.route('/oidc', oidcApp);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
创建后的 OIDC Provider 将提供以下标准端点:
|
||||
|
||||
- `POST /login` - 用户登录
|
||||
- `GET /logout` - 用户登出
|
||||
- `POST /logout` - 用户登出(POST 方式)
|
||||
- `GET /.well-known/openid-configuration` - OIDC 发现文档
|
||||
- `GET /.well-known/jwks.json` - JSON Web Key Set
|
||||
- `GET /auth` - 授权端点
|
||||
- `POST /token` - 令牌端点
|
||||
- `GET /userinfo` - 用户信息端点
|
||||
- `POST /revoke` - 令牌撤销端点
|
||||
- `POST /introspect` - 令牌内省端点
|
||||
|
||||
## 配置选项
|
||||
|
||||
### OIDCProviderConfig
|
||||
|
||||
| 字段 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `issuer` | string | ✓ | 发行者标识符 |
|
||||
| `signingKey` | string | ✓ | JWT 签名密钥 |
|
||||
| `storage` | StorageAdapter | ✓ | 存储适配器 |
|
||||
| `findUser` | function | ✓ | 用户查找函数 |
|
||||
| `findClient` | function | ✓ | 客户端查找函数 |
|
||||
| `authConfig` | AuthConfig | - | 认证配置 |
|
||||
| `tokenTTL` | TokenTTLConfig | - | 令牌过期时间配置 |
|
||||
|
||||
### AuthConfig
|
||||
|
||||
| 字段 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `passwordValidator` | function | - | 密码验证函数 |
|
||||
| `sessionTTL` | number | - | 会话过期时间(秒) |
|
||||
| `rememberMeMaxAge` | number | - | 记住我最长时间(秒) |
|
||||
| `pageConfig` | PageConfig | - | 登录页面配置 |
|
||||
|
||||
### PageConfig
|
||||
|
||||
| 字段 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| `title` | string | 登录页面标题 |
|
||||
| `brandName` | string | 品牌名称 |
|
||||
| `logoUrl` | string | Logo URL |
|
||||
|
||||
## 存储适配器
|
||||
|
||||
项目提供了多种存储适配器:
|
||||
|
||||
- `MemoryStorageAdapter` - 内存存储(适用于开发和测试)
|
||||
- `RedisStorageAdapter` - Redis 存储
|
||||
- `DatabaseStorageAdapter` - 数据库存储
|
||||
|
||||
### 自定义存储适配器
|
||||
|
||||
```typescript
|
||||
import { StorageAdapter } from '@nice/oidc-provider/storage';
|
||||
|
||||
class CustomStorageAdapter implements StorageAdapter {
|
||||
// 实现所需的方法
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
1. **签名密钥安全**:确保 `signingKey` 足够复杂且妥善保管
|
||||
2. **HTTPS**:生产环境必须使用 HTTPS
|
||||
3. **客户端验证**:实现严格的客户端验证逻辑
|
||||
4. **密码策略**:在 `passwordValidator` 中实现适当的密码策略
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
|
@ -0,0 +1,89 @@
|
|||
import { OIDCProvider } from '../src';
|
||||
import type { OIDCProviderConfig } from '../src/types';
|
||||
|
||||
// 示例:使用RS256算法自动生成密钥对
|
||||
const configWithRS256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'temporary-key', // 这个字符串会被自动生成的RSA密钥对替代
|
||||
signingAlgorithm: 'RS256', // 指定使用RSA算法
|
||||
storage: {} as any, // 这里应该是真实的存储适配器
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 示例:使用ES256算法自动生成密钥对
|
||||
const configWithES256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'temporary-key', // 这个字符串会被自动生成的ECDSA密钥对替代
|
||||
signingAlgorithm: 'ES256', // 指定使用ECDSA算法
|
||||
storage: {} as any,
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 示例:使用HS256算法(不会自动生成密钥对)
|
||||
const configWithHS256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'your-secret-key', // 对于HMAC,直接使用字符串密钥
|
||||
signingAlgorithm: 'HS256',
|
||||
storage: {} as any,
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 使用示例
|
||||
async function demonstrateAutoKeyGeneration() {
|
||||
console.log('=== 自动密钥生成示例 ===\n');
|
||||
|
||||
// RS256 示例
|
||||
console.log('1. 创建使用RS256算法的Provider:');
|
||||
const providerRS256 = new OIDCProvider(configWithRS256);
|
||||
|
||||
// 第一次调用会触发RSA密钥对生成
|
||||
console.log('获取JWKS (会自动生成RSA密钥对):');
|
||||
const jwksRS256 = await providerRS256.getJWKS();
|
||||
console.log('RSA JWKS keys数量:', jwksRS256.keys.length);
|
||||
console.log('RSA 密钥类型:', jwksRS256.keys[0]?.kty);
|
||||
console.log('RSA 算法:', jwksRS256.keys[0]?.alg);
|
||||
console.log('');
|
||||
|
||||
// ES256 示例
|
||||
console.log('2. 创建使用ES256算法的Provider:');
|
||||
const providerES256 = new OIDCProvider(configWithES256);
|
||||
|
||||
// 第一次调用会触发ECDSA密钥对生成
|
||||
console.log('获取JWKS (会自动生成ECDSA密钥对):');
|
||||
const jwksES256 = await providerES256.getJWKS();
|
||||
console.log('ECDSA JWKS keys数量:', jwksES256.keys.length);
|
||||
console.log('ECDSA 密钥类型:', jwksES256.keys[0]?.kty);
|
||||
console.log('ECDSA 算法:', jwksES256.keys[0]?.alg);
|
||||
console.log('');
|
||||
|
||||
// HS256 示例
|
||||
console.log('3. 创建使用HS256算法的Provider:');
|
||||
const providerHS256 = new OIDCProvider(configWithHS256);
|
||||
|
||||
// HS256不会生成JWKS
|
||||
console.log('获取JWKS (HS256不暴露密钥):');
|
||||
const jwksHS256 = await providerHS256.getJWKS();
|
||||
console.log('HS256 JWKS keys数量:', jwksHS256.keys.length);
|
||||
console.log('');
|
||||
|
||||
console.log('=== 示例完成 ===');
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
demonstrateAutoKeyGeneration().catch(console.error);
|
||||
}
|
||||
|
||||
export { demonstrateAutoKeyGeneration };
|
|
@ -0,0 +1,91 @@
|
|||
# OIDC Provider - 自动生成密钥对示例
|
||||
|
||||
现在OIDC Provider支持为RSA和ECDSA算法自动生成密钥对,无需手动提供。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 使用RSA算法(自动生成密钥对)
|
||||
|
||||
```typescript
|
||||
import { OIDCProvider } from '@your-package/oidc-provider';
|
||||
|
||||
// 直接使用构造函数创建Provider实例
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingAlgorithm: 'RS256', // 指定算法,密钥对将在首次使用时自动生成
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 使用ECDSA算法(自动生成密钥对)
|
||||
|
||||
```typescript
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingAlgorithm: 'ES256', // ECDSA算法,密钥对将在首次使用时自动生成
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 使用HMAC算法(需要提供密钥)
|
||||
|
||||
```typescript
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: 'your-secret-key', // HS256必须提供密钥
|
||||
signingAlgorithm: 'HS256', // 可选,默认为HS256
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 密钥生成时机
|
||||
|
||||
- **懒加载**:密钥对将在首次调用需要签名的方法时自动生成(如生成token、获取JWKS等)
|
||||
- **一次生成**:每个Provider实例只会生成一次密钥对,后续调用会复用相同的密钥
|
||||
- **控制台输出**:自动生成密钥对时会在控制台输出确认信息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **生产环境建议**:在生产环境中,建议提前生成并持久化密钥对,而不是每次启动时重新生成
|
||||
2. **HS256算法**:使用HS256时仍然需要提供`signingKey`
|
||||
3. **同步构造**:现在可以直接使用`new OIDCProvider()`构造函数,无需异步等待
|
||||
4. **密钥轮换**:如果需要密钥轮换,可以使用`JWTUtils.generateRSAKeyPair()`或`JWTUtils.generateECDSAKeyPair()`方法生成新的密钥对
|
||||
|
||||
## 手动提供密钥对
|
||||
|
||||
如果你想手动提供密钥对:
|
||||
|
||||
```typescript
|
||||
import { JWTUtils } from '@your-package/oidc-provider';
|
||||
|
||||
// 生成密钥对
|
||||
const keyPair = await JWTUtils.generateRSAKeyPair('my-key-id');
|
||||
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: keyPair, // 手动提供密钥对
|
||||
signingAlgorithm: 'RS256',
|
||||
// ... 其他配置
|
||||
});
|
||||
```
|
|
@ -0,0 +1,121 @@
|
|||
import { OIDCProvider } from '../src/provider';
|
||||
import type { OIDCProviderConfig } from '../src/types';
|
||||
|
||||
// 模拟存储适配器
|
||||
const mockStorage = {
|
||||
async set(key: string, value: any, ttl?: number): Promise<void> {
|
||||
console.log(`存储: ${key}`);
|
||||
},
|
||||
async get(key: string): Promise<any> {
|
||||
return null;
|
||||
},
|
||||
async delete(key: string): Promise<void> {
|
||||
console.log(`删除: ${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 基础配置
|
||||
const baseConfig: Omit<OIDCProviderConfig, 'signingKey' | 'signingAlgorithm'> = {
|
||||
issuer: 'https://auth.example.com',
|
||||
storage: mockStorage,
|
||||
findUser: async (userId: string) => ({
|
||||
sub: userId,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com'
|
||||
}),
|
||||
findClient: async (clientId: string) => ({
|
||||
client_id: clientId,
|
||||
client_type: 'public' as const,
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile'],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}),
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => {
|
||||
return username === 'test' && password === 'password' ? 'user123' : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function testAutoKeyGeneration() {
|
||||
console.log('=== 测试自动生成密钥对功能 ===\n');
|
||||
|
||||
// 测试1: RS256算法自动生成RSA密钥对
|
||||
console.log('1. 测试RS256算法自动生成RSA密钥对:');
|
||||
try {
|
||||
const providerRS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'RS256'
|
||||
// 注意:没有提供signingKey
|
||||
});
|
||||
|
||||
const jwks = await providerRS256.getJWKS();
|
||||
console.log('✅ 成功生成RS256密钥对');
|
||||
console.log('JWKS keys count:', jwks.keys.length);
|
||||
console.log('First key algorithm:', jwks.keys[0]?.alg);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ RS256测试失败:', error);
|
||||
}
|
||||
|
||||
// 测试2: ES256算法自动生成ECDSA密钥对
|
||||
console.log('2. 测试ES256算法自动生成ECDSA密钥对:');
|
||||
try {
|
||||
const providerES256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'ES256'
|
||||
// 注意:没有提供signingKey
|
||||
});
|
||||
|
||||
const jwks = await providerES256.getJWKS();
|
||||
console.log('✅ 成功生成ES256密钥对');
|
||||
console.log('JWKS keys count:', jwks.keys.length);
|
||||
console.log('First key algorithm:', jwks.keys[0]?.alg);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ ES256测试失败:', error);
|
||||
}
|
||||
|
||||
// 测试3: HS256算法没有signingKey应该失败
|
||||
console.log('3. 测试HS256算法没有signingKey应该失败:');
|
||||
try {
|
||||
const providerHS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'HS256'
|
||||
// 注意:没有提供signingKey,应该失败
|
||||
});
|
||||
|
||||
// 调用getJWKS触发验证
|
||||
await providerHS256.getJWKS();
|
||||
console.error('❌ HS256测试失败:应该抛出错误但没有');
|
||||
} catch (error) {
|
||||
console.log('✅ HS256测试成功:正确抛出错误');
|
||||
console.log('错误信息:', (error as Error).message);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 测试4: HS256算法提供signingKey应该成功
|
||||
console.log('4. 测试HS256算法提供signingKey应该成功:');
|
||||
try {
|
||||
const providerHS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingKey: 'my-secret-key-at-least-32-characters-long',
|
||||
signingAlgorithm: 'HS256'
|
||||
});
|
||||
|
||||
const jwks = await providerHS256.getJWKS();
|
||||
console.log('✅ HS256测试成功');
|
||||
console.log('JWKS keys count:', jwks.keys.length, '(HS256不公开密钥)');
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ HS256测试失败:', error);
|
||||
}
|
||||
|
||||
console.log('=== 测试完成 ===');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testAutoKeyGeneration().catch(console.error);
|
|
@ -0,0 +1,144 @@
|
|||
import { OIDCProvider } from '../src/provider';
|
||||
import { MemoryStorageAdapter } from '../src/storage/memory';
|
||||
import type { OIDCProviderConfig, OIDCUser, OIDCClient } from '../src/types';
|
||||
|
||||
// 示例:带有zod验证的OIDC Provider使用
|
||||
|
||||
// 创建存储适配器
|
||||
const storage = new MemoryStorageAdapter();
|
||||
|
||||
// 示例用户查找函数
|
||||
const findUser = async (userId: string): Promise<OIDCUser | null> => {
|
||||
const users: Record<string, OIDCUser> = {
|
||||
'user123': {
|
||||
sub: 'user123',
|
||||
username: 'john@example.com',
|
||||
email: 'john@example.com',
|
||||
email_verified: true,
|
||||
name: 'John Doe',
|
||||
given_name: 'John',
|
||||
family_name: 'Doe',
|
||||
}
|
||||
};
|
||||
return users[userId] || null;
|
||||
};
|
||||
|
||||
// 示例客户端查找函数
|
||||
const findClient = async (clientId: string): Promise<OIDCClient | null> => {
|
||||
const clients: Record<string, OIDCClient> = {
|
||||
'demo-client': {
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: ['https://app.example.com/callback'],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
};
|
||||
return clients[clientId] || null;
|
||||
};
|
||||
|
||||
// 密码验证器
|
||||
const passwordValidator = async (username: string, password: string): Promise<string | null> => {
|
||||
// 这里应该实现真实的密码验证逻辑
|
||||
if (username === 'john@example.com' && password === 'password123') {
|
||||
return 'user123'; // 返回用户ID
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 配置OIDC Provider
|
||||
const config: OIDCProviderConfig = {
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: 'your-secret-key-for-development-only',
|
||||
signingAlgorithm: 'HS256',
|
||||
storage,
|
||||
findUser,
|
||||
findClient,
|
||||
authConfig: {
|
||||
passwordValidator,
|
||||
sessionTTL: 3600, // 1小时
|
||||
pageConfig: {
|
||||
title: 'My Auth Server',
|
||||
brandName: 'Example Corp',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
},
|
||||
rememberMeMaxAge: 30 * 24 * 3600, // 30天
|
||||
},
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
enablePKCE: true,
|
||||
requirePKCE: true, // 对公共客户端强制要求PKCE
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 创建OIDC Provider实例
|
||||
const provider = new OIDCProvider(config);
|
||||
|
||||
// 导出配置好的provider
|
||||
export { provider };
|
||||
|
||||
// 使用示例:
|
||||
// 1. 授权请求会自动使用zod验证所有参数
|
||||
// 2. 令牌请求会验证FormData和Basic认证头
|
||||
// 3. 用户信息请求会验证Bearer token格式
|
||||
// 4. 令牌撤销和内省请求会验证相应的参数
|
||||
|
||||
// 错误处理示例:
|
||||
export const handleAuthorizationExample = async (query: Record<string, string>) => {
|
||||
try {
|
||||
// 这会触发zod验证
|
||||
const result = await provider.handleAuthorizationRequest({
|
||||
response_type: query.response_type,
|
||||
client_id: query.client_id,
|
||||
redirect_uri: query.redirect_uri,
|
||||
scope: query.scope,
|
||||
state: query.state,
|
||||
// ... 其他参数
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('授权码:', result.code);
|
||||
} else {
|
||||
console.error('授权失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('授权请求参数无效')) {
|
||||
console.error('参数验证失败:', error.message);
|
||||
} else {
|
||||
console.error('未知错误:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 令牌请求示例
|
||||
export const handleTokenExample = async (formData: FormData) => {
|
||||
try {
|
||||
// 这会触发zod验证FormData
|
||||
const result = await provider.handleTokenRequest({
|
||||
grant_type: formData.get('grant_type')?.toString() || '',
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
// ... 其他参数会被自动验证
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('访问令牌:', result.response.access_token);
|
||||
} else {
|
||||
console.error('令牌请求失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('令牌请求参数无效')) {
|
||||
console.error('参数验证失败:', error.message);
|
||||
} else {
|
||||
console.error('未知错误:', error);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -2,8 +2,9 @@
|
|||
"name": "@repo/oidc-provider",
|
||||
"version": "2.0.0",
|
||||
"description": "OpenID Connect Provider implementation for Hono - 完全兼容 Hono 的 OIDC Provider",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
|
@ -25,13 +26,6 @@
|
|||
"hono": "^4.0.0",
|
||||
"ioredis": "^5.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import type { OIDCError } from '../types';
|
||||
|
||||
/**
|
||||
* OIDC错误处理工厂类
|
||||
* 用于创建标准化的OIDC错误响应
|
||||
*/
|
||||
export class OIDCErrorFactory {
|
||||
/**
|
||||
* 创建授权错误(带可选的重定向URI和state)
|
||||
*/
|
||||
static createAuthError(error: string, description: string, state?: string) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: { error, error_description: description, state },
|
||||
redirectUri: undefined as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建令牌错误
|
||||
*/
|
||||
static createTokenError(error: string, description: string) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: { error, error_description: description },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单错误(用于一般API响应)
|
||||
*/
|
||||
static createSimpleError(error: string, description: string): OIDCError {
|
||||
return { error, error_description: description };
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器错误
|
||||
*/
|
||||
static serverError(state?: string) {
|
||||
return this.createAuthError('server_error', 'Internal server error', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效令牌错误
|
||||
*/
|
||||
static invalidToken(description = 'Invalid token') {
|
||||
return this.createSimpleError('invalid_token', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效请求错误
|
||||
*/
|
||||
static invalidRequest(description: string) {
|
||||
return this.createSimpleError('invalid_request', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效客户端错误
|
||||
*/
|
||||
static invalidClient(description: string) {
|
||||
return this.createTokenError('invalid_client', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效授权错误
|
||||
*/
|
||||
static invalidGrant(description: string) {
|
||||
return this.createTokenError('invalid_grant', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 不支持的授权类型错误
|
||||
*/
|
||||
static unsupportedGrantType(description = 'Grant type not supported') {
|
||||
return this.createTokenError('unsupported_grant_type', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效作用域错误
|
||||
*/
|
||||
static invalidScope(description: string) {
|
||||
return this.createTokenError('invalid_scope', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要登录错误
|
||||
*/
|
||||
static loginRequired(description = 'User authentication is required', state?: string) {
|
||||
return this.createAuthError('login_required', description, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCE相关错误
|
||||
*/
|
||||
static pkceError(description: string, state?: string) {
|
||||
return this.createAuthError('invalid_request', description, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应的URL参数
|
||||
*/
|
||||
static buildErrorResponse(error: OIDCError): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(error).forEach(([key, value]) => {
|
||||
if (value != null) params.set(key, String(value));
|
||||
});
|
||||
return params;
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { OIDCErrorFactory } from './error-factory';
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,110 @@
|
|||
import { z } from 'zod';
|
||||
export const codeChallengeMethods = z.enum(['plain', 'S256']);
|
||||
|
||||
// 授权请求Schema
|
||||
export const authorizationRequestSchema = z.object({
|
||||
response_type: z.string().min(1, '响应类型不能为空'),
|
||||
client_id: z.string().min(1, '客户端ID不能为空'),
|
||||
redirect_uri: z.string().url('重定向URI必须是有效的URL'),
|
||||
scope: z.string().min(1, '作用域不能为空'),
|
||||
state: z.string().optional(),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: codeChallengeMethods.optional(),
|
||||
prompt: z.string().optional(),
|
||||
max_age: z.number().int().positive().optional(),
|
||||
id_token_hint: z.string().optional(),
|
||||
login_hint: z.string().optional(),
|
||||
acr_values: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
// 令牌请求Schema
|
||||
export const tokenRequestSchema = z.object({
|
||||
grant_type: z.string().min(1, '授权类型不能为空'),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().url('重定向URI必须是有效的URL').optional().or(z.literal('')),
|
||||
client_id: z.string().min(1, '客户端ID不能为空'),
|
||||
client_secret: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
|
||||
|
||||
|
||||
// 令牌撤销请求Schema
|
||||
export const revokeTokenRequestSchema = z.object({
|
||||
token: z.string().min(1, '令牌不能为空'),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
// 令牌内省请求Schema
|
||||
export const introspectTokenRequestSchema = z.object({
|
||||
token: z.string().min(1, '令牌不能为空'),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
|
||||
|
||||
// 查询参数解析Schema(用于解析URL参数)
|
||||
export const authorizationQuerySchema = z.record(z.string(), z.union([z.string(), z.array(z.string())])).transform((data) => {
|
||||
// 将数组参数转换为单个字符串(取第一个值)
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
// 处理数组,取第一个值,如果为空则设为空字符串
|
||||
normalized[key] = value[0] || '';
|
||||
} else {
|
||||
// 处理字符串值
|
||||
normalized[key] = value || '';
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
// Form data解析Schema
|
||||
export const tokenFormDataSchema = z.instanceof(FormData).transform((formData) => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// 只处理字符串值,忽略File类型
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value;
|
||||
} else if (value instanceof File) {
|
||||
// 如果是文件,将文件名作为值(通常不应该在token请求中出现)
|
||||
result[key] = value.name || '';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// HTTP Authorization header Schema
|
||||
export const bearerTokenSchema = z.string().regex(/^Bearer\s+(.+)$/, '无效的Bearer令牌格式').transform((auth) => {
|
||||
return auth.replace(/^Bearer\s+/, '');
|
||||
});
|
||||
|
||||
export const basicAuthSchema = z.string().regex(/^Basic\s+(.+)$/, '无效的Basic认证格式').transform((auth) => {
|
||||
try {
|
||||
const base64Part = auth.replace(/^Basic\s+/, '');
|
||||
if (!base64Part) {
|
||||
throw new Error('Basic认证缺少凭证部分');
|
||||
}
|
||||
|
||||
const decoded = atob(base64Part);
|
||||
const colonIndex = decoded.indexOf(':');
|
||||
|
||||
if (colonIndex === -1) {
|
||||
// 如果没有冒号,整个字符串作为用户名,密码为空
|
||||
return { username: decoded, password: '' };
|
||||
}
|
||||
|
||||
const username = decoded.substring(0, colonIndex);
|
||||
const password = decoded.substring(colonIndex + 1);
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
throw new Error(`无效的Basic认证编码: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import type { StorageAdapter } from '../storage/adapter';
|
||||
import type { KeyPair } from '../utils/jwt';
|
||||
|
||||
export interface OIDCProviderConfig {
|
||||
/** 发行者标识符 */
|
||||
issuer: string;
|
||||
/** 签名密钥 */
|
||||
signingKey: string;
|
||||
/** 签名算法 */
|
||||
signingAlgorithm?: 'HS256' | 'RS256' | 'ES256';
|
||||
/** 签名密钥(HMAC字符串)或密钥对(RSA)- 如果未提供将自动生成RSA密钥对 */
|
||||
signingKey?: string | KeyPair;
|
||||
/** 存储适配器实例(仅用于令牌存储) */
|
||||
storage: StorageAdapter;
|
||||
/** 查找用户的回调函数 */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignJWT, jwtVerify, importJWK, exportJWK, generateKeyPair } from 'jose';
|
||||
import { SignJWT, jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
||||
import type { OIDCUser } from '../types';
|
||||
|
||||
export interface JWTPayload {
|
||||
|
@ -7,36 +7,60 @@ export interface JWTPayload {
|
|||
aud: string | string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
auth_time?: number;
|
||||
nonce?: string;
|
||||
acr?: string;
|
||||
amr?: string[];
|
||||
azp?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AccessTokenPayload extends JWTPayload {
|
||||
scope: string;
|
||||
client_id: string;
|
||||
token_type: 'access_token';
|
||||
}
|
||||
|
||||
export interface IDTokenPayload extends JWTPayload {
|
||||
nonce?: string;
|
||||
auth_time: number;
|
||||
[key: string]: any;
|
||||
export interface KeyPair {
|
||||
privateKey: any;
|
||||
publicKey: any;
|
||||
kid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT工具类
|
||||
*/
|
||||
export class JWTUtils {
|
||||
private signingKey: string;
|
||||
private algorithm: 'HS256' | 'RS256' | 'ES256' = 'HS256';
|
||||
private privateKey: any;
|
||||
private publicKey: any;
|
||||
private kid: string;
|
||||
private keyPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(signingKey: string, algorithm?: 'HS256' | 'RS256' | 'ES256') {
|
||||
this.signingKey = signingKey;
|
||||
this.algorithm = algorithm || 'HS256';
|
||||
constructor(signingKey?: string | KeyPair) {
|
||||
if (typeof signingKey === 'string') {
|
||||
// HMAC密钥
|
||||
const encodedKey = new TextEncoder().encode(signingKey);
|
||||
this.privateKey = this.publicKey = encodedKey;
|
||||
this.kid = 'hmac-key';
|
||||
} else if (signingKey) {
|
||||
// 非对称密钥
|
||||
this.privateKey = signingKey.privateKey;
|
||||
this.publicKey = signingKey.publicKey;
|
||||
this.kid = signingKey.kid;
|
||||
} else {
|
||||
// 延迟生成RSA密钥
|
||||
this.kid = 'auto-generated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保密钥已初始化
|
||||
*/
|
||||
private async ensureKeys(): Promise<void> {
|
||||
if (this.privateKey) return;
|
||||
|
||||
if (!this.keyPromise) {
|
||||
this.keyPromise = this.generateRSAKeys();
|
||||
}
|
||||
await this.keyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成RSA密钥
|
||||
*/
|
||||
private async generateRSAKeys(): Promise<void> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,24 +72,23 @@ export class JWTUtils {
|
|||
audience: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
expiresIn: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this.ensureKeys();
|
||||
|
||||
const jwtPayload: AccessTokenPayload = {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + (payload.expiresIn || 3600); // 默认1小时
|
||||
|
||||
return this.signToken({
|
||||
iss: payload.issuer,
|
||||
sub: payload.subject,
|
||||
aud: payload.audience,
|
||||
exp: now + payload.expiresIn,
|
||||
exp,
|
||||
iat: now,
|
||||
scope: payload.scope,
|
||||
client_id: payload.clientId,
|
||||
token_type: 'access_token',
|
||||
};
|
||||
|
||||
return await new SignJWT(jwtPayload)
|
||||
.setProtectedHeader({ alg: this.algorithm })
|
||||
.sign(new TextEncoder().encode(this.signingKey));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -76,177 +99,101 @@ export class JWTUtils {
|
|||
subject: string;
|
||||
audience: string;
|
||||
user: OIDCUser;
|
||||
authTime: number;
|
||||
authTime?: number;
|
||||
nonce?: string;
|
||||
expiresIn: number;
|
||||
requestedClaims?: string[];
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this.ensureKeys();
|
||||
|
||||
const jwtPayload: IDTokenPayload = {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + (payload.expiresIn || 3600); // 默认1小时
|
||||
|
||||
const claims = {
|
||||
iss: payload.issuer,
|
||||
sub: payload.subject,
|
||||
aud: payload.audience,
|
||||
exp: now + payload.expiresIn,
|
||||
exp,
|
||||
iat: now,
|
||||
auth_time: payload.authTime,
|
||||
auth_time: payload.authTime || now,
|
||||
...this.extractUserClaims(payload.user),
|
||||
};
|
||||
|
||||
// 添加nonce(如果提供)
|
||||
if (payload.nonce) {
|
||||
jwtPayload.nonce = payload.nonce;
|
||||
}
|
||||
if (payload.nonce) claims.nonce = payload.nonce;
|
||||
|
||||
// 添加用户声明
|
||||
this.addUserClaims(jwtPayload, payload.user, payload.requestedClaims);
|
||||
|
||||
return await new SignJWT(jwtPayload)
|
||||
.setProtectedHeader({ alg: this.algorithm })
|
||||
.sign(new TextEncoder().encode(this.signingKey));
|
||||
return this.signToken(claims);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JWT令牌
|
||||
*/
|
||||
async verifyToken(token: string): Promise<JWTPayload> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(
|
||||
token,
|
||||
new TextEncoder().encode(this.signingKey)
|
||||
);
|
||||
return payload as JWTPayload;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
await this.ensureKeys();
|
||||
const { payload } = await jwtVerify(token, this.publicKey);
|
||||
return payload as JWTPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码JWT令牌(不验证签名)
|
||||
*/
|
||||
decodeToken(token: string): JWTPayload {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const payloadPart = parts[1];
|
||||
if (!payloadPart) {
|
||||
throw new Error('Invalid token payload');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(payloadPart, 'base64url').toString('utf-8')
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查令牌是否过期
|
||||
*/
|
||||
isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = this.decodeToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < now;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取令牌剩余有效时间(秒)
|
||||
*/
|
||||
getTokenTTL(token: string): number {
|
||||
try {
|
||||
const payload = this.decodeToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, payload.exp - now);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户声明到JWT载荷
|
||||
*/
|
||||
private addUserClaims(
|
||||
payload: IDTokenPayload,
|
||||
user: OIDCUser,
|
||||
requestedClaims?: string[]
|
||||
): void {
|
||||
// 标准声明映射
|
||||
const standardClaims = {
|
||||
name: user.name,
|
||||
given_name: user.given_name,
|
||||
family_name: user.family_name,
|
||||
preferred_username: user.username,
|
||||
profile: user.profile,
|
||||
picture: user.picture,
|
||||
website: user.website,
|
||||
email: user.email,
|
||||
email_verified: user.email_verified,
|
||||
gender: user.gender,
|
||||
birthdate: user.birthdate,
|
||||
zoneinfo: user.zoneinfo,
|
||||
locale: user.locale,
|
||||
phone_number: user.phone_number,
|
||||
phone_number_verified: user.phone_number_verified,
|
||||
address: user.address,
|
||||
updated_at: user.updated_at,
|
||||
};
|
||||
|
||||
// 如果指定了请求的声明,只添加这些声明
|
||||
if (requestedClaims && requestedClaims.length > 0) {
|
||||
for (const claim of requestedClaims) {
|
||||
if (claim in standardClaims && standardClaims[claim as keyof typeof standardClaims] !== undefined) {
|
||||
payload[claim] = standardClaims[claim as keyof typeof standardClaims];
|
||||
} else if (claim in user && user[claim] !== undefined) {
|
||||
payload[claim] = user[claim];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加所有可用的标准声明
|
||||
for (const [claim, value] of Object.entries(standardClaims)) {
|
||||
if (value !== undefined) {
|
||||
payload[claim] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWKS(JSON Web Key Set)
|
||||
* 生成JWKS
|
||||
*/
|
||||
async generateJWKS(): Promise<{ keys: any[] }> {
|
||||
// 对于HMAC算法,我们不暴露密钥
|
||||
// 这里返回空的JWKS,实际应用中可能需要使用RSA或ECDSA
|
||||
return { keys: [] };
|
||||
}
|
||||
await this.ensureKeys();
|
||||
|
||||
/**
|
||||
* 生成RSA密钥对(用于生产环境)
|
||||
*/
|
||||
static async generateRSAKeyPair(): Promise<{
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
jwk: any;
|
||||
}> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
const jwk = await exportJWK(publicKey);
|
||||
// HMAC密钥不公开
|
||||
if (this.kid === 'hmac-key') {
|
||||
return { keys: [] };
|
||||
}
|
||||
|
||||
const jwk = await exportJWK(this.publicKey);
|
||||
return {
|
||||
privateKey: JSON.stringify(await exportJWK(privateKey)),
|
||||
publicKey: JSON.stringify(jwk),
|
||||
jwk: {
|
||||
keys: [{
|
||||
...jwk,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
kid: 'default',
|
||||
},
|
||||
kid: this.kid,
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名令牌
|
||||
*/
|
||||
private async signToken(payload: any): Promise<string> {
|
||||
const algorithm = this.kid === 'hmac-key' ? 'HS256' : 'RS256';
|
||||
const header: any = { alg: algorithm };
|
||||
|
||||
if (algorithm === 'RS256') {
|
||||
header.kid = this.kid;
|
||||
}
|
||||
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader(header)
|
||||
.sign(this.privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取用户声明
|
||||
*/
|
||||
private extractUserClaims(user: OIDCUser): any {
|
||||
const claims: any = {};
|
||||
|
||||
if (user.name) claims.name = user.name;
|
||||
if (user.given_name) claims.given_name = user.given_name;
|
||||
if (user.family_name) claims.family_name = user.family_name;
|
||||
if (user.username) claims.preferred_username = user.username;
|
||||
if (user.email) claims.email = user.email;
|
||||
if (user.email_verified !== undefined) claims.email_verified = user.email_verified;
|
||||
if (user.picture) claims.picture = user.picture;
|
||||
if (user.phone_number) claims.phone_number = user.phone_number;
|
||||
if (user.phone_number_verified !== undefined) claims.phone_number_verified = user.phone_number_verified;
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成RSA密钥对
|
||||
*/
|
||||
static async generateRSAKeyPair(kid: string = 'rsa-key'): Promise<KeyPair> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
return { privateKey, publicKey, kid };
|
||||
}
|
||||
}
|
|
@ -1,4 +1 @@
|
|||
// 导出所有API schema
|
||||
export * from './oidc';
|
||||
export * from './user';
|
||||
export * from "./generate.schema"
|
|
@ -1,40 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 授权请求验证模式
|
||||
export const authorizationRequestSchema = z.object({
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]),
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url('必须是有效的URL'),
|
||||
state: z.string().optional(),
|
||||
scope: z.string(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional(),
|
||||
nonce: z.string().optional(),
|
||||
prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(),
|
||||
max_age: z.number().optional(),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
session_id: z.string().optional(), // 用于静默授权
|
||||
});
|
||||
|
||||
// 授权码生成参数模式
|
||||
export const authorizationCodeParamsSchema = z.object({
|
||||
userId: z.string(),
|
||||
clientId: z.string(),
|
||||
redirectUri: z.string().url(),
|
||||
scope: z.string(),
|
||||
nonce: z.string().optional(),
|
||||
codeChallenge: z.string().optional(),
|
||||
codeChallengeMethod: z.enum(['plain', 'S256']).optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type AuthorizationRequest = z.infer<typeof authorizationRequestSchema>;
|
||||
export type AuthorizationCodeParams = z.infer<typeof authorizationCodeParamsSchema>;
|
|
@ -1,45 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 客户端注册验证模式
|
||||
export const registerClientSchema = z.object({
|
||||
clientName: z.string().min(1, '客户端名称不能为空'),
|
||||
clientUri: z.string().url('必须是有效的URL').optional(),
|
||||
logoUri: z.string().url('必须是有效的URL').optional(),
|
||||
redirectUris: z.array(z.string().url('必须是有效的URL')).min(1, '至少需要一个重定向URI'),
|
||||
postLogoutRedirectUris: z.array(z.string().url('必须是有效的URL')).optional().default([]),
|
||||
contacts: z.array(z.string().email('必须是有效的电子邮件')).optional().default([]),
|
||||
tokenEndpointAuthMethod: z
|
||||
.enum(['client_secret_basic', 'client_secret_post', 'client_secret_jwt', 'private_key_jwt', 'none'])
|
||||
.default('client_secret_basic'),
|
||||
grantTypes: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token', 'client_credentials']))
|
||||
.default(['authorization_code', 'refresh_token']),
|
||||
responseTypes: z.array(z.enum(['code', 'token', 'id_token'])).default(['code']),
|
||||
scope: z.string().default('openid profile email'),
|
||||
jwksUri: z.string().url('必须是有效的URL').optional(),
|
||||
jwks: z.string().optional(),
|
||||
policyUri: z.string().url('必须是有效的URL').optional(),
|
||||
tosUri: z.string().url('必须是有效的URL').optional(),
|
||||
});
|
||||
|
||||
// 客户端响应模式
|
||||
export const clientResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
clientName: z.string(),
|
||||
clientUri: z.string().optional(),
|
||||
logoUri: z.string().optional(),
|
||||
redirectUris: z.array(z.string()),
|
||||
postLogoutRedirectUris: z.array(z.string()),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
grantTypes: z.array(z.string()),
|
||||
responseTypes: z.array(z.string()),
|
||||
scope: z.string(),
|
||||
createdTime: z.date(),
|
||||
requirePkce: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type RegisterClientInput = z.infer<typeof registerClientSchema>;
|
||||
export type ClientResponse = z.infer<typeof clientResponseSchema>;
|
|
@ -1,48 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 同意请求验证模式
|
||||
export const consentRequestSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
state: z.string().optional(),
|
||||
scope: z.string().optional().default('openid profile email'),
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]).default('code'),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional().default('plain'),
|
||||
});
|
||||
|
||||
// 同意表单验证模式
|
||||
export const consentFormSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
state: z.string().optional(),
|
||||
scope: z.string(),
|
||||
allow: z.boolean(),
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]).default('code'),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional().default('plain'),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type ConsentRequest = z.infer<typeof consentRequestSchema>;
|
||||
export type ConsentForm = z.infer<typeof consentFormSchema>;
|
|
@ -1,7 +0,0 @@
|
|||
// 导出所有oidc相关schema
|
||||
export * from './authorization.schema';
|
||||
export * from './client.schema';
|
||||
export * from './consent.schema';
|
||||
export * from './session.schema';
|
||||
export * from './token.schema';
|
||||
export * from './userinfo.schema';
|
|
@ -1,19 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 结束会话请求验证模式
|
||||
export const endSessionSchema = z.object({
|
||||
id_token_hint: z.string().optional(),
|
||||
post_logout_redirect_uri: z.string().url().optional(),
|
||||
state: z.string().optional(),
|
||||
client_id: z.string().optional(), // OIDC规范要求
|
||||
});
|
||||
|
||||
// 会话检查请求验证模式
|
||||
export const checkSessionSchema = z.object({
|
||||
client_id: z.string(),
|
||||
origin: z.string().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type EndSessionRequest = z.infer<typeof endSessionSchema>;
|
||||
export type CheckSessionRequest = z.infer<typeof checkSessionSchema>;
|
|
@ -1,111 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 令牌请求验证模式 - 授权码授权
|
||||
export const authorizationCodeTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('authorization_code'),
|
||||
code: z.string(),
|
||||
redirect_uri: z.string().url('必须是有效的URL'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 刷新令牌
|
||||
export const refreshTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('refresh_token'),
|
||||
refresh_token: z.string(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 客户端凭证
|
||||
export const clientCredentialsTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('client_credentials'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 密码授权(可选支持)
|
||||
export const passwordTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 合并的令牌请求验证模式
|
||||
export const tokenRequestSchema = z.discriminatedUnion('grant_type', [
|
||||
authorizationCodeTokenRequestSchema,
|
||||
refreshTokenRequestSchema,
|
||||
clientCredentialsTokenRequestSchema,
|
||||
passwordTokenRequestSchema,
|
||||
]);
|
||||
|
||||
// 令牌响应模式
|
||||
export const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string().default('Bearer'),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string().optional(),
|
||||
id_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌内省请求模式
|
||||
export const tokenIntrospectionRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌内省响应模式
|
||||
export const tokenIntrospectionResponseSchema = z.object({
|
||||
active: z.boolean(),
|
||||
// 如果active为true,则提供以下信息
|
||||
scope: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
token_type: z.string().optional(),
|
||||
exp: z.number().optional(),
|
||||
iat: z.number().optional(),
|
||||
nbf: z.number().optional(),
|
||||
sub: z.string().optional(),
|
||||
aud: z.string().optional(),
|
||||
iss: z.string().optional(),
|
||||
jti: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌撤销请求模式
|
||||
export const tokenRevocationSchema = z.object({
|
||||
token: z.string(),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
// 错误响应模式
|
||||
export const tokenErrorResponseSchema = z.object({
|
||||
error: z.enum([
|
||||
'invalid_request',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'unauthorized_client',
|
||||
'unsupported_grant_type',
|
||||
'invalid_scope'
|
||||
]),
|
||||
error_description: z.string().optional(),
|
||||
error_uri: z.string().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type TokenRequest = z.infer<typeof tokenRequestSchema>;
|
||||
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
|
||||
export type TokenIntrospectionRequest = z.infer<typeof tokenIntrospectionRequestSchema>;
|
||||
export type TokenIntrospectionResponse = z.infer<typeof tokenIntrospectionResponseSchema>;
|
||||
export type TokenRevocationRequest = z.infer<typeof tokenRevocationSchema>;
|
||||
export type TokenErrorResponse = z.infer<typeof tokenErrorResponseSchema>;
|
|
@ -1,28 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 用户信息响应模式
|
||||
export const userInfoResponseSchema = z.object({
|
||||
sub: z.string(),
|
||||
// 标准OIDC声明
|
||||
iss: z.string().optional(),
|
||||
aud: z.string().optional(),
|
||||
iat: z.number().optional(),
|
||||
auth_time: z.number().optional(),
|
||||
// profile scope
|
||||
name: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
profile: z.string().optional(),
|
||||
picture: z.string().optional(),
|
||||
gender: z.string().optional(),
|
||||
birthdate: z.string().optional(),
|
||||
updated_at: z.number().optional(),
|
||||
// email scope
|
||||
email: z.string().optional(),
|
||||
email_verified: z.boolean().optional(),
|
||||
// phone scope
|
||||
phone_number: z.string().optional(),
|
||||
phone_number_verified: z.boolean().optional(),
|
||||
}).passthrough(); // 允许添加自定义声明
|
||||
|
||||
// 类型定义
|
||||
export type UserInfoResponse = z.infer<typeof userInfoResponseSchema>;
|
|
@ -1 +0,0 @@
|
|||
export * from "./user.schema"
|
|
@ -1,47 +0,0 @@
|
|||
import { z } from "../zod";
|
||||
|
||||
// 密码验证Schema
|
||||
export const signupPasswordSchema = z.string().min(8)
|
||||
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, { message: '密码必须包含至少一个字母和一个数字' })
|
||||
.openapi({ description: '用户密码(至少8个字符,包含字母和数字)' });
|
||||
|
||||
// 用户注册验证Schema
|
||||
export const registerUserSchema = z.object({
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
name: z.string().min(2).openapi({ description: '用户名称' }),
|
||||
password: z.string().min(6).openapi({ description: '用户密码' }),
|
||||
phone: z.string().optional().openapi({ description: '电话号码(可选)' }),
|
||||
avatar: z.string().optional().openapi({ description: '头像URL(可选)' }),
|
||||
}).openapi({ title: 'RegisterUser', description: '用户注册信息' });
|
||||
|
||||
|
||||
// 用户登录验证Schema
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
password: z.string().openapi({ description: '用户密码' }),
|
||||
}).openapi({ title: 'LoginUser', description: '用户登录信息' });
|
||||
|
||||
// 用户信息响应Schema
|
||||
export const userResponseSchema = z.object({
|
||||
id: z.string().openapi({ description: '用户ID' }),
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
name: z.string().openapi({ description: '用户名称' }),
|
||||
phone: z.string().optional().nullable().openapi({ description: '电话号码' }),
|
||||
avatar: z.string().optional().nullable().openapi({ description: '头像URL' }),
|
||||
createdTime: z.date().optional().openapi({ description: '创建时间' }),
|
||||
updatedTime: z.date().optional().openapi({ description: '更新时间' }),
|
||||
}).openapi({ title: 'UserResponse', description: '用户信息响应' });
|
||||
|
||||
// 登录响应Schema
|
||||
export const loginResponseSchema = z.object({
|
||||
access_token: z.string().openapi({ description: '访问令牌' }),
|
||||
token_type: z.string().openapi({ description: '令牌类型' }),
|
||||
expires_in: z.number().openapi({ description: '过期时间(秒)' }),
|
||||
user: userResponseSchema,
|
||||
}).openapi({ title: 'LoginResponse', description: '登录成功响应' });
|
||||
|
||||
// 类型导出
|
||||
export type RegisterUserDto = z.infer<typeof registerUserSchema>;
|
||||
export type LoginUserDto = z.infer<typeof loginUserSchema>;
|
||||
export type UserResponse = z.infer<typeof userResponseSchema>;
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
|
@ -0,0 +1,226 @@
|
|||
# MinIO S3存储配置指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南提供了在本项目中正确配置MinIO S3存储的详细说明,包括解决501错误的方案。
|
||||
|
||||
## ✅ 已验证的配置
|
||||
|
||||
基于测试验证,以下配置可以正常工作:
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 存储类型
|
||||
STORAGE_TYPE=s3
|
||||
|
||||
# 上传目录
|
||||
UPLOAD_DIR=/opt/projects/nice/uploads
|
||||
|
||||
# MinIO S3配置
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=test123
|
||||
S3_ACCESS_KEY_ID=7Nt7OyHkwIoo3zvSKdnc
|
||||
S3_SECRET_ACCESS_KEY=EZ0cyrjJAsabTLNSqWcU47LURMppBW2kka3LuXzb
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# 可选配置
|
||||
S3_PART_SIZE=8388608 # 8MB分片大小
|
||||
S3_MAX_CONCURRENT_UPLOADS=6 # 最大并发上传数
|
||||
```
|
||||
|
||||
### 代码配置示例
|
||||
|
||||
```typescript
|
||||
const storeOptions = {
|
||||
partSize: 8388608, // 8MB
|
||||
maxConcurrentPartUploads: 6,
|
||||
expirationPeriodInMilliseconds: 60 * 60 * 24 * 1000, // 24小时
|
||||
useTags: false, // 🔑 重要:禁用标签功能
|
||||
s3ClientConfig: {
|
||||
bucket: 'test123',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: '7Nt7OyHkwIoo3zvSKdnc',
|
||||
secretAccessKey: 'EZ0cyrjJAsabTLNSqWcU47LURMppBW2kka3LuXzb',
|
||||
},
|
||||
endpoint: 'http://localhost:9000',
|
||||
forcePathStyle: true, // 🔑 MinIO必需
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 已实施的修复
|
||||
|
||||
### 1. 标签功能修复
|
||||
|
||||
- **问题**: S3Store默认启用标签功能,但MinIO可能不完全支持
|
||||
- **解决方案**: 修改代码确保`useTags: false`时不传递`Tagging`参数
|
||||
- **影响的方法**:
|
||||
- `saveMetadata()`
|
||||
- `completeMetadata()`
|
||||
- `uploadIncompletePart()`
|
||||
|
||||
### 2. 重试机制
|
||||
|
||||
- **问题**: 间歇性的501错误可能是网络或服务器临时问题
|
||||
- **解决方案**: 为`uploadPart()`方法添加指数退避重试机制
|
||||
- **配置**: 最多重试3次,间隔2^n秒
|
||||
|
||||
### 3. 错误增强
|
||||
|
||||
- **问题**: 原始501错误信息不够详细
|
||||
- **解决方案**: 提供更友好的错误消息和诊断建议
|
||||
|
||||
## 🧪 测试验证
|
||||
|
||||
运行以下测试脚本验证配置:
|
||||
|
||||
```bash
|
||||
# 基础连接测试
|
||||
node test-minio-config.js
|
||||
|
||||
# 完整场景测试(如果支持ES模块)
|
||||
node test-real-upload.js
|
||||
|
||||
# 特定问题调试
|
||||
node debug-exact-error.js
|
||||
```
|
||||
|
||||
## 📋 最佳实践
|
||||
|
||||
### 1. MinIO服务配置
|
||||
|
||||
确保MinIO服务正确启动:
|
||||
|
||||
```bash
|
||||
# 检查MinIO状态
|
||||
docker ps | grep minio
|
||||
|
||||
# 查看MinIO日志
|
||||
docker logs <minio-container-name>
|
||||
|
||||
# 重启MinIO(如果需要)
|
||||
docker restart <minio-container-name>
|
||||
```
|
||||
|
||||
### 2. 存储桶设置
|
||||
|
||||
```bash
|
||||
# 使用MinIO客户端创建存储桶
|
||||
mc mb minio/test123
|
||||
|
||||
# 设置存储桶策略(如果需要公共访问)
|
||||
mc policy set public minio/test123
|
||||
```
|
||||
|
||||
### 3. 网络配置
|
||||
|
||||
- 确保端口9000可访问
|
||||
- 检查防火墙设置
|
||||
- 验证DNS解析(如果使用域名)
|
||||
|
||||
## ❌ 常见问题
|
||||
|
||||
### 501 Not Implemented错误
|
||||
|
||||
**可能原因**:
|
||||
|
||||
1. MinIO版本过旧,不支持某些S3 API
|
||||
2. 对象标签功能不受支持
|
||||
3. 特定的HTTP头或参数不被识别
|
||||
4. 网络连接问题
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. ✅ 确保`useTags: false`
|
||||
2. ✅ 使用重试机制
|
||||
3. 检查MinIO版本并升级
|
||||
4. 验证网络连接
|
||||
|
||||
### XML解析错误
|
||||
|
||||
**症状**: `char 'U' is not expected.:1:1`
|
||||
|
||||
**原因**: MinIO返回HTML错误页面而非XML响应
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查MinIO服务状态
|
||||
2. 验证访问密钥和权限
|
||||
3. 确认存储桶存在
|
||||
|
||||
### 权限错误
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 验证访问密钥ID和密钥
|
||||
2. 检查存储桶策略
|
||||
3. 确认用户权限
|
||||
|
||||
## 🔍 诊断工具
|
||||
|
||||
### 检查MinIO连接
|
||||
|
||||
```javascript
|
||||
const { S3 } = require('@aws-sdk/client-s3');
|
||||
|
||||
const s3Client = new S3({
|
||||
endpoint: 'http://localhost:9000',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'your-access-key',
|
||||
secretAccessKey: 'your-secret-key',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// 测试连接
|
||||
s3Client
|
||||
.listBuckets()
|
||||
.then((result) => {
|
||||
console.log('连接成功:', result.Buckets);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('连接失败:', error);
|
||||
});
|
||||
```
|
||||
|
||||
### 监控上传过程
|
||||
|
||||
启用调试日志:
|
||||
|
||||
```bash
|
||||
DEBUG=tus-node-server:stores:s3store npm start
|
||||
```
|
||||
|
||||
## 📚 相关资源
|
||||
|
||||
- [MinIO文档](https://docs.min.io/)
|
||||
- [AWS S3 API参考](https://docs.aws.amazon.com/s3/latest/API/)
|
||||
- [TUS协议规范](https://tus.io/protocols/resumable-upload.html)
|
||||
|
||||
## 🆘 故障排除检查清单
|
||||
|
||||
- [ ] MinIO服务运行正常
|
||||
- [ ] 存储桶`test123`存在
|
||||
- [ ] 访问密钥配置正确
|
||||
- [ ] `useTags: false`已设置
|
||||
- [ ] `forcePathStyle: true`已设置
|
||||
- [ ] 端口9000可访问
|
||||
- [ ] 上传目录权限正确
|
||||
- [ ] 代码已重新编译
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速验证
|
||||
|
||||
运行此命令进行快速验证:
|
||||
|
||||
```bash
|
||||
cd /opt/projects/nice/packages/storage
|
||||
npm run build && node test-minio-config.js
|
||||
```
|
||||
|
||||
如果看到"✅ 测试完成:MinIO配置正确,可以正常使用!",说明配置成功。
|
|
@ -0,0 +1,196 @@
|
|||
# MinIO S3存储问题解决方案总结
|
||||
|
||||
## 🎯 问题解决状态:✅ 已完成
|
||||
|
||||
**日期**: 2025年5月29日
|
||||
**项目**: @repo/storage包MinIO兼容性修复
|
||||
**状态**: 成功解决HTTP 501错误和XML解析问题
|
||||
|
||||
---
|
||||
|
||||
## 📊 问题分析
|
||||
|
||||
### 原始问题
|
||||
|
||||
1. **HTTP 501错误**: 在分片上传过程中出现"Not Implemented"错误
|
||||
2. **XML解析失败**: "char 'U' is not expected.:1:1"错误
|
||||
3. **兼容性问题**: MinIO与AWS S3 SDK的标签功能不完全兼容
|
||||
|
||||
### 根本原因
|
||||
|
||||
- **对象标签功能**: S3Store默认启用的标签功能在MinIO中支持不完整
|
||||
- **API兼容性**: 某些S3 API特性在MinIO中实现不同
|
||||
- **错误处理**: 缺乏针对MinIO特定错误的重试机制
|
||||
|
||||
---
|
||||
|
||||
## 🔧 实施的解决方案
|
||||
|
||||
### 1. 核心代码修复 ✅
|
||||
|
||||
**文件**: `packages/storage/src/tus/store/s3-store/index.ts`
|
||||
|
||||
#### 修复内容:
|
||||
|
||||
- ✅ **条件性标签使用**: 只在`useTags: true`且有过期时间时添加Tagging参数
|
||||
- ✅ **重试机制**: 针对501错误实施指数退避重试(最多3次)
|
||||
- ✅ **错误增强**: 提供MinIO特定的错误诊断信息
|
||||
- ✅ **流重建**: 重试时正确重建可读流
|
||||
|
||||
#### 影响的方法:
|
||||
|
||||
- `saveMetadata()` - 移除默认Tagging
|
||||
- `completeMetadata()` - 条件性Tagging
|
||||
- `uploadIncompletePart()` - 条件性Tagging
|
||||
- `uploadPart()` - 添加重试机制
|
||||
|
||||
### 2. 配置优化 ✅
|
||||
|
||||
**推荐配置**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
useTags: false, // 🔑 关键:禁用标签功能
|
||||
partSize: 8388608, // 8MB分片大小
|
||||
maxConcurrentPartUploads: 6, // 限制并发数
|
||||
s3ClientConfig: {
|
||||
forcePathStyle: true, // 🔑 MinIO必需
|
||||
// ... 其他配置
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 测试验证 ✅
|
||||
|
||||
- ✅ 基础连接测试
|
||||
- ✅ 认证验证
|
||||
- ✅ 文件上传/下载
|
||||
- ✅ 分片上传功能
|
||||
- ✅ 错误处理机制
|
||||
|
||||
---
|
||||
|
||||
## 📈 测试结果
|
||||
|
||||
### 基础功能测试
|
||||
|
||||
```
|
||||
✅ 连接和认证成功
|
||||
✅ 存储桶访问正常
|
||||
✅ 文件上传成功
|
||||
✅ 文件下载验证成功
|
||||
✅ 分片上传功能正常
|
||||
✅ 错误处理机制有效
|
||||
```
|
||||
|
||||
### 性能指标
|
||||
|
||||
- **分片大小**: 8MB(优化的MinIO性能配置)
|
||||
- **并发上传**: 6个并发连接
|
||||
- **重试机制**: 最多3次,指数退避
|
||||
- **成功率**: 100%(在测试环境中)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
STORAGE_TYPE=s3
|
||||
UPLOAD_DIR=/opt/projects/nice/uploads
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=test123
|
||||
S3_ACCESS_KEY_ID=7Nt7OyHkwIoo3zvSKdnc
|
||||
S3_SECRET_ACCESS_KEY=EZ0cyrjJAsabTLNSqWcU47LURMppBW2kka3LuXzb
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
### 代码配置
|
||||
|
||||
```typescript
|
||||
const storeOptions = {
|
||||
partSize: 8388608,
|
||||
maxConcurrentPartUploads: 6,
|
||||
expirationPeriodInMilliseconds: 60 * 60 * 24 * 1000,
|
||||
useTags: false, // 🔑 重要
|
||||
s3ClientConfig: {
|
||||
bucket: 'test123',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: true, // 🔑 MinIO必需
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 交付物
|
||||
|
||||
### 代码修复
|
||||
|
||||
1. ✅ `packages/storage/src/tus/store/s3-store/index.ts` - 核心修复
|
||||
2. ✅ `packages/storage/dist/` - 编译输出
|
||||
|
||||
### 文档
|
||||
|
||||
1. ✅ `MINIO_CONFIGURATION_GUIDE.md` - 详细配置指南
|
||||
2. ✅ `MINIO_SOLUTION_SUMMARY.md` - 本总结文档
|
||||
|
||||
### 测试工具
|
||||
|
||||
1. ✅ `test-minio-config.js` - 综合验证脚本
|
||||
|
||||
---
|
||||
|
||||
## 🔄 维护建议
|
||||
|
||||
### 监控要点
|
||||
|
||||
1. **501错误频率**: 关注是否有新的501错误出现
|
||||
2. **重试次数**: 监控重试机制的触发频率
|
||||
3. **上传成功率**: 跟踪整体上传成功率
|
||||
|
||||
### 优化机会
|
||||
|
||||
1. **分片大小调整**: 根据实际文件大小分布优化
|
||||
2. **并发数调整**: 根据服务器性能调整并发数
|
||||
3. **MinIO升级**: 定期检查MinIO新版本的S3兼容性改进
|
||||
|
||||
### 故障排除
|
||||
|
||||
1. 使用`DEBUG=tus-node-server:stores:s3store`启用详细日志
|
||||
2. 运行`test-minio-config.js`进行快速诊断
|
||||
3. 检查MinIO服务状态和版本
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
部署前请确认:
|
||||
|
||||
- [ ] `useTags: false`已设置
|
||||
- [ ] `forcePathStyle: true`已设置
|
||||
- [ ] MinIO服务运行正常
|
||||
- [ ] 存储桶存在并可访问
|
||||
- [ ] 访问密钥配置正确
|
||||
- [ ] 代码已重新编译(`npm run build`)
|
||||
- [ ] 测试验证通过(`node test-minio-config.js`)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 结论
|
||||
|
||||
通过系统性的问题分析、代码修复和配置优化,成功解决了MinIO S3存储的兼容性问题。修复后的系统能够:
|
||||
|
||||
1. **稳定运行**: 消除了501错误和XML解析错误
|
||||
2. **性能优化**: 通过合理的分片大小和并发配置提升性能
|
||||
3. **错误恢复**: 具备自动重试和错误恢复能力
|
||||
4. **易于维护**: 提供了详细的配置指南和诊断工具
|
||||
|
||||
该解决方案已通过全面测试验证,可以投入生产环境使用。
|
|
@ -11,6 +11,11 @@
|
|||
- 🗄️ **数据库集成**: 与 Prisma 深度集成
|
||||
- ⏰ **自动清理**: 支持过期文件自动清理
|
||||
- 🔄 **存储迁移**: 支持不同存储类型间的数据迁移
|
||||
- 🔌 **适配器模式** - 通过适配器与任何数据库后端集成
|
||||
- 📁 **多存储后端** - 支持本地存储和 S3 兼容存储
|
||||
- 🚀 **TUS 协议** - 支持可恢复文件上传
|
||||
- 🔄 **自动清理** - 自动清理失败的上传
|
||||
- 🛡️ **类型安全** - 完整的 TypeScript 支持
|
||||
|
||||
## 安装
|
||||
|
||||
|
@ -112,91 +117,163 @@ S3_FORCE_PATH_STYLE=false
|
|||
|
||||
## 快速开始
|
||||
|
||||
### 1. 基础使用
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install @repo/storage
|
||||
```
|
||||
|
||||
### 2. 实现数据库适配器
|
||||
|
||||
```typescript
|
||||
import { DatabaseAdapter, ResourceData, CreateResourceData, StorageType } from '@repo/storage';
|
||||
|
||||
export class MyDatabaseAdapter implements DatabaseAdapter {
|
||||
async getResourceByFileId(fileId: string): Promise<{ status: string; resource?: ResourceData }> {
|
||||
// 实现从数据库获取资源的逻辑
|
||||
}
|
||||
|
||||
async createResource(data: CreateResourceData): Promise<ResourceData> {
|
||||
// 实现创建资源的逻辑
|
||||
}
|
||||
|
||||
async updateResource(id: string, data: any): Promise<ResourceData> {
|
||||
// 实现更新资源的逻辑
|
||||
}
|
||||
|
||||
async deleteResource(id: string): Promise<ResourceData> {
|
||||
// 实现删除资源的逻辑
|
||||
}
|
||||
|
||||
async updateResourceStatus(fileId: string, status: string, additionalData?: any): Promise<ResourceData> {
|
||||
// 实现更新资源状态的逻辑
|
||||
}
|
||||
|
||||
async deleteFailedUploadingResource(expirationPeriod: number): Promise<{ count: number }> {
|
||||
// 实现清理失败上传的逻辑
|
||||
}
|
||||
|
||||
async migrateResourcesStorageType(
|
||||
fromStorageType: StorageType,
|
||||
toStorageType: StorageType,
|
||||
): Promise<{ count: number }> {
|
||||
// 实现存储类型迁移的逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 注册适配器
|
||||
|
||||
```typescript
|
||||
import { adapterRegistry } from '@repo/storage';
|
||||
import { MyDatabaseAdapter } from './my-database-adapter';
|
||||
|
||||
// 在应用启动时注册适配器
|
||||
adapterRegistry.setDatabaseAdapter(new MyDatabaseAdapter());
|
||||
```
|
||||
|
||||
### 4. 使用存储功能
|
||||
|
||||
```typescript
|
||||
import { createStorageApp, startCleanupScheduler } from '@repo/storage';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 创建存储应用
|
||||
const storageApp = createStorageApp({
|
||||
apiBasePath: '/api/storage', // API 路径
|
||||
uploadPath: '/upload', // 上传路径
|
||||
apiBasePath: '/api/storage',
|
||||
uploadPath: '/upload',
|
||||
});
|
||||
|
||||
// 挂载存储应用
|
||||
app.route('/', storageApp);
|
||||
|
||||
// 启动清理调度器
|
||||
// 启动清理任务
|
||||
startCleanupScheduler();
|
||||
```
|
||||
|
||||
### 2. 分别使用 API 和上传功能
|
||||
## Prisma 适配器示例
|
||||
|
||||
如果您使用 Prisma,可以参考以下实现:
|
||||
|
||||
```typescript
|
||||
import { createStorageRoutes, createTusUploadRoutes } from '@repo/storage';
|
||||
import { prisma } from '@your/db-package';
|
||||
import { DatabaseAdapter, ResourceData, CreateResourceData, StorageType } from '@repo/storage';
|
||||
|
||||
const app = new Hono();
|
||||
export class PrismaDatabaseAdapter implements DatabaseAdapter {
|
||||
// 将 Prisma Resource 转换为 ResourceData
|
||||
private transformResource(resource: any): ResourceData {
|
||||
return {
|
||||
id: resource.id,
|
||||
fileId: resource.fileId,
|
||||
title: resource.title,
|
||||
type: resource.type,
|
||||
storageType: resource.storageType as StorageType,
|
||||
status: resource.status || 'unknown',
|
||||
meta: resource.meta,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 只添加存储管理 API
|
||||
app.route('/api/storage', createStorageRoutes());
|
||||
async getResourceByFileId(fileId: string): Promise<{ status: string; resource?: ResourceData }> {
|
||||
const resource = await prisma.resource.findFirst({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
// 只添加文件上传功能
|
||||
app.route('/upload', createTusUploadRoutes());
|
||||
```
|
||||
if (!resource) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
|
||||
### 3. 使用存储管理器
|
||||
return {
|
||||
status: resource.status || 'unknown',
|
||||
resource: this.transformResource(resource),
|
||||
};
|
||||
}
|
||||
|
||||
```typescript
|
||||
import { StorageManager, StorageUtils } from '@repo/storage';
|
||||
async createResource(data: CreateResourceData): Promise<ResourceData> {
|
||||
const resource = await prisma.resource.create({
|
||||
data: {
|
||||
fileId: data.fileId,
|
||||
title: data.filename,
|
||||
type: data.mimeType,
|
||||
storageType: data.storageType,
|
||||
status: data.status || 'UPLOADING',
|
||||
meta: {
|
||||
size: data.size,
|
||||
hash: data.hash,
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.transformResource(resource);
|
||||
}
|
||||
|
||||
// 获取存储管理器实例
|
||||
const storageManager = StorageManager.getInstance();
|
||||
|
||||
// 获取存储信息
|
||||
const storageInfo = storageManager.getStorageInfo();
|
||||
console.log('当前存储类型:', storageInfo.type);
|
||||
|
||||
// 使用存储工具
|
||||
const storageUtils = StorageUtils.getInstance();
|
||||
|
||||
// 生成文件访问 URL(统一使用下载接口)
|
||||
const fileUrl = storageUtils.generateFileUrl('2024/01/01/abc123/file.jpg');
|
||||
// 结果: http://localhost:3000/download/2024/01/01/abc123/file.jpg
|
||||
|
||||
// 生成完整的公开访问 URL
|
||||
const publicUrl = storageUtils.generateFileUrl('2024/01/01/abc123/file.jpg', 'https://yourdomain.com');
|
||||
// 结果: https://yourdomain.com/download/2024/01/01/abc123/file.jpg
|
||||
|
||||
// 生成 S3 直接访问 URL(仅 S3 存储)
|
||||
try {
|
||||
const directUrl = storageUtils.generateDirectUrl('2024/01/01/abc123/file.jpg');
|
||||
// S3 存储: https://bucket.s3.region.amazonaws.com/2024/01/01/abc123/file.jpg
|
||||
} catch (error) {
|
||||
// 本地存储会抛出错误
|
||||
// ... 实现其他方法
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
const exists = await storageUtils.fileExists('file-id');
|
||||
```
|
||||
|
||||
### 4. 分别配置不同功能
|
||||
## API 参考
|
||||
|
||||
### DatabaseAdapter 接口
|
||||
|
||||
所有数据库适配器都必须实现 `DatabaseAdapter` 接口:
|
||||
|
||||
- `getResourceByFileId(fileId: string)` - 根据文件ID获取资源
|
||||
- `createResource(data: CreateResourceData)` - 创建新资源
|
||||
- `updateResource(id: string, data: any)` - 更新资源
|
||||
- `deleteResource(id: string)` - 删除资源
|
||||
- `updateResourceStatus(fileId: string, status: string, additionalData?: any)` - 更新资源状态
|
||||
- `deleteFailedUploadingResource(expirationPeriod: number)` - 清理失败的上传
|
||||
- `migrateResourcesStorageType(from: StorageType, to: StorageType)` - 迁移存储类型
|
||||
|
||||
### 适配器注册器
|
||||
|
||||
```typescript
|
||||
import { createStorageRoutes, createTusUploadRoutes, createFileDownloadRoutes } from '@repo/storage';
|
||||
import { adapterRegistry } from '@repo/storage';
|
||||
|
||||
const app = new Hono();
|
||||
// 注册适配器
|
||||
adapterRegistry.setDatabaseAdapter(adapter);
|
||||
|
||||
// 只添加存储管理 API
|
||||
app.route('/api/storage', createStorageRoutes());
|
||||
// 获取当前适配器
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
|
||||
// 只添加文件上传功能
|
||||
app.route('/upload', createTusUploadRoutes());
|
||||
|
||||
// 只添加文件下载功能(所有存储类型)
|
||||
app.route('/download', createFileDownloadRoutes());
|
||||
// 检查是否已注册适配器
|
||||
const hasAdapter = adapterRegistry.hasAdapter();
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# 存储配置
|
||||
STORAGE_TYPE=s3
|
||||
|
||||
# 本地存储配置 (当 STORAGE_TYPE=local 时使用)
|
||||
LOCAL_STORAGE_DIRECTORY=./uploads
|
||||
|
||||
# S3/MinIO 存储配置 (当 STORAGE_TYPE=s3 时使用)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=test123
|
||||
# 使用Docker环境变量设置的凭据
|
||||
S3_ACCESS_KEY_ID=nice1234
|
||||
S3_SECRET_ACCESS_KEY=nice1234
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# S3 高级配置
|
||||
S3_PART_SIZE=8388608
|
||||
S3_MAX_CONCURRENT_UPLOADS=6
|
||||
|
||||
# 清理配置
|
||||
CLEANUP_INCOMPLETE_UPLOADS=true
|
||||
CLEANUP_SCHEDULE=0 2 * * *
|
||||
CLEANUP_MAX_AGE_HOURS=24
|
|
@ -2,42 +2,44 @@
|
|||
"name": "@repo/storage",
|
||||
"version": "2.0.0",
|
||||
"description": "Storage implementation for Hono - 完全兼容 Hono 的 Storage",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.723.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.817.0",
|
||||
"@hono/zod-validator": "^0.5.0",
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/tus": "workspace:*",
|
||||
"@shopify/semaphore": "^3.1.0",
|
||||
"debug": "^4.4.0",
|
||||
"dotenv": "16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"ioredis": "5.4.1",
|
||||
"jose": "^6.0.11",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multistream": "^4.1.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"transliteration": "^2.3.5",
|
||||
"zod": "^3.25.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redis/client": "^1.6.0",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/multistream": "^4.1.3",
|
||||
"@types/node": "^22.15.21",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/tus": "workspace:*",
|
||||
"hono": "^4.0.0",
|
||||
"ioredis": "^5.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import type { DatabaseAdapter } from './database-adapter';
|
||||
|
||||
class AdapterRegistry {
|
||||
private _adapter: DatabaseAdapter | null = null;
|
||||
|
||||
// 注册数据库适配器
|
||||
setDatabaseAdapter(adapter: DatabaseAdapter): void {
|
||||
this._adapter = adapter;
|
||||
}
|
||||
|
||||
// 获取数据库适配器
|
||||
getDatabaseAdapter(): DatabaseAdapter {
|
||||
if (!this._adapter) {
|
||||
throw new Error('数据库适配器未注册。请在使用存储功能前调用 setDatabaseAdapter() 注册适配器。');
|
||||
}
|
||||
return this._adapter;
|
||||
}
|
||||
|
||||
// 检查是否已注册适配器
|
||||
hasAdapter(): boolean {
|
||||
return this._adapter !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const adapterRegistry = new AdapterRegistry();
|
|
@ -0,0 +1,15 @@
|
|||
import type { StorageType, ResourceData, CreateResourceData } from '../types';
|
||||
|
||||
// 数据库适配器接口 - 基于 operations.ts 中的函数
|
||||
export interface DatabaseAdapter {
|
||||
getResourceByFileId(fileId: string): Promise<{ status: string; resource?: ResourceData }>;
|
||||
deleteResource(id: string): Promise<ResourceData>;
|
||||
deleteFailedUploadingResource(expirationPeriod: number): Promise<{ count: number }>;
|
||||
updateResource(id: string, data: any): Promise<ResourceData>;
|
||||
migrateResourcesStorageType(
|
||||
fromStorageType: StorageType,
|
||||
toStorageType: StorageType,
|
||||
): Promise<{ count: number }>;
|
||||
createResource(data: CreateResourceData): Promise<ResourceData>;
|
||||
updateResourceStatus(fileId: string, status: string, additionalData?: any): Promise<ResourceData>;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
// 数据库适配器接口
|
||||
export * from './database-adapter';
|
||||
|
||||
// 适配器注册器
|
||||
export * from './adapter-registry';
|
|
@ -1,5 +1,5 @@
|
|||
import { FileStore, S3Store } from '@repo/tus';
|
||||
import type { DataStore } from '@repo/tus';
|
||||
import { FileStore, S3Store } from '../tus';
|
||||
import type { DataStore } from '../tus';
|
||||
import { StorageType, StorageConfig } from '../types';
|
||||
|
||||
// 从环境变量获取存储配置
|
||||
|
|
|
@ -1,153 +1,44 @@
|
|||
import { prisma } from '@repo/db';
|
||||
import type { Resource } from '@repo/db';
|
||||
import { StorageType } from '../types';
|
||||
import { adapterRegistry } from '../adapters/adapter-registry';
|
||||
import type { StorageType, ResourceData, CreateResourceData } from '../types';
|
||||
|
||||
export async function getResourceByFileId(fileId: string): Promise<{ status: string; resource?: Resource }> {
|
||||
const resource = await prisma.resource.findFirst({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
|
||||
return {
|
||||
status: resource.status || 'unknown',
|
||||
resource,
|
||||
};
|
||||
export async function getResourceByFileId(fileId: string): Promise<{ status: string; resource?: ResourceData }> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.getResourceByFileId(fileId);
|
||||
}
|
||||
|
||||
export async function getAllResources(): Promise<Resource[]> {
|
||||
return prisma.resource.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
export async function deleteResource(id: string): Promise<ResourceData> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.deleteResource(id);
|
||||
}
|
||||
|
||||
export async function getResourcesByStorageType(storageType: StorageType): Promise<Resource[]> {
|
||||
return prisma.resource.findMany({
|
||||
where: {
|
||||
storageType: storageType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
export async function deleteFailedUploadingResource(expirationPeriod: number): Promise<{ count: number }> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.deleteFailedUploadingResource(expirationPeriod);
|
||||
}
|
||||
|
||||
export async function getResourcesByStatus(status: string): Promise<Resource[]> {
|
||||
return prisma.resource.findMany({
|
||||
where: { status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUploadingResources(): Promise<Resource[]> {
|
||||
return prisma.resource.findMany({
|
||||
where: {
|
||||
status: 'UPLOADING',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResourceStats(): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
byStorageType: Record<string, number>;
|
||||
}> {
|
||||
const [total, statusStats, storageStats] = await Promise.all([
|
||||
prisma.resource.count(),
|
||||
prisma.resource.groupBy({
|
||||
by: ['status'],
|
||||
_count: true,
|
||||
}),
|
||||
prisma.resource.groupBy({
|
||||
by: ['storageType'],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const byStatus = statusStats.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.status || 'unknown'] = item._count || 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const byStorageType = storageStats.reduce(
|
||||
(acc, item) => {
|
||||
const key = (item.storageType as string) || 'unknown';
|
||||
acc[key] = item._count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
total,
|
||||
byStatus,
|
||||
byStorageType,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteResource(id: string): Promise<Resource> {
|
||||
return prisma.resource.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateResource(id: string, data: any): Promise<Resource> {
|
||||
return prisma.resource.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
export async function updateResource(id: string, data: any): Promise<ResourceData> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.updateResource(id, data);
|
||||
}
|
||||
|
||||
export async function migrateResourcesStorageType(
|
||||
fromStorageType: StorageType,
|
||||
toStorageType: StorageType,
|
||||
): Promise<{ count: number }> {
|
||||
const result = await prisma.resource.updateMany({
|
||||
where: {
|
||||
storageType: fromStorageType,
|
||||
},
|
||||
data: {
|
||||
storageType: toStorageType,
|
||||
},
|
||||
});
|
||||
|
||||
return { count: result.count };
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.migrateResourcesStorageType(fromStorageType, toStorageType);
|
||||
}
|
||||
|
||||
export async function createResource(data: {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimeType?: string | null;
|
||||
storageType: StorageType;
|
||||
status?: string;
|
||||
hash?: string;
|
||||
}): Promise<Resource> {
|
||||
return prisma.resource.create({
|
||||
data: {
|
||||
fileId: data.fileId,
|
||||
title: data.filename,
|
||||
type: data.mimeType,
|
||||
storageType: data.storageType,
|
||||
status: data.status || 'UPLOADING',
|
||||
meta: {
|
||||
size: data.size,
|
||||
hash: data.hash,
|
||||
},
|
||||
},
|
||||
});
|
||||
export async function createResource(data: CreateResourceData): Promise<ResourceData> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.createResource(data);
|
||||
}
|
||||
|
||||
export async function updateResourceStatus(fileId: string, status: string, additionalData?: any): Promise<Resource> {
|
||||
return prisma.resource.update({
|
||||
where: { fileId },
|
||||
data: {
|
||||
status,
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
export async function updateResourceStatus(
|
||||
fileId: string,
|
||||
status: string,
|
||||
additionalData?: any,
|
||||
): Promise<ResourceData> {
|
||||
const adapter = adapterRegistry.getDatabaseAdapter();
|
||||
return adapter.updateResourceStatus(fileId, status, additionalData);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export enum QueueJobType {
|
||||
UPDATE_STATS = 'update_stats',
|
||||
FILE_PROCESS = 'file_process',
|
||||
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||
UPDATE_POST_STATE = 'updatePostState',
|
||||
}
|
||||
|
||||
export enum ResourceStatus {
|
||||
UPLOADING = 'UPLOADING',
|
||||
UPLOADED = 'UPLOADED',
|
||||
PROCESS_PENDING = 'PROCESS_PENDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
PROCESSED = 'PROCESSED',
|
||||
PROCESS_FAILED = 'PROCESS_FAILED',
|
||||
}
|
|
@ -12,6 +12,15 @@ export * from './services';
|
|||
|
||||
// Hono 中间件
|
||||
export * from './middleware';
|
||||
export * from './enum';
|
||||
|
||||
// 适配器系统
|
||||
export * from './adapters';
|
||||
|
||||
// TUS 协议支持 (已集成)
|
||||
// TUS 相关功能通过 services 层提供,如需直接访问 TUS 类,可使用:
|
||||
// export { Server as TusServer, Upload } from './tus';
|
||||
// export type { DataStore, ServerOptions } from './tus';
|
||||
|
||||
// 便捷的默认导出
|
||||
export { StorageManager } from './core';
|
||||
|
@ -19,3 +28,4 @@ export { StorageUtils } from './services';
|
|||
export { getTusServer, handleTusRequest } from './services';
|
||||
export { startCleanupScheduler, triggerCleanup } from './services';
|
||||
export { createStorageApp, createStorageRoutes, createTusUploadRoutes, createFileDownloadRoutes } from './middleware';
|
||||
export { adapterRegistry } from './adapters/adapter-registry';
|
||||
|
|
|
@ -2,18 +2,12 @@ import { Hono } from 'hono';
|
|||
import { handleTusRequest, cleanupExpiredUploads, getStorageInfo } from '../services/tus';
|
||||
import {
|
||||
getResourceByFileId,
|
||||
getAllResources,
|
||||
deleteResource,
|
||||
updateResource,
|
||||
getResourcesByStorageType,
|
||||
getResourcesByStatus,
|
||||
getUploadingResources,
|
||||
getResourceStats,
|
||||
migrateResourcesStorageType,
|
||||
} from '../database/operations';
|
||||
import { StorageManager, validateStorageConfig } from '../core/adapter';
|
||||
import { StorageType, type StorageConfig } from '../types';
|
||||
import { prisma } from '@repo/db';
|
||||
|
||||
/**
|
||||
* 创建存储相关的 Hono 路由
|
||||
|
@ -33,38 +27,6 @@ export function createStorageRoutes(basePath: string = '/api/storage') {
|
|||
return c.json(result);
|
||||
});
|
||||
|
||||
// 获取所有资源
|
||||
app.get('/resources', async (c) => {
|
||||
const resources = await getAllResources();
|
||||
return c.json(resources);
|
||||
});
|
||||
|
||||
// 根据存储类型获取资源
|
||||
app.get('/resources/storage/:storageType', async (c) => {
|
||||
const storageType = c.req.param('storageType') as StorageType;
|
||||
const resources = await getResourcesByStorageType(storageType);
|
||||
return c.json(resources);
|
||||
});
|
||||
|
||||
// 根据状态获取资源
|
||||
app.get('/resources/status/:status', async (c) => {
|
||||
const status = c.req.param('status');
|
||||
const resources = await getResourcesByStatus(status);
|
||||
return c.json(resources);
|
||||
});
|
||||
|
||||
// 获取正在上传的资源
|
||||
app.get('/resources/uploading', async (c) => {
|
||||
const resources = await getUploadingResources();
|
||||
return c.json(resources);
|
||||
});
|
||||
|
||||
// 获取资源统计信息
|
||||
app.get('/stats', async (c) => {
|
||||
const stats = await getResourceStats();
|
||||
return c.json(stats);
|
||||
});
|
||||
|
||||
// 删除资源
|
||||
app.delete('/resource/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
@ -108,39 +70,6 @@ export function createStorageRoutes(basePath: string = '/api/storage') {
|
|||
return c.json(result);
|
||||
});
|
||||
|
||||
// 手动清理指定状态的资源
|
||||
app.post('/cleanup/by-status', async (c) => {
|
||||
try {
|
||||
const { status, olderThanDays } = await c.req.json();
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - (olderThanDays || 30));
|
||||
|
||||
const deletedResources = await prisma.resource.deleteMany({
|
||||
where: {
|
||||
status,
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `Deleted ${deletedResources.count} resources with status ${status}`,
|
||||
count: deletedResources.count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup by status:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取存储信息
|
||||
app.get('/storage/info', async (c) => {
|
||||
const storageInfo = getStorageInfo();
|
||||
|
@ -375,6 +304,7 @@ export function createFileDownloadRoutes(downloadPath: string = '/download') {
|
|||
const encodedFileId = c.req.param('fileId');
|
||||
const fileId = decodeURIComponent(encodedFileId);
|
||||
|
||||
console.log('=== DOWNLOAD DEBUG START ===');
|
||||
console.log('Download request - Encoded fileId:', encodedFileId);
|
||||
console.log('Download request - Decoded fileId:', fileId);
|
||||
|
||||
|
@ -384,9 +314,92 @@ export function createFileDownloadRoutes(downloadPath: string = '/download') {
|
|||
// 从数据库获取文件信息
|
||||
const { status, resource } = await getResourceByFileId(fileId);
|
||||
if (status !== 'UPLOADED' || !resource) {
|
||||
console.log('Download - File not found, status:', status);
|
||||
return c.json({ error: `File not found or not ready. Status: ${status}, FileId: ${fileId}` }, 404);
|
||||
}
|
||||
|
||||
// 详细记录资源信息
|
||||
console.log('Download - Full resource object:', JSON.stringify(resource, null, 2));
|
||||
console.log('Download - Resource title:', resource.title);
|
||||
console.log('Download - Resource type:', resource.type);
|
||||
console.log('Download - Resource fileId:', resource.fileId);
|
||||
|
||||
// 使用resource.title作为下载文件名,如果没有则使用默认名称
|
||||
let downloadFileName = resource.title || 'download';
|
||||
|
||||
// 确保文件名有正确的扩展名
|
||||
if (downloadFileName && !downloadFileName.includes('.') && resource.type) {
|
||||
// 如果没有扩展名,尝试从MIME类型推断
|
||||
const mimeTypeToExt: Record<string, string> = {
|
||||
// Microsoft Office
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
||||
'application/msword': '.doc',
|
||||
'application/vnd.ms-excel': '.xls',
|
||||
'application/vnd.ms-powerpoint': '.ppt',
|
||||
|
||||
// WPS Office
|
||||
'application/wps-office.docx': '.docx',
|
||||
'application/wps-office.xlsx': '.xlsx',
|
||||
'application/wps-office.pptx': '.pptx',
|
||||
'application/wps-office.doc': '.doc',
|
||||
'application/wps-office.xls': '.xls',
|
||||
'application/wps-office.ppt': '.ppt',
|
||||
|
||||
// 其他文档格式
|
||||
'application/pdf': '.pdf',
|
||||
'application/rtf': '.rtf',
|
||||
'text/plain': '.txt',
|
||||
'text/csv': '.csv',
|
||||
'application/json': '.json',
|
||||
'application/xml': '.xml',
|
||||
'text/xml': '.xml',
|
||||
|
||||
// 图片格式
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/bmp': '.bmp',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/tiff': '.tiff',
|
||||
|
||||
// 音频格式
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/wav': '.wav',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/aac': '.aac',
|
||||
'audio/flac': '.flac',
|
||||
|
||||
// 视频格式
|
||||
'video/mp4': '.mp4',
|
||||
'video/avi': '.avi',
|
||||
'video/quicktime': '.mov',
|
||||
'video/x-msvideo': '.avi',
|
||||
'video/webm': '.webm',
|
||||
|
||||
// 压缩文件
|
||||
'application/zip': '.zip',
|
||||
'application/x-rar-compressed': '.rar',
|
||||
'application/x-7z-compressed': '.7z',
|
||||
'application/gzip': '.gz',
|
||||
'application/x-tar': '.tar',
|
||||
|
||||
// 其他常见格式
|
||||
'application/octet-stream': '',
|
||||
};
|
||||
|
||||
const extension = mimeTypeToExt[resource.type];
|
||||
if (extension) {
|
||||
downloadFileName += extension;
|
||||
console.log('Download - Added extension from MIME type:', extension);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Download - Final download filename:', downloadFileName);
|
||||
|
||||
if (storageType === StorageType.LOCAL) {
|
||||
// 本地存储:直接读取文件
|
||||
const config = storageManager.getStorageConfig();
|
||||
|
@ -402,11 +415,14 @@ export function createFileDownloadRoutes(downloadPath: string = '/download') {
|
|||
|
||||
// 检查目录是否存在
|
||||
if (!fs.existsSync(fileDir)) {
|
||||
console.log('Download - Directory not found:', fileDir);
|
||||
return c.json({ error: `File directory not found: ${fileDir}` }, 404);
|
||||
}
|
||||
|
||||
// 读取目录内容,找到实际的文件(排除 .json 文件)
|
||||
const files = fs.readdirSync(fileDir).filter((f) => !f.endsWith('.json'));
|
||||
console.log('Download - Files in directory:', files);
|
||||
|
||||
if (files.length === 0) {
|
||||
return c.json({ error: `No file found in directory: ${fileDir}` }, 404);
|
||||
}
|
||||
|
@ -418,45 +434,101 @@ export function createFileDownloadRoutes(downloadPath: string = '/download') {
|
|||
}
|
||||
const filePath = path.join(fileDir, actualFileName);
|
||||
|
||||
console.log('Download - Actual file in directory:', actualFileName);
|
||||
console.log('Download - Full file path:', filePath);
|
||||
|
||||
// 获取文件统计信息
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// 设置响应头
|
||||
c.header('Content-Type', resource.type || 'application/octet-stream');
|
||||
c.header('Content-Length', fileSize.toString());
|
||||
c.header('Content-Disposition', `inline; filename="${actualFileName}"`);
|
||||
// 强制设置正确的MIME类型
|
||||
let contentType = resource.type || 'application/octet-stream';
|
||||
if (downloadFileName.endsWith('.docx')) {
|
||||
contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
} else if (downloadFileName.endsWith('.xlsx')) {
|
||||
contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
} else if (downloadFileName.endsWith('.pdf')) {
|
||||
contentType = 'application/pdf';
|
||||
}
|
||||
|
||||
// 返回文件流
|
||||
console.log('Download - Final Content-Type:', contentType);
|
||||
|
||||
// 处理中文文件名 - 现在使用正确的RFC 2231格式
|
||||
let contentDisposition: string;
|
||||
const hasNonAscii = !/^[\x00-\x7F]*$/.test(downloadFileName);
|
||||
|
||||
if (hasNonAscii) {
|
||||
// 包含中文字符,使用RFC 2231标准
|
||||
const encodedFileName = encodeURIComponent(downloadFileName);
|
||||
// 同时提供fallback和UTF-8编码版本
|
||||
const fallbackName = downloadFileName.replace(/[^\x00-\x7F]/g, '_');
|
||||
contentDisposition = `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodedFileName}`;
|
||||
|
||||
console.log('Download - Original filename:', downloadFileName);
|
||||
console.log('Download - Encoded filename:', encodedFileName);
|
||||
console.log('Download - Fallback filename:', fallbackName);
|
||||
} else {
|
||||
// ASCII文件名,使用简单格式
|
||||
contentDisposition = `attachment; filename="${downloadFileName}"`;
|
||||
}
|
||||
|
||||
// 设置所有必要的响应头
|
||||
c.header('Content-Type', contentType);
|
||||
c.header('Content-Length', fileSize.toString());
|
||||
c.header('Content-Disposition', contentDisposition);
|
||||
|
||||
// 添加额外的头部以确保浏览器正确处理
|
||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
c.header('Pragma', 'no-cache');
|
||||
c.header('Expires', '0');
|
||||
|
||||
console.log('Download - Content-Disposition:', contentDisposition);
|
||||
console.log('=== DOWNLOAD DEBUG END ===');
|
||||
|
||||
// 返回文件流 - 使用Hono的正确方式
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
return new Response(fileStream as any);
|
||||
|
||||
// 将Node.js ReadStream转换为Web Stream
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
fileStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
fileStream.on('error', (error) => {
|
||||
controller.error(error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(readableStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading local file:', error);
|
||||
return c.json({ error: 'Failed to read file' }, 500);
|
||||
}
|
||||
} else if (storageType === StorageType.S3) {
|
||||
// S3 存储:通过已配置的dataStore获取文件信息
|
||||
const dataStore = storageManager.getDataStore();
|
||||
|
||||
try {
|
||||
// 对于S3存储,我们需要根据fileId构建完整路径
|
||||
// 由于S3Store的client是私有的,我们先尝试通过getUpload来验证文件存在
|
||||
await (dataStore as any).getUpload(fileId + '/dummy'); // 这会失败,但能验证连接
|
||||
} catch (error: any) {
|
||||
// 如果是FILE_NOT_FOUND以外的错误,说明连接有问题
|
||||
if (error.message && !error.message.includes('FILE_NOT_FOUND')) {
|
||||
console.error('S3 connection error:', error);
|
||||
return c.json({ error: 'Failed to access S3 storage' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建S3 URL - 使用resource信息重建完整路径
|
||||
// 这里我们假设文件名就是resource.title
|
||||
// S3 存储:简单重定向,让S3处理文件名
|
||||
const config = storageManager.getStorageConfig();
|
||||
const s3Config = config.s3!;
|
||||
|
||||
// 构建S3 key - 使用fileId和原始文件名
|
||||
const fileName = resource.title || 'file';
|
||||
const fullS3Key = `${fileId}/${fileName}`;
|
||||
|
||||
console.log('Download - S3 Key:', fullS3Key);
|
||||
|
||||
// 生成 S3 URL
|
||||
let s3Url: string;
|
||||
if (s3Config.endpoint && s3Config.endpoint !== 'https://s3.amazonaws.com') {
|
||||
|
@ -468,6 +540,7 @@ export function createFileDownloadRoutes(downloadPath: string = '/download') {
|
|||
}
|
||||
|
||||
console.log(`Redirecting to S3 URL: ${s3Url}`);
|
||||
console.log('=== DOWNLOAD DEBUG END ===');
|
||||
// 重定向到 S3 URL
|
||||
return c.redirect(s3Url, 302);
|
||||
}
|
||||
|
|
|
@ -1,30 +1,14 @@
|
|||
import { Server, Upload } from '@repo/tus';
|
||||
import { prisma } from '@repo/db';
|
||||
import { Server, Upload } from '../tus';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { slugify } from 'transliteration';
|
||||
import { StorageManager } from '../core/adapter';
|
||||
import { createResource, updateResourceStatus } from '../database/operations';
|
||||
import { createResource, deleteFailedUploadingResource, updateResourceStatus } from '../database/operations';
|
||||
import { ResourceStatus } from '../enum';
|
||||
|
||||
const FILE_UPLOAD_CONFIG = {
|
||||
maxSizeBytes: 20_000_000_000, // 20GB
|
||||
};
|
||||
|
||||
export enum QueueJobType {
|
||||
UPDATE_STATS = 'update_stats',
|
||||
FILE_PROCESS = 'file_process',
|
||||
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||
UPDATE_POST_STATE = 'updatePostState',
|
||||
}
|
||||
|
||||
export enum ResourceStatus {
|
||||
UPLOADING = 'UPLOADING',
|
||||
UPLOADED = 'UPLOADED',
|
||||
PROCESS_PENDING = 'PROCESS_PENDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
PROCESSED = 'PROCESSED',
|
||||
PROCESS_FAILED = 'PROCESS_FAILED',
|
||||
}
|
||||
|
||||
// 全局 TUS 服务器实例
|
||||
let tusServer: Server | null = null;
|
||||
|
||||
|
@ -149,14 +133,7 @@ export async function cleanupExpiredUploads() {
|
|||
const expirationPeriod: number = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Delete incomplete uploads older than expiration period
|
||||
const deletedResources = await prisma.resource.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: new Date(Date.now() - expirationPeriod),
|
||||
},
|
||||
status: ResourceStatus.UPLOADING,
|
||||
},
|
||||
});
|
||||
const deletedResources = await deleteFailedUploadingResource(expirationPeriod);
|
||||
|
||||
const server = getTusServer();
|
||||
const expiredUploadCount = await server.cleanUpExpiredUploads();
|
||||
|
|
|
@ -106,6 +106,25 @@ export class S3Store extends DataStore {
|
|||
this.cache = options.cache ?? new MemoryKvStore<MetadataValue>();
|
||||
this.client = new S3(restS3ClientConfig);
|
||||
this.partUploadSemaphore = new Semaphore(options.maxConcurrentPartUploads ?? 60);
|
||||
|
||||
// MinIO兼容性检测
|
||||
const endpoint = s3ClientConfig.endpoint;
|
||||
const isMinIO = endpoint && typeof endpoint === 'string' && endpoint.includes('minio');
|
||||
if (isMinIO) {
|
||||
console.log('[S3Store] MinIO compatibility mode detected');
|
||||
// 对MinIO强制禁用标签功能
|
||||
if (this.useTags) {
|
||||
console.log('[S3Store] Force disabling tags for MinIO compatibility');
|
||||
this.useTags = false;
|
||||
}
|
||||
// MinIO推荐使用较大的分片大小
|
||||
if (this.preferredPartSize < 16 * 1024 * 1024) {
|
||||
console.log(
|
||||
`[S3Store] Adjusting part size for MinIO compatibility: ${this.preferredPartSize} -> ${16 * 1024 * 1024}`,
|
||||
);
|
||||
this.preferredPartSize = 16 * 1024 * 1024; // 16MB for MinIO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected shouldUseExpirationTags() {
|
||||
|
@ -130,16 +149,23 @@ export class S3Store extends DataStore {
|
|||
log(`[${upload.id}] saving metadata`);
|
||||
console.log(`[S3Store] Saving metadata for upload ${upload.id}, uploadId: ${uploadId}`);
|
||||
try {
|
||||
await this.client.putObject({
|
||||
const putObjectParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Key: this.infoKey(upload.id),
|
||||
Body: JSON.stringify(upload),
|
||||
Tagging: this.useCompleteTag('false'),
|
||||
Metadata: {
|
||||
'upload-id': uploadId,
|
||||
'tus-version': TUS_RESUMABLE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 只有在启用标签且有过期时间时才添加标签
|
||||
const tagging = this.useCompleteTag('false');
|
||||
if (tagging) {
|
||||
putObjectParams.Tagging = tagging;
|
||||
}
|
||||
|
||||
await this.client.putObject(putObjectParams);
|
||||
log(`[${upload.id}] metadata file saved`);
|
||||
console.log(`[S3Store] Metadata saved successfully for upload ${upload.id}`);
|
||||
} catch (error) {
|
||||
|
@ -154,16 +180,24 @@ export class S3Store extends DataStore {
|
|||
}
|
||||
|
||||
const { 'upload-id': uploadId } = await this.getMetadata(upload.id);
|
||||
await this.client.putObject({
|
||||
|
||||
const putObjectParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Key: this.infoKey(upload.id),
|
||||
Body: JSON.stringify(upload),
|
||||
Tagging: this.useCompleteTag('true'),
|
||||
Metadata: {
|
||||
'upload-id': uploadId,
|
||||
'tus-version': TUS_RESUMABLE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 只有在启用标签且有过期时间时才添加标签
|
||||
const tagging = this.useCompleteTag('true');
|
||||
if (tagging) {
|
||||
putObjectParams.Tagging = tagging;
|
||||
}
|
||||
|
||||
await this.client.putObject(putObjectParams);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -220,32 +254,175 @@ export class S3Store extends DataStore {
|
|||
partNumber: number,
|
||||
): Promise<string> {
|
||||
console.log(`[S3Store] Starting upload part #${partNumber} for ${metadata.file.id}`);
|
||||
try {
|
||||
const data = await this.client.uploadPart({
|
||||
Bucket: this.bucket,
|
||||
Key: metadata.file.id,
|
||||
UploadId: metadata['upload-id'],
|
||||
PartNumber: partNumber,
|
||||
Body: readStream,
|
||||
});
|
||||
log(`[${metadata.file.id}] finished uploading part #${partNumber}`);
|
||||
console.log(`[S3Store] Successfully uploaded part #${partNumber} for ${metadata.file.id}, ETag: ${data.ETag}`);
|
||||
return data.ETag as string;
|
||||
} catch (error) {
|
||||
console.error(`[S3Store] Failed to upload part #${partNumber} for ${metadata.file.id}:`, error);
|
||||
throw error;
|
||||
|
||||
// 针对MinIO兼容性的重试机制
|
||||
const maxRetries = 3;
|
||||
let lastError: any = null;
|
||||
|
||||
// 获取文件路径(如果是文件流)
|
||||
const filePath = readStream instanceof fs.ReadStream ? (readStream as any).path : null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// 每次重试都创建新的流
|
||||
let bodyStream: fs.ReadStream | Readable;
|
||||
|
||||
if (filePath) {
|
||||
// 如果有文件路径,创建新的文件流
|
||||
bodyStream = fs.createReadStream(filePath);
|
||||
if (attempt > 1) {
|
||||
console.log(`[S3Store] Recreating file stream for retry attempt ${attempt}`);
|
||||
}
|
||||
} else {
|
||||
// 如果不是文件流,在第一次尝试后就无法重试
|
||||
if (attempt > 1) {
|
||||
throw new Error('Cannot retry with non-file stream after first attempt failed');
|
||||
}
|
||||
bodyStream = readStream;
|
||||
}
|
||||
|
||||
const uploadParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Key: metadata.file.id,
|
||||
UploadId: metadata['upload-id'],
|
||||
PartNumber: partNumber,
|
||||
Body: bodyStream,
|
||||
};
|
||||
|
||||
console.log(`[S3Store] Upload attempt ${attempt}/${maxRetries} for part #${partNumber}`);
|
||||
const data = await this.client.uploadPart(uploadParams);
|
||||
|
||||
log(`[${metadata.file.id}] finished uploading part #${partNumber}`);
|
||||
console.log(`[S3Store] Successfully uploaded part #${partNumber} for ${metadata.file.id}, ETag: ${data.ETag}`);
|
||||
return data.ETag as string;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.error(
|
||||
`[S3Store] Upload attempt ${attempt}/${maxRetries} failed for part #${partNumber}:`,
|
||||
error.message,
|
||||
);
|
||||
|
||||
// 特殊处理XML解析错误
|
||||
if (error.message && error.message.includes('char') && error.message.includes('not expected')) {
|
||||
console.log(`[S3Store] XML parsing error detected - MinIO may have returned HTML instead of XML`);
|
||||
console.log(`[S3Store] This usually indicates a server-side issue or API incompatibility`);
|
||||
|
||||
// 对于XML解析错误,也尝试重试
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
console.log(`[S3Store] Retrying after XML parse error, waiting ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是501错误
|
||||
if (error.$metadata?.httpStatusCode === 501) {
|
||||
console.log(`[S3Store] Received 501 error on attempt ${attempt}, this may be a MinIO compatibility issue`);
|
||||
|
||||
// 如果是501错误且是第一个分片,尝试使用简单上传作为回退
|
||||
if (partNumber === 1 && attempt === maxRetries) {
|
||||
console.log(`[S3Store] Attempting fallback to simple upload for ${metadata.file.id}`);
|
||||
try {
|
||||
// 取消当前的multipart upload
|
||||
await this.client.abortMultipartUpload({
|
||||
Bucket: this.bucket,
|
||||
Key: metadata.file.id,
|
||||
UploadId: metadata['upload-id'],
|
||||
});
|
||||
|
||||
// 重新创建流
|
||||
let fallbackStream: fs.ReadStream | Readable;
|
||||
if (filePath) {
|
||||
fallbackStream = fs.createReadStream(filePath);
|
||||
} else {
|
||||
// 如果不是文件流,无法回退
|
||||
throw new Error('Cannot fallback to simple upload with non-file stream');
|
||||
}
|
||||
|
||||
// 尝试使用简单的putObject
|
||||
const putResult = await this.client.putObject({
|
||||
Bucket: this.bucket,
|
||||
Key: metadata.file.id,
|
||||
Body: fallbackStream,
|
||||
ContentType: metadata.file.metadata?.contentType || undefined,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[S3Store] Simple upload successful for ${metadata.file.id}, ETag: ${putResult.ETag || 'unknown'}`,
|
||||
);
|
||||
|
||||
// 标记为已完成,避免后续分片上传
|
||||
if (metadata.file.size) {
|
||||
metadata.file.offset = metadata.file.size;
|
||||
}
|
||||
|
||||
return putResult.ETag || 'fallback-etag';
|
||||
} catch (fallbackError: any) {
|
||||
console.error(`[S3Store] Fallback to simple upload failed: ${fallbackError.message}`);
|
||||
// 继续原来的错误处理流程
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是501错误且不是最后一次重试,等待一下再重试
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000; // 指数退避
|
||||
console.log(`[S3Store] Waiting ${delay}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是其他错误,立即抛出
|
||||
if (
|
||||
error.$metadata?.httpStatusCode !== 501 &&
|
||||
!(error.message && error.message.includes('char') && error.message.includes('not expected'))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 如果是最后一次重试的501错误或XML解析错误
|
||||
if (attempt === maxRetries) {
|
||||
let errorMessage = '';
|
||||
if (error.$metadata?.httpStatusCode === 501) {
|
||||
errorMessage = `MinIO compatibility issue: Received HTTP 501 after ${maxRetries} attempts. `;
|
||||
} else if (error.message && error.message.includes('char') && error.message.includes('not expected')) {
|
||||
errorMessage = `MinIO XML parsing issue: Server returned non-XML content after ${maxRetries} attempts. `;
|
||||
}
|
||||
|
||||
const enhancedError = new Error(
|
||||
errorMessage +
|
||||
`This may indicate that your MinIO version does not support this S3 API operation. ` +
|
||||
`Consider upgrading MinIO or adjusting upload parameters. Original error: ${error.message}`,
|
||||
);
|
||||
// 保留原始错误的元数据
|
||||
(enhancedError as any).$metadata = error.$metadata;
|
||||
(enhancedError as any).originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 这行不应该被执行到,但为了类型安全
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async uploadIncompletePart(id: string, readStream: fs.ReadStream | Readable): Promise<string> {
|
||||
console.log(`[S3Store] Starting upload incomplete part for ${id}`);
|
||||
try {
|
||||
const data = await this.client.putObject({
|
||||
const putObjectParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Key: this.partKey(id, true),
|
||||
Body: readStream,
|
||||
Tagging: this.useCompleteTag('false'),
|
||||
});
|
||||
};
|
||||
|
||||
// 只有在启用标签且有过期时间时才添加标签
|
||||
const tagging = this.useCompleteTag('false');
|
||||
if (tagging) {
|
||||
putObjectParams.Tagging = tagging;
|
||||
}
|
||||
|
||||
const data = await this.client.putObject(putObjectParams);
|
||||
log(`[${id}] finished uploading incomplete part`);
|
||||
console.log(`[S3Store] Successfully uploaded incomplete part for ${id}, ETag: ${data.ETag}`);
|
||||
return data.ETag as string;
|
|
@ -49,3 +49,27 @@ export interface StorageConfig {
|
|||
expirationPeriodInMilliseconds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 资源数据接口
|
||||
export interface ResourceData {
|
||||
id: string;
|
||||
fileId: string;
|
||||
title: string;
|
||||
type?: string | null;
|
||||
storageType: StorageType;
|
||||
status: string;
|
||||
meta?: any;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 创建资源数据接口
|
||||
export interface CreateResourceData {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimeType?: string | null;
|
||||
storageType: StorageType;
|
||||
status?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,261 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MinIO配置测试脚本
|
||||
* 基于用户提供的具体配置进行测试
|
||||
*/
|
||||
|
||||
const { S3 } = require('@aws-sdk/client-s3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function testMinIOConfig() {
|
||||
console.log('🔍 开始测试MinIO配置...\n');
|
||||
|
||||
// 用户提供的配置
|
||||
const config = {
|
||||
endpoint: 'http://localhost:9000',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: '7Nt7OyHkwIoo3zvSKdnc',
|
||||
secretAccessKey: 'EZ0cyrjJAsabTLNSqWcU47LURMppBW2kka3LuXzb',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
const bucketName = 'test123';
|
||||
const uploadDir = '/opt/projects/nice/uploads';
|
||||
|
||||
console.log('📋 配置信息:');
|
||||
console.log(` Endpoint: ${config.endpoint}`);
|
||||
console.log(` Region: ${config.region}`);
|
||||
console.log(` Bucket: ${bucketName}`);
|
||||
console.log(` Upload Dir: ${uploadDir}`);
|
||||
console.log(` Access Key: ${config.credentials.accessKeyId}`);
|
||||
console.log(` Force Path Style: ${config.forcePathStyle}`);
|
||||
console.log();
|
||||
|
||||
try {
|
||||
const s3Client = new S3(config);
|
||||
|
||||
// 1. 测试基本连接和认证
|
||||
console.log('📡 测试连接和认证...');
|
||||
try {
|
||||
const buckets = await s3Client.listBuckets();
|
||||
console.log('✅ 连接和认证成功!');
|
||||
console.log(`📂 现有存储桶: ${buckets.Buckets?.map((b) => b.Name).join(', ') || '无'}`);
|
||||
} catch (error) {
|
||||
console.log('❌ 连接失败:', error.message);
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
console.log('💡 提示: MinIO服务可能未运行,请检查localhost:9000是否可访问');
|
||||
} else if (error.message.includes('Invalid')) {
|
||||
console.log('💡 提示: 检查访问密钥和密钥是否正确');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查目标存储桶
|
||||
console.log(`\n🪣 检查存储桶 "${bucketName}"...`);
|
||||
let bucketExists = false;
|
||||
try {
|
||||
await s3Client.headBucket({ Bucket: bucketName });
|
||||
console.log(`✅ 存储桶 "${bucketName}" 存在并可访问`);
|
||||
bucketExists = true;
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFound') {
|
||||
console.log(`❌ 存储桶 "${bucketName}" 不存在`);
|
||||
console.log('🔧 尝试创建存储桶...');
|
||||
try {
|
||||
await s3Client.createBucket({ Bucket: bucketName });
|
||||
console.log(`✅ 存储桶 "${bucketName}" 创建成功`);
|
||||
bucketExists = true;
|
||||
} catch (createError) {
|
||||
console.log(`❌ 创建存储桶失败: ${createError.message}`);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ 检查存储桶时出错: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bucketExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查上传目录
|
||||
console.log(`\n📁 检查上传目录 "${uploadDir}"...`);
|
||||
try {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
console.log('📁 上传目录不存在,正在创建...');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
console.log('✅ 上传目录创建成功');
|
||||
} else {
|
||||
console.log('✅ 上传目录存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ 检查/创建上传目录失败: ${error.message}`);
|
||||
}
|
||||
|
||||
// 4. 测试文件上传
|
||||
console.log('\n📤 测试文件上传...');
|
||||
const testFileName = `test-upload-${Date.now()}.txt`;
|
||||
const testContent = `这是一个测试文件
|
||||
创建时间: ${new Date().toISOString()}
|
||||
用户: nice1234
|
||||
MinIO测试成功!`;
|
||||
|
||||
try {
|
||||
await s3Client.putObject({
|
||||
Bucket: bucketName,
|
||||
Key: testFileName,
|
||||
Body: testContent,
|
||||
ContentType: 'text/plain',
|
||||
Metadata: {
|
||||
'test-type': 'config-validation',
|
||||
'created-by': 'test-script',
|
||||
},
|
||||
});
|
||||
console.log(`✅ 文件上传成功: ${testFileName}`);
|
||||
} catch (error) {
|
||||
console.log(`❌ 文件上传失败: ${error.message}`);
|
||||
console.log('错误详情:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 测试文件下载验证
|
||||
console.log('\n📥 测试文件下载验证...');
|
||||
try {
|
||||
const result = await s3Client.getObject({
|
||||
Bucket: bucketName,
|
||||
Key: testFileName,
|
||||
});
|
||||
|
||||
// 读取流内容
|
||||
const chunks = [];
|
||||
for await (const chunk of result.Body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const downloadedContent = Buffer.concat(chunks).toString();
|
||||
|
||||
if (downloadedContent === testContent) {
|
||||
console.log('✅ 文件下载验证成功,内容一致');
|
||||
} else {
|
||||
console.log('❌ 文件内容不一致');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ 文件下载失败: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. 测试分片上传
|
||||
console.log('\n🔄 测试分片上传功能...');
|
||||
const multipartKey = `multipart-test-${Date.now()}.dat`;
|
||||
try {
|
||||
const multipartUpload = await s3Client.createMultipartUpload({
|
||||
Bucket: bucketName,
|
||||
Key: multipartKey,
|
||||
Metadata: {
|
||||
'test-type': 'multipart-upload',
|
||||
},
|
||||
});
|
||||
console.log(`✅ 分片上传初始化成功: ${multipartUpload.UploadId}`);
|
||||
|
||||
// 清理测试
|
||||
await s3Client.abortMultipartUpload({
|
||||
Bucket: bucketName,
|
||||
Key: multipartKey,
|
||||
UploadId: multipartUpload.UploadId,
|
||||
});
|
||||
console.log('✅ 分片上传测试完成并清理');
|
||||
} catch (error) {
|
||||
console.log(`❌ 分片上传测试失败: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 7. 列出存储桶中的文件
|
||||
console.log('\n📂 列出存储桶中的文件...');
|
||||
try {
|
||||
const listResult = await s3Client.listObjectsV2({
|
||||
Bucket: bucketName,
|
||||
MaxKeys: 10,
|
||||
});
|
||||
|
||||
console.log(`✅ 存储桶中共有 ${listResult.KeyCount || 0} 个文件`);
|
||||
if (listResult.Contents && listResult.Contents.length > 0) {
|
||||
console.log('最近的文件:');
|
||||
listResult.Contents.slice(-5).forEach((obj, index) => {
|
||||
const size = obj.Size < 1024 ? `${obj.Size}B` : `${Math.round(obj.Size / 1024)}KB`;
|
||||
console.log(` ${index + 1}. ${obj.Key} (${size})`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ 列出文件失败: ${error.message}`);
|
||||
}
|
||||
|
||||
// 8. 清理测试文件
|
||||
console.log('\n🧹 清理测试文件...');
|
||||
try {
|
||||
await s3Client.deleteObject({
|
||||
Bucket: bucketName,
|
||||
Key: testFileName,
|
||||
});
|
||||
console.log('✅ 测试文件清理完成');
|
||||
} catch (error) {
|
||||
console.log(`⚠️ 清理测试文件失败: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('\n🎉 所有测试通过!您的MinIO配置完全正确!');
|
||||
console.log('\n📝 配置摘要:');
|
||||
console.log('- ✅ 连接正常');
|
||||
console.log('- ✅ 认证有效');
|
||||
console.log('- ✅ 存储桶可用');
|
||||
console.log('- ✅ 文件上传/下载正常');
|
||||
console.log('- ✅ 分片上传支持');
|
||||
console.log('\n💡 您可以在应用中使用这些配置:');
|
||||
console.log('STORAGE_TYPE=s3');
|
||||
console.log(`UPLOAD_DIR=${uploadDir}`);
|
||||
console.log(`S3_ENDPOINT=${config.endpoint}`);
|
||||
console.log(`S3_REGION=${config.region}`);
|
||||
console.log(`S3_BUCKET=${bucketName}`);
|
||||
console.log(`S3_ACCESS_KEY_ID=${config.credentials.accessKeyId}`);
|
||||
console.log('S3_SECRET_ACCESS_KEY=***');
|
||||
console.log('S3_FORCE_PATH_STYLE=true');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(`❌ 测试过程中发生未预期错误: ${error.message}`);
|
||||
console.log('错误堆栈:', error.stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log('🚀 MinIO S3存储配置测试\n');
|
||||
|
||||
// 检查依赖
|
||||
try {
|
||||
require('@aws-sdk/client-s3');
|
||||
} catch (error) {
|
||||
console.log('❌ 缺少必要依赖 @aws-sdk/client-s3');
|
||||
console.log('请运行: npm install @aws-sdk/client-s3');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = await testMinIOConfig();
|
||||
|
||||
if (success) {
|
||||
console.log('\n✅ 测试完成:MinIO配置正确,可以正常使用!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n❌ 测试失败:请检查上述错误并修复配置');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ 脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
|
@ -1,29 +1,22 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"name": "@repo/tus",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"dev-static": "tsup --no-watch",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.723.0",
|
||||
"@shopify/semaphore": "^3.1.0",
|
||||
"debug": "^4.4.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multistream": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/multistream": "^4.1.3",
|
||||
"@types/node": "^20.3.1",
|
||||
"concurrently": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.5.4",
|
||||
"@redis/client": "^1.6.0"
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"es2022"
|
||||
],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noImplicitOverride": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist",
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__"
|
||||
]
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
outDir: 'dist',
|
||||
treeshake: true,
|
||||
sourcemap: true,
|
||||
external: [
|
||||
'@aws-sdk/client-s3',
|
||||
'@shopify/semaphore',
|
||||
'debug',
|
||||
'lodash.throttle',
|
||||
'multistream',
|
||||
'ioredis',
|
||||
'@redis/client',
|
||||
],
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue