add
This commit is contained in:
parent
4a70b731b8
commit
4dce2dc613
|
@ -13,3 +13,5 @@ REDIS_PASSWORD=nice
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
OIDC_REDIRECT_URI=https://your-frontend.com/callback
|
OIDC_REDIRECT_URI=https://your-frontend.com/callback
|
||||||
|
|
||||||
|
UPLOAD_DIR=/opt/projects/nice/uploads
|
|
@ -16,9 +16,13 @@
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"nanoid-cjs": "^0.0.7",
|
||||||
|
"transliteration": "^2.3.5",
|
||||||
"node-cron": "^4.0.7",
|
"node-cron": "^4.0.7",
|
||||||
"oidc-provider": "^9.1.1",
|
"oidc-provider": "^9.1.1",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"zod": "^3.25.23"
|
"zod": "^3.25.23"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -15,6 +15,11 @@ import { appRouter } from './trpc';
|
||||||
import { createBunWebSocket } from 'hono/bun';
|
import { createBunWebSocket } from 'hono/bun';
|
||||||
import { wsHandler, wsConfig } from './socket';
|
import { wsHandler, wsConfig } from './socket';
|
||||||
|
|
||||||
|
// 导入新的路由
|
||||||
|
import userRest from './user/user.rest';
|
||||||
|
import uploadRest from './upload/upload.rest';
|
||||||
|
import { startCleanupScheduler } from './upload/scheduler';
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
redis: Redis;
|
redis: Redis;
|
||||||
|
@ -51,6 +56,10 @@ app.use(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 添加 REST API 路由
|
||||||
|
app.route('/api/users', userRest);
|
||||||
|
app.route('/api/upload', uploadRest);
|
||||||
|
|
||||||
app.use('/oidc/*', async (c, next) => {
|
app.use('/oidc/*', async (c, next) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await oidc.callback(c.req.raw, c.res.raw);
|
await oidc.callback(c.req.raw, c.res.raw);
|
||||||
|
@ -60,6 +69,10 @@ app.use('/oidc/*', async (c, next) => {
|
||||||
|
|
||||||
// 添加 WebSocket 路由
|
// 添加 WebSocket 路由
|
||||||
app.get('/ws', wsHandler);
|
app.get('/ws', wsHandler);
|
||||||
|
|
||||||
|
// 启动上传清理定时任务
|
||||||
|
startCleanupScheduler();
|
||||||
|
|
||||||
const bunServerConfig = {
|
const bunServerConfig = {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
|
|
|
@ -4,8 +4,9 @@ import { prisma } from '@repo/db';
|
||||||
|
|
||||||
async function getClients() {
|
async function getClients() {
|
||||||
const dbClients = await prisma.oidcClient.findMany?.();
|
const dbClients = await prisma.oidcClient.findMany?.();
|
||||||
const dbClientList = (dbClients && dbClients.length > 0)
|
const dbClientList =
|
||||||
? dbClients.map(c => ({
|
dbClients && dbClients.length > 0
|
||||||
|
? dbClients.map((c) => ({
|
||||||
client_id: c.clientId,
|
client_id: c.clientId,
|
||||||
client_secret: c.clientSecret,
|
client_secret: c.clientSecret,
|
||||||
grant_types: JSON.parse(c.grantTypes), // string -> string[]
|
grant_types: JSON.parse(c.grantTypes), // string -> string[]
|
||||||
|
@ -26,7 +27,7 @@ async function getClients() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查是否与数据库client_id重复
|
// 检查是否与数据库client_id重复
|
||||||
const allClients = [defaultClient, ...dbClientList.filter(c => c.client_id !== defaultClient.client_id)];
|
const allClients = [defaultClient, ...dbClientList.filter((c) => c.client_id !== defaultClient.client_id)];
|
||||||
|
|
||||||
return allClients;
|
return allClients;
|
||||||
}
|
}
|
||||||
|
@ -63,7 +64,8 @@ const config: Configuration = {
|
||||||
q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M',
|
q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M',
|
||||||
qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU',
|
qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU',
|
||||||
use: 'sig',
|
use: 'sig',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
crv: 'P-256',
|
crv: 'P-256',
|
||||||
d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws',
|
d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws',
|
||||||
kty: 'EC',
|
kty: 'EC',
|
||||||
|
|
|
@ -14,7 +14,7 @@ interface WSMessageParams {
|
||||||
}
|
}
|
||||||
// 定义消息类型接口
|
// 定义消息类型接口
|
||||||
interface WSMessage {
|
interface WSMessage {
|
||||||
type: 'join' | 'leave' | 'message';
|
action: 'join' | 'leave' | 'message';
|
||||||
roomId: string;
|
roomId: string;
|
||||||
data: WSMessageParams;
|
data: WSMessageParams;
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ const wsHandler = upgradeWebSocket((c) => {
|
||||||
const parsedMessage: WSMessage = JSON.parse(message.data as any);
|
const parsedMessage: WSMessage = JSON.parse(message.data as any);
|
||||||
console.log('收到消息:', parsedMessage);
|
console.log('收到消息:', parsedMessage);
|
||||||
|
|
||||||
switch (parsedMessage.type) {
|
switch (parsedMessage.action) {
|
||||||
case 'join':
|
case 'join':
|
||||||
// 加入房间
|
// 加入房间
|
||||||
if (!rooms.has(parsedMessage.roomId)) {
|
if (!rooms.has(parsedMessage.roomId)) {
|
||||||
|
@ -51,7 +51,7 @@ const wsHandler = upgradeWebSocket((c) => {
|
||||||
// 发送加入成功消息
|
// 发送加入成功消息
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'system',
|
action: 'system',
|
||||||
data: {
|
data: {
|
||||||
text: `成功加入房间 ${parsedMessage.roomId}`,
|
text: `成功加入房间 ${parsedMessage.roomId}`,
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
|
@ -75,7 +75,7 @@ const wsHandler = upgradeWebSocket((c) => {
|
||||||
const room = rooms.get(parsedMessage.roomId);
|
const room = rooms.get(parsedMessage.roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
const messageToSend = {
|
const messageToSend = {
|
||||||
type: 'message',
|
action: 'message',
|
||||||
data: parsedMessage.data,
|
data: parsedMessage.data,
|
||||||
roomId: parsedMessage.roomId,
|
roomId: parsedMessage.roomId,
|
||||||
};
|
};
|
||||||
|
@ -92,7 +92,7 @@ const wsHandler = upgradeWebSocket((c) => {
|
||||||
console.error('处理消息时出错:', error);
|
console.error('处理消息时出错:', error);
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'error',
|
action: 'error',
|
||||||
data: {
|
data: {
|
||||||
text: '消息处理失败',
|
text: '消息处理失败',
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
|
|
|
@ -0,0 +1,232 @@
|
||||||
|
# 上传模块架构改造
|
||||||
|
|
||||||
|
本模块已从 NestJS 架构成功改造为 Hono + Bun 架构,并支持多种存储后端的无感切换。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/upload/
|
||||||
|
├── tus.ts # TUS 协议服务核心实现
|
||||||
|
├── upload.index.ts # 资源管理相关函数
|
||||||
|
├── upload.rest.ts # Hono REST API 路由
|
||||||
|
├── storage.adapter.ts # 存储适配器系统 🆕
|
||||||
|
├── storage.utils.ts # 存储工具类 🆕
|
||||||
|
├── scheduler.ts # 定时清理任务
|
||||||
|
├── utils.ts # 工具函数
|
||||||
|
├── types.ts # 类型定义
|
||||||
|
└── README.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 存储适配器系统
|
||||||
|
|
||||||
|
### 支持的存储类型
|
||||||
|
|
||||||
|
1. **本地存储 (Local)** - 文件存储在本地文件系统
|
||||||
|
2. **S3 存储 (S3)** - 文件存储在 AWS S3 或兼容的对象存储服务
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
#### 本地存储配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
UPLOAD_EXPIRATION_MS=0 # 0 表示不自动过期(推荐设置)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### S3 存储配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STORAGE_TYPE=s3
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
S3_ENDPOINT=https://s3.amazonaws.com # 可选,支持其他 S3 兼容服务
|
||||||
|
S3_FORCE_PATH_STYLE=false # 可选,路径风格
|
||||||
|
S3_PART_SIZE=8388608 # 可选,分片大小 (8MB)
|
||||||
|
S3_MAX_CONCURRENT_UPLOADS=60 # 可选,最大并发上传数
|
||||||
|
UPLOAD_EXPIRATION_MS=0 # 0 表示不自动过期(推荐设置)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 存储类型记录
|
||||||
|
|
||||||
|
- **数据库支持**: 每个资源记录都包含 `storageType` 字段,标识文件使用的存储后端
|
||||||
|
- **自动记录**: 上传时自动记录当前的存储类型
|
||||||
|
- **迁移支持**: 支持批量更新现有资源的存储类型标记
|
||||||
|
|
||||||
|
### 不过期设置
|
||||||
|
|
||||||
|
- **默认行为**: 过期时间默认设为 0,表示文件不会自动过期
|
||||||
|
- **手动清理**: 提供多种手动清理选项
|
||||||
|
- **灵活控制**: 可根据需要设置过期时间,或完全禁用自动清理
|
||||||
|
|
||||||
|
### 无感切换机制
|
||||||
|
|
||||||
|
1. **单例模式管理**: `StorageManager` 使用单例模式确保全局一致性
|
||||||
|
2. **自动配置检测**: 启动时根据环境变量自动选择存储类型
|
||||||
|
3. **统一接口**: 所有存储类型都实现相同的 TUS `DataStore` 接口
|
||||||
|
4. **运行时切换**: 支持运行时切换存储配置(需要重启生效)
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 资源管理
|
||||||
|
|
||||||
|
- `GET /api/upload/resource/:fileId` - 获取文件资源信息
|
||||||
|
- `GET /api/upload/resources` - 获取所有资源
|
||||||
|
- `GET /api/upload/resources/storage/:storageType` - 🆕 根据存储类型获取资源
|
||||||
|
- `GET /api/upload/resources/status/:status` - 🆕 根据状态获取资源
|
||||||
|
- `GET /api/upload/resources/uploading` - 🆕 获取正在上传的资源
|
||||||
|
- `GET /api/upload/stats` - 🆕 获取资源统计信息
|
||||||
|
- `DELETE /api/upload/resource/:id` - 删除资源
|
||||||
|
- `PATCH /api/upload/resource/:id` - 更新资源
|
||||||
|
- `POST /api/upload/cleanup` - 手动触发清理
|
||||||
|
- `POST /api/upload/cleanup/by-status` - 🆕 根据状态清理资源
|
||||||
|
- `POST /api/upload/migrate-storage` - 🆕 迁移资源存储类型标记
|
||||||
|
|
||||||
|
### 存储管理
|
||||||
|
|
||||||
|
- `GET /api/upload/storage/info` - 获取当前存储配置信息
|
||||||
|
- `POST /api/upload/storage/switch` - 切换存储类型
|
||||||
|
- `POST /api/upload/storage/validate` - 验证存储配置
|
||||||
|
|
||||||
|
### TUS 协议
|
||||||
|
|
||||||
|
- `OPTIONS /api/upload/*` - TUS 协议选项请求
|
||||||
|
- `HEAD /api/upload/*` - TUS 协议头部请求
|
||||||
|
- `POST /api/upload/*` - TUS 协议创建上传
|
||||||
|
- `PATCH /api/upload/*` - TUS 协议上传数据
|
||||||
|
- `GET /api/upload/*` - TUS 协议获取状态
|
||||||
|
|
||||||
|
## 新增 API 使用示例
|
||||||
|
|
||||||
|
### 获取存储类型统计
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/stats');
|
||||||
|
const stats = await response.json();
|
||||||
|
// {
|
||||||
|
// total: 150,
|
||||||
|
// byStatus: { "UPLOADED": 120, "UPLOADING": 5, "PROCESSED": 25 },
|
||||||
|
// byStorageType: { "local": 80, "s3": 70 }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询特定存储类型的资源
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/resources/storage/s3');
|
||||||
|
const s3Resources = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移存储类型标记
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/migrate-storage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ from: 'local', to: 's3' }),
|
||||||
|
});
|
||||||
|
// { success: true, message: "Migrated 50 resources from local to s3", count: 50 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动清理特定状态的资源
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/cleanup/by-status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'UPLOADING',
|
||||||
|
olderThanDays: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🆕 存储管理示例
|
||||||
|
|
||||||
|
### 获取存储信息
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/storage/info');
|
||||||
|
const storageInfo = await response.json();
|
||||||
|
// { type: 'local', config: { directory: './uploads' } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换到 S3 存储
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const newConfig = {
|
||||||
|
type: 's3',
|
||||||
|
s3: {
|
||||||
|
bucket: 'my-bucket',
|
||||||
|
region: 'us-west-2',
|
||||||
|
accessKeyId: 'YOUR_ACCESS_KEY',
|
||||||
|
secretAccessKey: 'YOUR_SECRET_KEY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload/storage/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newConfig),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证存储配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/upload/storage/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newConfig),
|
||||||
|
});
|
||||||
|
const validation = await response.json();
|
||||||
|
// { valid: true, message: 'Storage configuration is valid' }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性保留
|
||||||
|
|
||||||
|
1. **TUS 协议支持** - 完全保留原有的断点续传功能
|
||||||
|
2. **文件命名** - 保留安全的文件命名策略
|
||||||
|
3. **资源状态管理** - 保留完整的上传状态跟踪
|
||||||
|
4. **自动清理** - 保留过期文件清理功能(默认禁用)
|
||||||
|
5. **数据库集成** - 保留 Prisma ORM 数据库操作
|
||||||
|
|
||||||
|
## 🆕 新增特性
|
||||||
|
|
||||||
|
1. **多存储后端支持** - 支持本地存储和 S3 存储
|
||||||
|
2. **无感切换** - 运行时可切换存储类型
|
||||||
|
3. **配置验证** - 提供存储配置验证功能
|
||||||
|
4. **存储信息查询** - 可查询当前存储配置
|
||||||
|
5. **统一日志** - 存储操作统一日志记录
|
||||||
|
6. **🆕 存储类型记录** - 数据库记录每个资源的存储类型
|
||||||
|
7. **🆕 灵活清理** - 支持按状态、时间等条件清理
|
||||||
|
8. **🆕 统计分析** - 提供详细的资源统计信息
|
||||||
|
9. **🆕 不过期设置** - 默认不自动过期,避免意外删除
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
服务启动时会自动:
|
||||||
|
|
||||||
|
1. 根据环境变量初始化存储适配器
|
||||||
|
2. 初始化 TUS 服务器
|
||||||
|
3. 注册 REST API 路由
|
||||||
|
4. 启动定时清理任务(如果启用)
|
||||||
|
|
||||||
|
支持的存储切换场景:
|
||||||
|
|
||||||
|
- 开发环境使用本地存储
|
||||||
|
- 生产环境使用 S3 存储
|
||||||
|
- 混合云部署灵活切换
|
||||||
|
- 存储迁移时批量更新资源标记
|
||||||
|
|
||||||
|
## 💡 最佳实践
|
||||||
|
|
||||||
|
1. **过期设置**: 推荐设置 `UPLOAD_EXPIRATION_MS=0` 避免文件意外过期
|
||||||
|
2. **存储记录**: 利用数据库中的 `storageType` 字段追踪文件位置
|
||||||
|
3. **定期清理**: 使用手动清理 API 定期清理不需要的资源
|
||||||
|
4. **监控统计**: 使用统计 API 监控存储使用情况
|
||||||
|
5. **迁移策略**: 在存储迁移时先更新环境变量,再使用迁移 API 更新数据库标记
|
||||||
|
|
||||||
|
无需代码修改,仅通过环境变量即可实现存储后端的无感切换。
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { cleanupExpiredUploads } from './tus';
|
||||||
|
|
||||||
|
// 设置定时清理任务 - 每天凌晨执行
|
||||||
|
export function startCleanupScheduler() {
|
||||||
|
// 每 24 小时执行一次清理任务
|
||||||
|
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||||
|
|
||||||
|
// 立即执行一次清理
|
||||||
|
setTimeout(async () => {
|
||||||
|
console.log('Starting initial cleanup...');
|
||||||
|
try {
|
||||||
|
await cleanupExpiredUploads();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Initial cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}, 5000); // 启动 5 秒后执行
|
||||||
|
|
||||||
|
// 设置定期清理
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log('Starting scheduled cleanup...');
|
||||||
|
try {
|
||||||
|
await cleanupExpiredUploads();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scheduled cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}, CLEANUP_INTERVAL);
|
||||||
|
|
||||||
|
console.log('Upload cleanup scheduler started - will run every 24 hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动触发清理(可用于 API 调用)
|
||||||
|
export async function triggerCleanup() {
|
||||||
|
console.log('Manual cleanup triggered...');
|
||||||
|
try {
|
||||||
|
return await cleanupExpiredUploads();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Manual cleanup failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { FileStore, S3Store } from '@repo/tus';
|
||||||
|
import type { DataStore } from '@repo/tus';
|
||||||
|
|
||||||
|
// 存储类型枚举
|
||||||
|
export enum StorageType {
|
||||||
|
LOCAL = 'local',
|
||||||
|
S3 = 's3',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储配置接口
|
||||||
|
export interface StorageConfig {
|
||||||
|
type: StorageType;
|
||||||
|
// 本地存储配置
|
||||||
|
local?: {
|
||||||
|
directory: string;
|
||||||
|
expirationPeriodInMilliseconds?: number;
|
||||||
|
};
|
||||||
|
// S3 存储配置
|
||||||
|
s3?: {
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
endpoint?: string; // 用于兼容其他 S3 兼容服务
|
||||||
|
forcePathStyle?: boolean;
|
||||||
|
partSize?: number;
|
||||||
|
maxConcurrentPartUploads?: number;
|
||||||
|
expirationPeriodInMilliseconds?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从环境变量获取存储配置
|
||||||
|
export function getStorageConfig(): StorageConfig {
|
||||||
|
const storageType = (process.env.STORAGE_TYPE || 'local') as StorageType;
|
||||||
|
|
||||||
|
const config: StorageConfig = {
|
||||||
|
type: storageType,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (storageType === StorageType.LOCAL) {
|
||||||
|
config.local = {
|
||||||
|
directory: process.env.UPLOAD_DIR || './uploads',
|
||||||
|
expirationPeriodInMilliseconds: parseInt(process.env.UPLOAD_EXPIRATION_MS || '0'), // 默认不过期
|
||||||
|
};
|
||||||
|
} else if (storageType === StorageType.S3) {
|
||||||
|
config.s3 = {
|
||||||
|
bucket: process.env.S3_BUCKET || '',
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
|
||||||
|
partSize: parseInt(process.env.S3_PART_SIZE || '8388608'), // 8MB
|
||||||
|
maxConcurrentPartUploads: parseInt(process.env.S3_MAX_CONCURRENT_UPLOADS || '60'),
|
||||||
|
expirationPeriodInMilliseconds: parseInt(process.env.UPLOAD_EXPIRATION_MS || '0'), // 默认不过期
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证存储配置
|
||||||
|
export function validateStorageConfig(config: StorageConfig): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (config.type === StorageType.LOCAL) {
|
||||||
|
if (!config.local?.directory) {
|
||||||
|
errors.push('Local storage directory is required');
|
||||||
|
}
|
||||||
|
} else if (config.type === StorageType.S3) {
|
||||||
|
const s3Config = config.s3;
|
||||||
|
if (!s3Config?.bucket) errors.push('S3 bucket is required');
|
||||||
|
if (!s3Config?.region) errors.push('S3 region is required');
|
||||||
|
if (!s3Config?.accessKeyId) errors.push('S3 access key ID is required');
|
||||||
|
if (!s3Config?.secretAccessKey) errors.push('S3 secret access key is required');
|
||||||
|
} else {
|
||||||
|
errors.push(`Unsupported storage type: ${config.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建存储实例
|
||||||
|
export function createStorageInstance(config: StorageConfig): DataStore {
|
||||||
|
// 验证配置
|
||||||
|
const errors = validateStorageConfig(config);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Storage configuration errors: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case StorageType.LOCAL:
|
||||||
|
return new FileStore({
|
||||||
|
directory: config.local!.directory,
|
||||||
|
expirationPeriodInMilliseconds: config.local!.expirationPeriodInMilliseconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
case StorageType.S3:
|
||||||
|
const s3Config = config.s3!;
|
||||||
|
return new S3Store({
|
||||||
|
partSize: s3Config.partSize,
|
||||||
|
maxConcurrentPartUploads: s3Config.maxConcurrentPartUploads,
|
||||||
|
expirationPeriodInMilliseconds: s3Config.expirationPeriodInMilliseconds,
|
||||||
|
s3ClientConfig: {
|
||||||
|
bucket: s3Config.bucket,
|
||||||
|
region: s3Config.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: s3Config.accessKeyId,
|
||||||
|
secretAccessKey: s3Config.secretAccessKey,
|
||||||
|
},
|
||||||
|
endpoint: s3Config.endpoint,
|
||||||
|
forcePathStyle: s3Config.forcePathStyle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported storage type: ${config.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储管理器类
|
||||||
|
export class StorageManager {
|
||||||
|
private static instance: StorageManager;
|
||||||
|
private storageConfig: StorageConfig;
|
||||||
|
private dataStore: DataStore;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.storageConfig = getStorageConfig();
|
||||||
|
this.dataStore = createStorageInstance(this.storageConfig);
|
||||||
|
|
||||||
|
console.log(`Storage initialized: ${this.storageConfig.type}`);
|
||||||
|
if (this.storageConfig.type === StorageType.LOCAL) {
|
||||||
|
console.log(`Local directory: ${this.storageConfig.local?.directory}`);
|
||||||
|
} else if (this.storageConfig.type === StorageType.S3) {
|
||||||
|
console.log(`S3 bucket: ${this.storageConfig.s3?.bucket} in region: ${this.storageConfig.s3?.region}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): StorageManager {
|
||||||
|
if (!StorageManager.instance) {
|
||||||
|
StorageManager.instance = new StorageManager();
|
||||||
|
}
|
||||||
|
return StorageManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDataStore(): DataStore {
|
||||||
|
return this.dataStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStorageConfig(): StorageConfig {
|
||||||
|
return this.storageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStorageType(): StorageType {
|
||||||
|
return this.storageConfig.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换存储类型(需要重启应用)
|
||||||
|
public async switchStorage(newConfig: StorageConfig): Promise<void> {
|
||||||
|
const errors = validateStorageConfig(newConfig);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Invalid storage configuration: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storageConfig = newConfig;
|
||||||
|
this.dataStore = createStorageInstance(newConfig);
|
||||||
|
|
||||||
|
console.log(`Storage switched to: ${newConfig.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储统计信息
|
||||||
|
public getStorageInfo() {
|
||||||
|
return {
|
||||||
|
type: this.storageConfig.type,
|
||||||
|
config:
|
||||||
|
this.storageConfig.type === StorageType.LOCAL
|
||||||
|
? { directory: this.storageConfig.local?.directory }
|
||||||
|
: {
|
||||||
|
bucket: this.storageConfig.s3?.bucket,
|
||||||
|
region: this.storageConfig.s3?.region,
|
||||||
|
endpoint: this.storageConfig.s3?.endpoint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { StorageManager, StorageType } from './storage.adapter';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储工具类 - 处理不同存储类型的文件操作
|
||||||
|
*/
|
||||||
|
export class StorageUtils {
|
||||||
|
private static instance: StorageUtils;
|
||||||
|
private storageManager: StorageManager;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.storageManager = StorageManager.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): StorageUtils {
|
||||||
|
if (!StorageUtils.instance) {
|
||||||
|
StorageUtils.instance = new StorageUtils();
|
||||||
|
}
|
||||||
|
return StorageUtils.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件访问URL
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @param isPublic 是否为公开访问链接
|
||||||
|
* @returns 文件访问URL
|
||||||
|
*/
|
||||||
|
public generateFileUrl(fileId: string, isPublic: boolean = false): string {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
const config = this.storageManager.getStorageConfig();
|
||||||
|
|
||||||
|
switch (storageType) {
|
||||||
|
case StorageType.LOCAL:
|
||||||
|
// 本地存储返回相对路径或服务器路径
|
||||||
|
if (isPublic) {
|
||||||
|
// 假设有一个静态文件服务
|
||||||
|
return `/uploads/${fileId}`;
|
||||||
|
}
|
||||||
|
return path.join(config.local?.directory || './uploads', fileId);
|
||||||
|
|
||||||
|
case StorageType.S3:
|
||||||
|
// S3 存储返回对象存储路径
|
||||||
|
const s3Config = config.s3!;
|
||||||
|
if (s3Config.endpoint && s3Config.endpoint !== 'https://s3.amazonaws.com') {
|
||||||
|
// 自定义 S3 兼容服务
|
||||||
|
return `${s3Config.endpoint}/${s3Config.bucket}/${fileId}`;
|
||||||
|
}
|
||||||
|
// AWS S3
|
||||||
|
return `https://${s3Config.bucket}.s3.${s3Config.region}.amazonaws.com/${fileId}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported storage type: ${storageType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成预签名URL(仅支持S3)
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @param expiresIn 过期时间(秒)
|
||||||
|
* @returns 预签名URL
|
||||||
|
*/
|
||||||
|
public async generatePresignedUrl(fileId: string, expiresIn: number = 3600): Promise<string> {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
|
||||||
|
if (storageType !== StorageType.S3) {
|
||||||
|
throw new Error('Presigned URLs are only supported for S3 storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 实现 S3 预签名 URL 生成
|
||||||
|
// 这需要使用 AWS SDK 的 getSignedUrl 方法
|
||||||
|
// const s3Client = this.storageManager.getS3Client();
|
||||||
|
// return await getSignedUrl(s3Client, new GetObjectCommand({
|
||||||
|
// Bucket: config.s3!.bucket,
|
||||||
|
// Key: fileId
|
||||||
|
// }), { expiresIn });
|
||||||
|
|
||||||
|
throw new Error('Presigned URL generation not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件物理路径(仅本地存储)
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @returns 文件物理路径
|
||||||
|
*/
|
||||||
|
public getFilePath(fileId: string): string {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
|
||||||
|
if (storageType !== StorageType.LOCAL) {
|
||||||
|
throw new Error('File path is only available for local storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.storageManager.getStorageConfig();
|
||||||
|
return path.join(config.local?.directory || './uploads', fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @returns 是否存在
|
||||||
|
*/
|
||||||
|
public async fileExists(fileId: string): Promise<boolean> {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
const dataStore = this.storageManager.getDataStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dataStore.getUpload(fileId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
* @param fileId 文件ID
|
||||||
|
*/
|
||||||
|
public async deleteFile(fileId: string): Promise<void> {
|
||||||
|
const dataStore = this.storageManager.getDataStore();
|
||||||
|
await dataStore.remove(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件流(用于下载)
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @returns 文件流
|
||||||
|
*/
|
||||||
|
public async getFileStream(fileId: string): Promise<ReadableStream | NodeJS.ReadableStream> {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
const dataStore = this.storageManager.getDataStore();
|
||||||
|
|
||||||
|
if (storageType === StorageType.LOCAL) {
|
||||||
|
// 本地存储直接返回文件流
|
||||||
|
return (dataStore as any).read(fileId);
|
||||||
|
} else if (storageType === StorageType.S3) {
|
||||||
|
// S3 存储返回对象流
|
||||||
|
return (dataStore as any).read(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`File stream not supported for storage type: ${storageType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文件到另一个位置
|
||||||
|
* @param sourceFileId 源文件ID
|
||||||
|
* @param targetFileId 目标文件ID
|
||||||
|
*/
|
||||||
|
public async copyFile(sourceFileId: string, targetFileId: string): Promise<void> {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
|
||||||
|
if (storageType === StorageType.LOCAL) {
|
||||||
|
// 本地存储使用文件系统复制
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const sourcePath = this.getFilePath(sourceFileId);
|
||||||
|
const targetPath = this.getFilePath(targetFileId);
|
||||||
|
await fs.copyFile(sourcePath, targetPath);
|
||||||
|
} else if (storageType === StorageType.S3) {
|
||||||
|
// S3 存储使用对象复制
|
||||||
|
// TODO: 实现 S3 对象复制
|
||||||
|
throw new Error('S3 file copy not implemented yet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取存储统计信息
|
||||||
|
*/
|
||||||
|
public async getStorageStats(): Promise<{
|
||||||
|
storageType: StorageType;
|
||||||
|
totalFiles?: number;
|
||||||
|
totalSize?: number;
|
||||||
|
availableSpace?: number;
|
||||||
|
}> {
|
||||||
|
const storageType = this.storageManager.getStorageType();
|
||||||
|
const config = this.storageManager.getStorageConfig();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
storageType,
|
||||||
|
totalFiles: undefined as number | undefined,
|
||||||
|
totalSize: undefined as number | undefined,
|
||||||
|
availableSpace: undefined as number | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (storageType === StorageType.LOCAL) {
|
||||||
|
// 本地存储可以计算磁盘使用情况
|
||||||
|
try {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const path = await import('path');
|
||||||
|
const uploadDir = config.local?.directory || './uploads';
|
||||||
|
|
||||||
|
// 计算文件总数和大小(这里简化实现)
|
||||||
|
// 实际应用中可能需要递归遍历目录
|
||||||
|
const stat = await fs.stat(uploadDir).catch(() => null);
|
||||||
|
if (stat) {
|
||||||
|
// TODO: 实现详细的目录统计
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get local storage stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { Server, Upload } from '@repo/tus';
|
||||||
|
import { prisma } from '@repo/db';
|
||||||
|
import { getFilenameWithoutExt } from '../utils/file';
|
||||||
|
import { nanoid } from 'nanoid-cjs';
|
||||||
|
import { slugify } from 'transliteration';
|
||||||
|
import { StorageManager } from './storage.adapter';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
function getFileId(uploadId: string) {
|
||||||
|
return uploadId.replace(/\/[^/]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadCreate(req: any, res: any, upload: Upload, url: string) {
|
||||||
|
try {
|
||||||
|
const fileId = getFileId(upload.id);
|
||||||
|
const storageManager = StorageManager.getInstance();
|
||||||
|
|
||||||
|
await prisma.resource.create({
|
||||||
|
data: {
|
||||||
|
title: getFilenameWithoutExt(upload.metadata?.filename || 'untitled'),
|
||||||
|
fileId, // 移除最后的文件名
|
||||||
|
url: upload.id,
|
||||||
|
meta: upload.metadata,
|
||||||
|
status: ResourceStatus.UPLOADING,
|
||||||
|
storageType: storageManager.getStorageType(), // 记录存储类型
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Resource created for ${upload.id} using ${storageManager.getStorageType()} storage`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create resource during upload', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadFinish(req: any, res: any, upload: Upload) {
|
||||||
|
try {
|
||||||
|
const resource = await prisma.resource.update({
|
||||||
|
where: { fileId: getFileId(upload.id) },
|
||||||
|
data: { status: ResourceStatus.UPLOADED },
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: 这里可以添加队列处理逻辑
|
||||||
|
// fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id });
|
||||||
|
console.log(`Upload finished ${resource.url} using ${StorageManager.getInstance().getStorageType()} storage`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update resource after upload', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeTusServer() {
|
||||||
|
if (tusServer) {
|
||||||
|
return tusServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储管理器实例
|
||||||
|
const storageManager = StorageManager.getInstance();
|
||||||
|
const dataStore = storageManager.getDataStore();
|
||||||
|
|
||||||
|
tusServer = new Server({
|
||||||
|
namingFunction(req, metadata) {
|
||||||
|
const safeFilename = slugify(metadata?.filename || 'untitled');
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
const uniqueId = nanoid(10);
|
||||||
|
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
|
||||||
|
},
|
||||||
|
path: '/upload',
|
||||||
|
datastore: dataStore, // 使用存储适配器
|
||||||
|
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
||||||
|
postReceiveInterval: 1000,
|
||||||
|
getFileIdFromRequest: (req, lastPath) => {
|
||||||
|
const match = req.url?.match(/\/upload\/(.+)/);
|
||||||
|
return match ? match[1] : lastPath;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置事件处理器
|
||||||
|
tusServer.on('POST_CREATE', handleUploadCreate);
|
||||||
|
tusServer.on('POST_FINISH', handleUploadFinish);
|
||||||
|
|
||||||
|
console.log(`TUS server initialized with ${storageManager.getStorageType()} storage`);
|
||||||
|
return tusServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTusServer() {
|
||||||
|
return initializeTusServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleTusRequest(req: any, res: any) {
|
||||||
|
const server = getTusServer();
|
||||||
|
return server.handle(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupExpiredUploads() {
|
||||||
|
try {
|
||||||
|
const storageManager = StorageManager.getInstance();
|
||||||
|
|
||||||
|
// 获取过期时间配置,如果设置为 0 则不自动清理
|
||||||
|
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 server = getTusServer();
|
||||||
|
const expiredUploadCount = await server.cleanUpExpiredUploads();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed from ${storageManager.getStorageType()} storage`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { deletedResources: deletedResources.count, expiredUploads: expiredUploadCount };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Expired uploads cleanup failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储信息
|
||||||
|
export function getStorageInfo() {
|
||||||
|
const storageManager = StorageManager.getInstance();
|
||||||
|
return storageManager.getStorageInfo();
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
export interface UploadCompleteEvent {
|
||||||
|
identifier: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
hash: string;
|
||||||
|
integrityVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadEvent = {
|
||||||
|
uploadStart: {
|
||||||
|
identifier: string;
|
||||||
|
filename: string;
|
||||||
|
totalSize: number;
|
||||||
|
resuming?: boolean;
|
||||||
|
};
|
||||||
|
uploadComplete: UploadCompleteEvent;
|
||||||
|
uploadError: { identifier: string; error: string; filename: string };
|
||||||
|
};
|
||||||
|
export interface UploadLock {
|
||||||
|
clientId: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
// 添加重试机制,处理临时网络问题
|
||||||
|
// 实现定期清理过期的临时文件
|
||||||
|
// 添加文件完整性校验
|
||||||
|
// 实现上传进度持久化,支持服务重启后恢复
|
||||||
|
// 添加并发限制,防止系统资源耗尽
|
||||||
|
// 实现文件去重功能,避免重复上传
|
||||||
|
// 添加日志记录和监控机制
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { prisma } from '@repo/db';
|
||||||
|
import type { Resource } from '@repo/db';
|
||||||
|
import { StorageType } from './storage.adapter';
|
||||||
|
|
||||||
|
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: 'ready', resource };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllResources(): Promise<Resource[]> {
|
||||||
|
return prisma.resource.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResourcesByStorageType(storageType: StorageType): Promise<Resource[]> {
|
||||||
|
return prisma.resource.findMany({
|
||||||
|
where: {
|
||||||
|
storageType: storageType,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 migrateResourcesStorageType(
|
||||||
|
fromStorageType: StorageType,
|
||||||
|
toStorageType: StorageType,
|
||||||
|
): Promise<{ count: number }> {
|
||||||
|
const result = await prisma.resource.updateMany({
|
||||||
|
where: {
|
||||||
|
storageType: fromStorageType,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
storageType: toStorageType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { count: result.count };
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { handleTusRequest, cleanupExpiredUploads, getStorageInfo } from './tus';
|
||||||
|
import {
|
||||||
|
getResourceByFileId,
|
||||||
|
getAllResources,
|
||||||
|
deleteResource,
|
||||||
|
updateResource,
|
||||||
|
getResourcesByStorageType,
|
||||||
|
getResourcesByStatus,
|
||||||
|
getUploadingResources,
|
||||||
|
getResourceStats,
|
||||||
|
migrateResourcesStorageType,
|
||||||
|
} from './upload.index';
|
||||||
|
import { StorageManager, StorageType, type StorageConfig } from './storage.adapter';
|
||||||
|
import { prisma } from '@repo/db';
|
||||||
|
|
||||||
|
const uploadRest = new Hono();
|
||||||
|
|
||||||
|
// 获取文件资源信息
|
||||||
|
uploadRest.get('/resource/:fileId', async (c) => {
|
||||||
|
const fileId = c.req.param('fileId');
|
||||||
|
const result = await getResourceByFileId(fileId);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有资源
|
||||||
|
uploadRest.get('/resources', async (c) => {
|
||||||
|
const resources = await getAllResources();
|
||||||
|
return c.json(resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据存储类型获取资源
|
||||||
|
uploadRest.get('/resources/storage/:storageType', async (c) => {
|
||||||
|
const storageType = c.req.param('storageType') as StorageType;
|
||||||
|
const resources = await getResourcesByStorageType(storageType);
|
||||||
|
return c.json(resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据状态获取资源
|
||||||
|
uploadRest.get('/resources/status/:status', async (c) => {
|
||||||
|
const status = c.req.param('status');
|
||||||
|
const resources = await getResourcesByStatus(status);
|
||||||
|
return c.json(resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取正在上传的资源
|
||||||
|
uploadRest.get('/resources/uploading', async (c) => {
|
||||||
|
const resources = await getUploadingResources();
|
||||||
|
return c.json(resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取资源统计信息
|
||||||
|
uploadRest.get('/stats', async (c) => {
|
||||||
|
const stats = await getResourceStats();
|
||||||
|
return c.json(stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除资源
|
||||||
|
uploadRest.delete('/resource/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const result = await deleteResource(id);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新资源
|
||||||
|
uploadRest.patch('/resource/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = await c.req.json();
|
||||||
|
const result = await updateResource(id, data);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移资源存储类型(批量更新数据库中的存储类型标记)
|
||||||
|
uploadRest.post('/migrate-storage', async (c) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = await c.req.json();
|
||||||
|
const result = await migrateResourcesStorageType(from as StorageType, to as StorageType);
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
message: `Migrated ${result.count} resources from ${from} to ${to}`,
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to migrate storage type:', error);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理过期上传
|
||||||
|
uploadRest.post('/cleanup', async (c) => {
|
||||||
|
const result = await cleanupExpiredUploads();
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 手动清理指定状态的资源
|
||||||
|
uploadRest.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取存储信息
|
||||||
|
uploadRest.get('/storage/info', async (c) => {
|
||||||
|
const storageInfo = getStorageInfo();
|
||||||
|
return c.json(storageInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换存储类型(需要重启应用)
|
||||||
|
uploadRest.post('/storage/switch', async (c) => {
|
||||||
|
try {
|
||||||
|
const newConfig = (await c.req.json()) as StorageConfig;
|
||||||
|
const storageManager = StorageManager.getInstance();
|
||||||
|
await storageManager.switchStorage(newConfig);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Storage configuration updated. Please restart the application for changes to take effect.',
|
||||||
|
newType: newConfig.type,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to switch storage:', error);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证存储配置
|
||||||
|
uploadRest.post('/storage/validate', async (c) => {
|
||||||
|
try {
|
||||||
|
const config = (await c.req.json()) as StorageConfig;
|
||||||
|
const { validateStorageConfig } = await import('./storage.adapter');
|
||||||
|
const errors = validateStorageConfig(config);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return c.json({ valid: false, errors }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ valid: true, message: 'Storage configuration is valid' });
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
errors: [error instanceof Error ? error.message : 'Invalid JSON'],
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TUS 协议处理 - 使用通用处理器
|
||||||
|
uploadRest.all('/*', async (c) => {
|
||||||
|
try {
|
||||||
|
await handleTusRequest(c.req.raw, c.res);
|
||||||
|
return new Response(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TUS request error:', error);
|
||||||
|
return c.json({ error: 'Upload request failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default uploadRest;
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function extractFileIdFromNginxUrl(url: string) {
|
||||||
|
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
dotenv.config();
|
||||||
|
export function getFilenameWithoutExt(filename: string | null | undefined) {
|
||||||
|
return filename ? filename.replace(/\.[^/.]+$/, '') : filename || dayjs().format('YYYYMMDDHHmmss');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 计算文件的 SHA-256 哈希值
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns Promise<string> 返回文件的哈希值(十六进制字符串)
|
||||||
|
*/
|
||||||
|
export async function calculateFileHash(filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 创建一个 SHA-256 哈希对象
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
// 创建文件读取流
|
||||||
|
const readStream = createReadStream(filePath);
|
||||||
|
// 处理读取错误
|
||||||
|
readStream.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to read file: ${error.message}`));
|
||||||
|
});
|
||||||
|
// 处理哈希计算错误
|
||||||
|
hash.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to calculate hash: ${error.message}`));
|
||||||
|
});
|
||||||
|
// 流式处理文件内容
|
||||||
|
readStream
|
||||||
|
.pipe(hash)
|
||||||
|
.on('finish', () => {
|
||||||
|
// 获取最终的哈希值(十六进制格式)
|
||||||
|
const fileHash = hash.digest('hex');
|
||||||
|
resolve(fileHash);
|
||||||
|
})
|
||||||
|
.on('error', (error) => {
|
||||||
|
reject(new Error(`Hash calculation failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 Buffer 的 SHA-256 哈希值
|
||||||
|
* @param buffer 要计算哈希的 Buffer
|
||||||
|
* @returns string 返回 Buffer 的哈希值(十六进制字符串)
|
||||||
|
*/
|
||||||
|
export function calculateBufferHash(buffer: Buffer): string {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(buffer);
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算字符串的 SHA-256 哈希值
|
||||||
|
* @param content 要计算哈希的字符串
|
||||||
|
* @returns string 返回字符串的哈希值(十六进制字符串)
|
||||||
|
*/
|
||||||
|
export function calculateStringHash(content: string): string {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(content);
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
export const getUploadFilePath = (fileId: string): string => {
|
||||||
|
const uploadDirectory = process.env.UPLOAD_DIR || '';
|
||||||
|
return path.join(uploadDirectory, fileId);
|
||||||
|
};
|
|
@ -1,11 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useHello, useTRPC, useWebSocket, MessageType } from '@repo/client';
|
import { useHello, useTRPC, useWebSocket, MessageType } from '@repo/client';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
export default function WebSocketPage() {
|
export default function WebSocketPage() {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions());
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [roomId, setRoomId] = useState('');
|
const [roomId, setRoomId] = useState('');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
@ -16,9 +15,13 @@ export default function WebSocketPage() {
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
setTimeout(scrollToBottom, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当消息更新时自动滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const handleJoinRoom = async () => {
|
const handleJoinRoom = async () => {
|
||||||
const success = await joinRoom(roomId.trim());
|
const success = await joinRoom(roomId.trim());
|
||||||
if (success) {
|
if (success) {
|
||||||
|
@ -48,6 +51,14 @@ export default function WebSocketPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理房间ID输入框的回车事件
|
||||||
|
const handleRoomKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleJoinRoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">WebSocket 房间测试</h1>
|
<h1 className="text-2xl font-bold mb-4">WebSocket 房间测试</h1>
|
||||||
|
@ -61,7 +72,7 @@ export default function WebSocketPage() {
|
||||||
type="text"
|
type="text"
|
||||||
value={roomId}
|
value={roomId}
|
||||||
onChange={(e) => setRoomId(e.target.value)}
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleRoomKeyPress}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
className="border border-gray-300 rounded px-3 py-2 flex-1"
|
className="border border-gray-300 rounded px-3 py-2 flex-1"
|
||||||
placeholder="输入房间ID..."
|
placeholder="输入房间ID..."
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as tus from "tus-js-client";
|
||||||
|
import { env } from "../env";
|
||||||
|
import { getCompressedImageUrl } from "@nice/utils";
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
compressedUrl: string;
|
||||||
|
url: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTusUpload() {
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getFileId = (url: string) => {
|
||||||
|
const parts = url.split("/");
|
||||||
|
const uploadIndex = parts.findIndex((part) => part === "upload");
|
||||||
|
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||||
|
throw new Error("Invalid upload URL format");
|
||||||
|
}
|
||||||
|
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/");
|
||||||
|
};
|
||||||
|
const getResourceUrl = (url: string) => {
|
||||||
|
const parts = url.split("/");
|
||||||
|
const uploadIndex = parts.findIndex((part) => part === "upload");
|
||||||
|
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||||
|
throw new Error("Invalid upload URL format");
|
||||||
|
}
|
||||||
|
const resUrl = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
|
||||||
|
return resUrl;
|
||||||
|
};
|
||||||
|
const handleFileUpload = async (
|
||||||
|
file: File | Blob,
|
||||||
|
onSuccess: (result: UploadResult) => void,
|
||||||
|
onError: (error: Error) => void,
|
||||||
|
fileKey: string // 添加文件唯一标识
|
||||||
|
) => {
|
||||||
|
// console.log()
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 }));
|
||||||
|
setUploadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果是Blob,需要转换为File
|
||||||
|
let fileName = "uploaded-file";
|
||||||
|
if (file instanceof Blob && !(file instanceof File)) {
|
||||||
|
// 根据MIME类型设置文件扩展名
|
||||||
|
const extension = file.type.split('/')[1];
|
||||||
|
fileName = `uploaded-file.${extension}`;
|
||||||
|
}
|
||||||
|
const uploadFile = file instanceof Blob && !(file instanceof File)
|
||||||
|
? new File([file], fileName, { type: file.type })
|
||||||
|
: file as File;
|
||||||
|
console.log(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`);
|
||||||
|
const upload = new tus.Upload(uploadFile, {
|
||||||
|
endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`,
|
||||||
|
retryDelays: [0, 1000, 3000, 5000],
|
||||||
|
metadata: {
|
||||||
|
filename: uploadFile.name,
|
||||||
|
filetype: uploadFile.type,
|
||||||
|
size: uploadFile.size as any,
|
||||||
|
},
|
||||||
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
|
const progress = Number(
|
||||||
|
((bytesUploaded / bytesTotal) * 100).toFixed(2)
|
||||||
|
);
|
||||||
|
setUploadProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fileKey]: progress,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onSuccess: async (payload) => {
|
||||||
|
if (upload.url) {
|
||||||
|
const fileId = getFileId(upload.url);
|
||||||
|
//console.log(fileId)
|
||||||
|
const url = getResourceUrl(upload.url);
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fileKey]: 100,
|
||||||
|
}));
|
||||||
|
onSuccess({
|
||||||
|
compressedUrl: getCompressedImageUrl(url),
|
||||||
|
url,
|
||||||
|
fileId,
|
||||||
|
fileName: uploadFile.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const err =
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Unknown error");
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadError(error.message);
|
||||||
|
console.log(error);
|
||||||
|
onError(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upload.start();
|
||||||
|
} catch (error) {
|
||||||
|
const err =
|
||||||
|
error instanceof Error ? error : new Error("Upload failed");
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadError(err.message);
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadProgress,
|
||||||
|
isUploading,
|
||||||
|
uploadError,
|
||||||
|
handleFileUpload,
|
||||||
|
};
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
@ -24,6 +24,7 @@
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"tus-js-client": "^4.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"superjson": "^2.2.2"
|
"superjson": "^2.2.2"
|
||||||
},
|
},
|
||||||
|
@ -31,7 +32,6 @@
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@repo/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
@ -37,11 +37,11 @@ export class WebSocketClient {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data) as WSMessage;
|
const message = JSON.parse(event.data) as WSMessage;
|
||||||
|
|
||||||
// 只处理系统消息、错误消息,或者当前房间的消息
|
// 处理所有系统消息、错误消息,以及当前房间的所有消息
|
||||||
if (
|
if (
|
||||||
message.action === 'system' ||
|
message.action === 'system' ||
|
||||||
message.action === 'error' ||
|
message.action === 'error' ||
|
||||||
(message.roomId && message.roomId === this.currentRoom)
|
(message.action === 'message' && message.roomId === this.currentRoom)
|
||||||
) {
|
) {
|
||||||
console.log('收到消息:', message);
|
console.log('收到消息:', message);
|
||||||
// 触发消息处理器
|
// 触发消息处理器
|
||||||
|
|
|
@ -20,6 +20,6 @@
|
||||||
"@repo/backend/*": ["../../apps/backend/src/*"]
|
"@repo/backend/*": ["../../apps/backend/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../../apps/web/hooks/useTusUpload.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "resource" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" TEXT,
|
||||||
|
"fileId" TEXT,
|
||||||
|
"url" TEXT,
|
||||||
|
"meta" JSONB,
|
||||||
|
"status" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3),
|
||||||
|
"created_by" TEXT,
|
||||||
|
"updated_by" TEXT,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
"is_public" BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "resource_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "resource_fileId_key" ON "resource"("fileId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "resource_type_idx" ON "resource"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "resource_created_at_idx" ON "resource"("created_at");
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "resource" ADD COLUMN "storage_type" TEXT;
|
|
@ -112,3 +112,25 @@ model OidcClient {
|
||||||
|
|
||||||
@@map("oidc_clients")
|
@@map("oidc_clients")
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
|
}
|
155
pnpm-lock.yaml
155
pnpm-lock.yaml
|
@ -41,12 +41,21 @@ importers:
|
||||||
'@repo/db':
|
'@repo/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/db
|
version: link:../../packages/db
|
||||||
|
'@repo/tus':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/tus
|
||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: 11.1.2
|
specifier: 11.1.2
|
||||||
version: 11.1.2(typescript@5.8.3)
|
version: 11.1.2(typescript@5.8.3)
|
||||||
'@types/oidc-provider':
|
'@types/oidc-provider':
|
||||||
specifier: ^9.1.0
|
specifier: ^9.1.0
|
||||||
version: 9.1.0
|
version: 9.1.0
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.7
|
||||||
|
version: 16.5.0
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.7.10
|
specifier: ^4.7.10
|
||||||
version: 4.7.10
|
version: 4.7.10
|
||||||
|
@ -62,6 +71,9 @@ importers:
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.5
|
specifier: ^5.1.5
|
||||||
version: 5.1.5
|
version: 5.1.5
|
||||||
|
nanoid-cjs:
|
||||||
|
specifier: ^0.0.7
|
||||||
|
version: 0.0.7
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.0.7
|
specifier: ^4.0.7
|
||||||
version: 4.0.7
|
version: 4.0.7
|
||||||
|
@ -71,6 +83,9 @@ importers:
|
||||||
superjson:
|
superjson:
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
transliteration:
|
||||||
|
specifier: ^2.3.5
|
||||||
|
version: 2.3.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.25.23
|
specifier: ^3.25.23
|
||||||
version: 3.25.23
|
version: 3.25.23
|
||||||
|
@ -132,6 +147,9 @@ importers:
|
||||||
superjson:
|
superjson:
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
tus-js-client:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.3.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@repo/eslint-config':
|
'@repo/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
@ -2670,6 +2688,9 @@ packages:
|
||||||
buffer-crc32@0.2.13:
|
buffer-crc32@0.2.13:
|
||||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||||
|
|
||||||
|
buffer-from@1.1.2:
|
||||||
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
|
@ -2826,6 +2847,9 @@ packages:
|
||||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||||
engines: {node: '>=12.5.0'}
|
engines: {node: '>=12.5.0'}
|
||||||
|
|
||||||
|
combine-errors@3.0.3:
|
||||||
|
resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
combined-stream@1.0.8:
|
||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
@ -2933,6 +2957,9 @@ packages:
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
custom-error-instance@2.1.1:
|
||||||
|
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
|
||||||
|
|
||||||
data-uri-to-buffer@6.0.2:
|
data-uri-to-buffer@6.0.2:
|
||||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
@ -3075,6 +3102,10 @@ packages:
|
||||||
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
|
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv@16.5.0:
|
||||||
|
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
@ -3849,6 +3880,9 @@ packages:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
js-base64@3.7.7:
|
||||||
|
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
@ -3995,6 +4029,24 @@ packages:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash._baseiteratee@4.7.0:
|
||||||
|
resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==}
|
||||||
|
|
||||||
|
lodash._basetostring@4.12.0:
|
||||||
|
resolution: {integrity: sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==}
|
||||||
|
|
||||||
|
lodash._baseuniq@4.6.0:
|
||||||
|
resolution: {integrity: sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==}
|
||||||
|
|
||||||
|
lodash._createset@4.0.3:
|
||||||
|
resolution: {integrity: sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==}
|
||||||
|
|
||||||
|
lodash._root@3.0.1:
|
||||||
|
resolution: {integrity: sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==}
|
||||||
|
|
||||||
|
lodash._stringtopath@4.8.0:
|
||||||
|
resolution: {integrity: sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==}
|
||||||
|
|
||||||
lodash.camelcase@4.3.0:
|
lodash.camelcase@4.3.0:
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
|
@ -4017,6 +4069,9 @@ packages:
|
||||||
lodash.throttle@4.1.1:
|
lodash.throttle@4.1.1:
|
||||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||||
|
|
||||||
|
lodash.uniqby@4.5.0:
|
||||||
|
resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
@ -4161,6 +4216,9 @@ packages:
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
|
nanoid-cjs@0.0.7:
|
||||||
|
resolution: {integrity: sha512-z72crZ0JcTb5s40Pm9Vk99qfEw9Oe1qyVjK/kpelCKyZDH8YTX4HejSfp54PMJT8F5rmsiBpG6wfVAGAhLEFhA==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
@ -4484,6 +4542,9 @@ packages:
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
|
proper-lockfile@4.1.2:
|
||||||
|
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
||||||
|
|
||||||
proxy-agent@6.5.0:
|
proxy-agent@6.5.0:
|
||||||
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
|
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
@ -4499,6 +4560,9 @@ packages:
|
||||||
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
querystringify@2.2.0:
|
||||||
|
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
@ -4595,6 +4659,9 @@ packages:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
requires-port@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -4619,6 +4686,10 @@ packages:
|
||||||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
retry@0.12.0:
|
||||||
|
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
reusify@1.1.0:
|
reusify@1.1.0:
|
||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
@ -5010,6 +5081,11 @@ packages:
|
||||||
tr46@1.0.1:
|
tr46@1.0.1:
|
||||||
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
||||||
|
|
||||||
|
transliteration@2.3.5:
|
||||||
|
resolution: {integrity: sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
tree-kill@1.2.2:
|
tree-kill@1.2.2:
|
||||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
@ -5122,6 +5198,10 @@ packages:
|
||||||
resolution: {integrity: sha512-iHuaNcq5GZZnr3XDZNuu2LSyCzAOPwDuo5Qt+q64DfsTP1i3T2bKfxJhni2ZQxsvAoxRbuUK5QetJki4qc5aYA==}
|
resolution: {integrity: sha512-iHuaNcq5GZZnr3XDZNuu2LSyCzAOPwDuo5Qt+q64DfsTP1i3T2bKfxJhni2ZQxsvAoxRbuUK5QetJki4qc5aYA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tus-js-client@4.3.1:
|
||||||
|
resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tw-animate-css@1.3.0:
|
tw-animate-css@1.3.0:
|
||||||
resolution: {integrity: sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw==}
|
resolution: {integrity: sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw==}
|
||||||
|
|
||||||
|
@ -5229,6 +5309,9 @@ packages:
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
url-parse@1.5.10:
|
||||||
|
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||||
|
|
||||||
use-callback-ref@1.3.3:
|
use-callback-ref@1.3.3:
|
||||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -7861,6 +7944,8 @@ snapshots:
|
||||||
|
|
||||||
buffer-crc32@0.2.13: {}
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
@ -8036,6 +8121,11 @@ snapshots:
|
||||||
color-string: 1.9.1
|
color-string: 1.9.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
combine-errors@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
custom-error-instance: 2.1.1
|
||||||
|
lodash.uniqby: 4.5.0
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
combined-stream@1.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
|
@ -8145,6 +8235,8 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
custom-error-instance@2.1.1: {}
|
||||||
|
|
||||||
data-uri-to-buffer@6.0.2: {}
|
data-uri-to-buffer@6.0.2: {}
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
|
@ -8279,6 +8371,8 @@ snapshots:
|
||||||
|
|
||||||
dotenv@16.4.5: {}
|
dotenv@16.4.5: {}
|
||||||
|
|
||||||
|
dotenv@16.5.0: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
@ -9289,6 +9383,8 @@ snapshots:
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
|
js-base64@3.7.7: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.0:
|
js-yaml@4.1.0:
|
||||||
|
@ -9424,6 +9520,25 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash._baseiteratee@4.7.0:
|
||||||
|
dependencies:
|
||||||
|
lodash._stringtopath: 4.8.0
|
||||||
|
|
||||||
|
lodash._basetostring@4.12.0: {}
|
||||||
|
|
||||||
|
lodash._baseuniq@4.6.0:
|
||||||
|
dependencies:
|
||||||
|
lodash._createset: 4.0.3
|
||||||
|
lodash._root: 3.0.1
|
||||||
|
|
||||||
|
lodash._createset@4.0.3: {}
|
||||||
|
|
||||||
|
lodash._root@3.0.1: {}
|
||||||
|
|
||||||
|
lodash._stringtopath@4.8.0:
|
||||||
|
dependencies:
|
||||||
|
lodash._basetostring: 4.12.0
|
||||||
|
|
||||||
lodash.camelcase@4.3.0: {}
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
@ -9438,6 +9553,11 @@ snapshots:
|
||||||
|
|
||||||
lodash.throttle@4.1.1: {}
|
lodash.throttle@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.uniqby@4.5.0:
|
||||||
|
dependencies:
|
||||||
|
lodash._baseiteratee: 4.7.0
|
||||||
|
lodash._baseuniq: 4.6.0
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
log-symbols@3.0.0:
|
log-symbols@3.0.0:
|
||||||
|
@ -9579,6 +9699,10 @@ snapshots:
|
||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
thenify-all: 1.6.0
|
thenify-all: 1.6.0
|
||||||
|
|
||||||
|
nanoid-cjs@0.0.7:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 5.1.5
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
nanoid@5.1.5: {}
|
nanoid@5.1.5: {}
|
||||||
|
@ -9924,6 +10048,12 @@ snapshots:
|
||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
|
|
||||||
|
proper-lockfile@4.1.2:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
retry: 0.12.0
|
||||||
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
proxy-agent@6.5.0:
|
proxy-agent@6.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.3
|
agent-base: 7.1.3
|
||||||
|
@ -9948,6 +10078,8 @@ snapshots:
|
||||||
split-on-first: 1.1.0
|
split-on-first: 1.1.0
|
||||||
strict-uri-encode: 2.0.0
|
strict-uri-encode: 2.0.0
|
||||||
|
|
||||||
|
querystringify@2.2.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
quick-lru@7.0.1: {}
|
quick-lru@7.0.1: {}
|
||||||
|
@ -10051,6 +10183,8 @@ snapshots:
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
requires-port@1.0.0: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-from@5.0.0: {}
|
resolve-from@5.0.0: {}
|
||||||
|
@ -10074,6 +10208,8 @@ snapshots:
|
||||||
onetime: 5.1.2
|
onetime: 5.1.2
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
|
retry@0.12.0: {}
|
||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
|
@ -10544,6 +10680,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
transliteration@2.3.5:
|
||||||
|
dependencies:
|
||||||
|
yargs: 17.7.2
|
||||||
|
|
||||||
tree-kill@1.2.2: {}
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
ts-api-utils@2.1.0(typescript@5.8.3):
|
ts-api-utils@2.1.0(typescript@5.8.3):
|
||||||
|
@ -10665,6 +10805,16 @@ snapshots:
|
||||||
turbo-windows-64: 2.5.3
|
turbo-windows-64: 2.5.3
|
||||||
turbo-windows-arm64: 2.5.3
|
turbo-windows-arm64: 2.5.3
|
||||||
|
|
||||||
|
tus-js-client@4.3.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-from: 1.1.2
|
||||||
|
combine-errors: 3.0.3
|
||||||
|
is-stream: 2.0.1
|
||||||
|
js-base64: 3.7.7
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
proper-lockfile: 4.1.2
|
||||||
|
url-parse: 1.5.10
|
||||||
|
|
||||||
tw-animate-css@1.3.0: {}
|
tw-animate-css@1.3.0: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
|
@ -10775,6 +10925,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
url-parse@1.5.10:
|
||||||
|
dependencies:
|
||||||
|
querystringify: 2.2.0
|
||||||
|
requires-port: 1.0.0
|
||||||
|
|
||||||
use-callback-ref@1.3.3(@types/react@19.1.5)(react@19.1.0):
|
use-callback-ref@1.3.3(@types/react@19.1.5)(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
35
turbo.json
35
turbo.json
|
@ -1,8 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalDependencies": [
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"**/.env.*local"
|
|
||||||
],
|
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": ["^db:generate"],
|
"dependsOn": ["^db:generate"],
|
||||||
|
@ -11,25 +9,14 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build", "^db:generate"],
|
"dependsOn": ["^build", "^db:generate"],
|
||||||
"inputs": [
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
"$TURBO_DEFAULT$",
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
".env*"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
"dist/**",
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^lint"]
|
||||||
"^lint"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"check-types": {
|
"check-types": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^check-types"]
|
||||||
"^check-types"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
@ -48,20 +35,14 @@
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
"generate": {
|
"generate": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^generate"],
|
||||||
"^generate"
|
|
||||||
],
|
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"outputs": [
|
"outputs": ["coverage/**"]
|
||||||
"coverage/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"test:e2e": {
|
"test:e2e": {
|
||||||
"outputs": [
|
"outputs": ["coverage-e2e/**"]
|
||||||
"coverage-e2e/**"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue