From 4dce2dc613140f8aee83dc34d68ec621d86488b9 Mon Sep 17 00:00:00 2001 From: ditiqi Date: Wed, 28 May 2025 08:23:15 +0800 Subject: [PATCH] add --- apps/backend/.env.example | 4 +- apps/backend/package.json | 4 + apps/backend/src/index.ts | 13 + apps/backend/src/oidc/config.ts | 190 +++++++------- apps/backend/src/socket.ts | 10 +- apps/backend/src/upload/README.md | 232 ++++++++++++++++++ apps/backend/src/upload/scheduler.ts | 40 +++ apps/backend/src/upload/storage.adapter.ts | 185 ++++++++++++++ apps/backend/src/upload/storage.utils.ts | 202 +++++++++++++++ apps/backend/src/upload/tus.ts | 153 ++++++++++++ apps/backend/src/upload/types.ts | 29 +++ apps/backend/src/upload/upload.index.ts | 116 +++++++++ apps/backend/src/upload/upload.rest.ts | 198 +++++++++++++++ apps/backend/src/upload/utils.ts | 4 + apps/backend/src/utils/file.ts | 67 +++++ apps/web/app/websocket/page.tsx | 19 +- apps/web/hooks/useTusUpload.ts | 122 +++++++++ apps/web/package.json | 76 +++--- packages/client/src/websocket/client.ts | 4 +- packages/client/tsconfig.json | 2 +- .../20250527111157_init/migration.sql | 28 +++ .../20250527115119_init/migration.sql | 2 + packages/db/prisma/schema.prisma | 22 ++ pnpm-lock.yaml | 155 ++++++++++++ turbo.json | 113 ++++----- 25 files changed, 1779 insertions(+), 211 deletions(-) create mode 100644 apps/backend/src/upload/README.md create mode 100644 apps/backend/src/upload/scheduler.ts create mode 100644 apps/backend/src/upload/storage.adapter.ts create mode 100644 apps/backend/src/upload/storage.utils.ts create mode 100644 apps/backend/src/upload/tus.ts create mode 100644 apps/backend/src/upload/types.ts create mode 100644 apps/backend/src/upload/upload.index.ts create mode 100644 apps/backend/src/upload/upload.rest.ts create mode 100644 apps/backend/src/upload/utils.ts create mode 100644 apps/backend/src/utils/file.ts create mode 100644 apps/web/hooks/useTusUpload.ts create mode 100644 packages/db/prisma/migrations/20250527111157_init/migration.sql create mode 100644 packages/db/prisma/migrations/20250527115119_init/migration.sql diff --git a/apps/backend/.env.example b/apps/backend/.env.example index df91ae3..589f11c 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -12,4 +12,6 @@ REDIS_PASSWORD=nice # OIDC_COOKIE_KEY= OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret -OIDC_REDIRECT_URI=https://your-frontend.com/callback \ No newline at end of file +OIDC_REDIRECT_URI=https://your-frontend.com/callback + +UPLOAD_DIR=/opt/projects/nice/uploads \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index c08ed25..1472d63 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -16,9 +16,13 @@ "jose": "^6.0.11", "minio": "7.1.3", "nanoid": "^5.1.5", + "nanoid-cjs": "^0.0.7", + "transliteration": "^2.3.5", "node-cron": "^4.0.7", "oidc-provider": "^9.1.1", "superjson": "^2.2.2", + "dayjs": "^1.11.13", + "dotenv": "^16.4.7", "zod": "^3.25.23" }, "devDependencies": { diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f2f5b74..8962108 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -15,6 +15,11 @@ import { appRouter } from './trpc'; import { createBunWebSocket } from 'hono/bun'; import { wsHandler, wsConfig } from './socket'; +// 导入新的路由 +import userRest from './user/user.rest'; +import uploadRest from './upload/upload.rest'; +import { startCleanupScheduler } from './upload/scheduler'; + type Env = { Variables: { 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) => { // @ts-ignore await oidc.callback(c.req.raw, c.res.raw); @@ -60,6 +69,10 @@ app.use('/oidc/*', async (c, next) => { // 添加 WebSocket 路由 app.get('/ws', wsHandler); + +// 启动上传清理定时任务 +startCleanupScheduler(); + const bunServerConfig = { port: 3000, fetch: app.fetch, diff --git a/apps/backend/src/oidc/config.ts b/apps/backend/src/oidc/config.ts index 09c71f6..e518af0 100644 --- a/apps/backend/src/oidc/config.ts +++ b/apps/backend/src/oidc/config.ts @@ -3,107 +3,109 @@ import { RedisAdapter } from './redis-adapter'; import { prisma } from '@repo/db'; async function getClients() { - const dbClients = await prisma.oidcClient.findMany?.(); - const dbClientList = (dbClients && dbClients.length > 0) - ? dbClients.map(c => ({ - client_id: c.clientId, - client_secret: c.clientSecret, - grant_types: JSON.parse(c.grantTypes), // string -> string[] - redirect_uris: JSON.parse(c.redirectUris), // string -> string[] - response_types: JSON.parse(c.responseTypes), // string -> string[] - scope: c.scope, - })) - : []; + const dbClients = await prisma.oidcClient.findMany?.(); + const dbClientList = + dbClients && dbClients.length > 0 + ? dbClients.map((c) => ({ + client_id: c.clientId, + client_secret: c.clientSecret, + grant_types: JSON.parse(c.grantTypes), // string -> string[] + redirect_uris: JSON.parse(c.redirectUris), // string -> string[] + response_types: JSON.parse(c.responseTypes), // string -> string[] + scope: c.scope, + })) + : []; - // 管理后台client,通过环境变量读取 - const defaultClient = { - client_id: process.env.OIDC_CLIENT_ID || 'admin-client', - client_secret: process.env.OIDC_CLIENT_SECRET || 'admin-secret', - grant_types: ['authorization_code', 'refresh_token'], - redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'], - response_types: ['code'], - scope: 'openid email profile', - }; + // 管理后台client,通过环境变量读取 + const defaultClient = { + client_id: process.env.OIDC_CLIENT_ID || 'admin-client', + client_secret: process.env.OIDC_CLIENT_SECRET || 'admin-secret', + grant_types: ['authorization_code', 'refresh_token'], + redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'], + response_types: ['code'], + scope: 'openid email profile', + }; - // 检查是否与数据库client_id重复 - const allClients = [defaultClient, ...dbClientList.filter(c => c.client_id !== defaultClient.client_id)]; + // 检查是否与数据库client_id重复 + const allClients = [defaultClient, ...dbClientList.filter((c) => c.client_id !== defaultClient.client_id)]; - return allClients; + return allClients; } const OIDC_COOKIE_KEY = process.env.OIDC_COOKIE_KEY || 'HrbEPlzByV0CcjFJhl2pjKV2iG8FgQIc'; const config: Configuration = { - adapter: RedisAdapter, - // 注意:clients字段现在是Promise,需在Provider初始化时await - clients: await getClients(), - pkce: { - required: () => true, - }, - features: { - devInteractions: { enabled: false }, - resourceIndicators: { enabled: true }, - revocation: { enabled: true }, - userinfo: { enabled: true }, - registration: { enabled: true }, - }, - cookies: { - keys: [OIDC_COOKIE_KEY], - }, - jwks: { - keys: [ - { - d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ', - dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0', - dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc', - e: 'AQAB', - kty: 'RSA', - n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ', - p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM', - q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M', - qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU', - use: 'sig', - }, { - crv: 'P-256', - d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws', - kty: 'EC', - use: 'sig', - x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4', - y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4', - }, - ], - }, - ttl: { - AccessToken: 3600, - AuthorizationCode: 600, - IdToken: 3600, - RefreshToken: 1209600, - BackchannelAuthenticationRequest: 600, - ClientCredentials: 600, - DeviceCode: 600, - Grant: 1209600, - Interaction: 3600, - Session: 1209600, - RegistrationAccessToken: 3600, - DPoPProof: 300, - PushedAuthorizationRequest: 600, - ReplayDetection: 3600, - LogoutToken: 600, - }, - findAccount: async (ctx, id) => { - const user = await prisma.user.findUnique({ where: { id } }); - if (!user) return undefined; - return { - accountId: user.id, - async claims() { - return { - sub: user.id, - email: user.email, - name: user.name, - }; - }, - }; - }, + adapter: RedisAdapter, + // 注意:clients字段现在是Promise,需在Provider初始化时await + clients: await getClients(), + pkce: { + required: () => true, + }, + features: { + devInteractions: { enabled: false }, + resourceIndicators: { enabled: true }, + revocation: { enabled: true }, + userinfo: { enabled: true }, + registration: { enabled: true }, + }, + cookies: { + keys: [OIDC_COOKIE_KEY], + }, + jwks: { + keys: [ + { + d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ', + dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0', + dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc', + e: 'AQAB', + kty: 'RSA', + n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ', + p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM', + q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M', + qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU', + use: 'sig', + }, + { + crv: 'P-256', + d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws', + kty: 'EC', + use: 'sig', + x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4', + y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4', + }, + ], + }, + ttl: { + AccessToken: 3600, + AuthorizationCode: 600, + IdToken: 3600, + RefreshToken: 1209600, + BackchannelAuthenticationRequest: 600, + ClientCredentials: 600, + DeviceCode: 600, + Grant: 1209600, + Interaction: 3600, + Session: 1209600, + RegistrationAccessToken: 3600, + DPoPProof: 300, + PushedAuthorizationRequest: 600, + ReplayDetection: 3600, + LogoutToken: 600, + }, + findAccount: async (ctx, id) => { + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) return undefined; + return { + accountId: user.id, + async claims() { + return { + sub: user.id, + email: user.email, + name: user.name, + }; + }, + }; + }, }; -export default config; \ No newline at end of file +export default config; diff --git a/apps/backend/src/socket.ts b/apps/backend/src/socket.ts index 27c5269..95e78b6 100644 --- a/apps/backend/src/socket.ts +++ b/apps/backend/src/socket.ts @@ -14,7 +14,7 @@ interface WSMessageParams { } // 定义消息类型接口 interface WSMessage { - type: 'join' | 'leave' | 'message'; + action: 'join' | 'leave' | 'message'; roomId: string; data: WSMessageParams; } @@ -39,7 +39,7 @@ const wsHandler = upgradeWebSocket((c) => { const parsedMessage: WSMessage = JSON.parse(message.data as any); console.log('收到消息:', parsedMessage); - switch (parsedMessage.type) { + switch (parsedMessage.action) { case 'join': // 加入房间 if (!rooms.has(parsedMessage.roomId)) { @@ -51,7 +51,7 @@ const wsHandler = upgradeWebSocket((c) => { // 发送加入成功消息 ws.send( JSON.stringify({ - type: 'system', + action: 'system', data: { text: `成功加入房间 ${parsedMessage.roomId}`, type: MessageType.TEXT, @@ -75,7 +75,7 @@ const wsHandler = upgradeWebSocket((c) => { const room = rooms.get(parsedMessage.roomId); if (room) { const messageToSend = { - type: 'message', + action: 'message', data: parsedMessage.data, roomId: parsedMessage.roomId, }; @@ -92,7 +92,7 @@ const wsHandler = upgradeWebSocket((c) => { console.error('处理消息时出错:', error); ws.send( JSON.stringify({ - type: 'error', + action: 'error', data: { text: '消息处理失败', type: MessageType.TEXT, diff --git a/apps/backend/src/upload/README.md b/apps/backend/src/upload/README.md new file mode 100644 index 0000000..38aac07 --- /dev/null +++ b/apps/backend/src/upload/README.md @@ -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 更新数据库标记 + +无需代码修改,仅通过环境变量即可实现存储后端的无感切换。 diff --git a/apps/backend/src/upload/scheduler.ts b/apps/backend/src/upload/scheduler.ts new file mode 100644 index 0000000..fe4d09f --- /dev/null +++ b/apps/backend/src/upload/scheduler.ts @@ -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; + } +} diff --git a/apps/backend/src/upload/storage.adapter.ts b/apps/backend/src/upload/storage.adapter.ts new file mode 100644 index 0000000..6ba69fd --- /dev/null +++ b/apps/backend/src/upload/storage.adapter.ts @@ -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 { + 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, + }, + }; + } +} diff --git a/apps/backend/src/upload/storage.utils.ts b/apps/backend/src/upload/storage.utils.ts new file mode 100644 index 0000000..1c37c8d --- /dev/null +++ b/apps/backend/src/upload/storage.utils.ts @@ -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 { + 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 { + 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 { + const dataStore = this.storageManager.getDataStore(); + await dataStore.remove(fileId); + } + + /** + * 获取文件流(用于下载) + * @param fileId 文件ID + * @returns 文件流 + */ + public async getFileStream(fileId: string): Promise { + 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 { + 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; + } +} diff --git a/apps/backend/src/upload/tus.ts b/apps/backend/src/upload/tus.ts new file mode 100644 index 0000000..55d8950 --- /dev/null +++ b/apps/backend/src/upload/tus.ts @@ -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(); +} diff --git a/apps/backend/src/upload/types.ts b/apps/backend/src/upload/types.ts new file mode 100644 index 0000000..2140ebc --- /dev/null +++ b/apps/backend/src/upload/types.ts @@ -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; +} +// 添加重试机制,处理临时网络问题 +// 实现定期清理过期的临时文件 +// 添加文件完整性校验 +// 实现上传进度持久化,支持服务重启后恢复 +// 添加并发限制,防止系统资源耗尽 +// 实现文件去重功能,避免重复上传 +// 添加日志记录和监控机制 diff --git a/apps/backend/src/upload/upload.index.ts b/apps/backend/src/upload/upload.index.ts new file mode 100644 index 0000000..83066fc --- /dev/null +++ b/apps/backend/src/upload/upload.index.ts @@ -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 { + return prisma.resource.findMany({ + orderBy: { createdAt: 'desc' }, + }); +} + +export async function getResourcesByStorageType(storageType: StorageType): Promise { + return prisma.resource.findMany({ + where: { + storageType: storageType, + }, + orderBy: { createdAt: 'desc' }, + }); +} + +export async function getResourcesByStatus(status: string): Promise { + return prisma.resource.findMany({ + where: { status }, + orderBy: { createdAt: 'desc' }, + }); +} + +export async function getUploadingResources(): Promise { + return prisma.resource.findMany({ + where: { + status: 'UPLOADING', + }, + orderBy: { createdAt: 'desc' }, + }); +} + +export async function getResourceStats(): Promise<{ + total: number; + byStatus: Record; + byStorageType: Record; +}> { + 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, + ); + + const byStorageType = storageStats.reduce( + (acc, item) => { + const key = (item.storageType as string) || 'unknown'; + acc[key] = item._count; + return acc; + }, + {} as Record, + ); + + return { + total, + byStatus, + byStorageType, + }; +} + +export async function deleteResource(id: string): Promise { + return prisma.resource.delete({ + where: { id }, + }); +} + +export async function updateResource(id: string, data: any): Promise { + 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 }; +} diff --git a/apps/backend/src/upload/upload.rest.ts b/apps/backend/src/upload/upload.rest.ts new file mode 100644 index 0000000..440e457 --- /dev/null +++ b/apps/backend/src/upload/upload.rest.ts @@ -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; diff --git a/apps/backend/src/upload/utils.ts b/apps/backend/src/upload/utils.ts new file mode 100644 index 0000000..a7c189f --- /dev/null +++ b/apps/backend/src/upload/utils.ts @@ -0,0 +1,4 @@ +export function extractFileIdFromNginxUrl(url: string) { + const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/); + return match ? match[1] : ''; +} diff --git a/apps/backend/src/utils/file.ts b/apps/backend/src/utils/file.ts new file mode 100644 index 0000000..812af7e --- /dev/null +++ b/apps/backend/src/utils/file.ts @@ -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 返回文件的哈希值(十六进制字符串) + */ +export async function calculateFileHash(filePath: string): Promise { + 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); +}; diff --git a/apps/web/app/websocket/page.tsx b/apps/web/app/websocket/page.tsx index b044baa..d7f600e 100644 --- a/apps/web/app/websocket/page.tsx +++ b/apps/web/app/websocket/page.tsx @@ -1,11 +1,10 @@ 'use client'; import { useHello, useTRPC, useWebSocket, MessageType } from '@repo/client'; import { useQuery } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; export default function WebSocketPage() { const trpc = useTRPC(); - const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions()); const [message, setMessage] = useState(''); const [roomId, setRoomId] = useState(''); const messagesEndRef = useRef(null); @@ -16,9 +15,13 @@ export default function WebSocketPage() { // 滚动到底部 const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - setTimeout(scrollToBottom, 100); }; + // 当消息更新时自动滚动到底部 + useEffect(() => { + scrollToBottom(); + }, [messages]); + const handleJoinRoom = async () => { const success = await joinRoom(roomId.trim()); 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 (

WebSocket 房间测试

@@ -61,7 +72,7 @@ export default function WebSocketPage() { type="text" value={roomId} onChange={(e) => setRoomId(e.target.value)} - onKeyPress={handleKeyPress} + onKeyPress={handleRoomKeyPress} disabled={connecting} className="border border-gray-300 rounded px-3 py-2 flex-1" placeholder="输入房间ID..." diff --git a/apps/web/hooks/useTusUpload.ts b/apps/web/hooks/useTusUpload.ts new file mode 100644 index 0000000..85c3d31 --- /dev/null +++ b/apps/web/hooks/useTusUpload.ts @@ -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 + >({}); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(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, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index cf560aa..0cbeb59 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,40 +1,40 @@ { - "name": "web", - "version": "0.0.1", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@repo/client": "workspace:*", - "@repo/db": "workspace:*", - "@repo/ui": "workspace:*", - "@tanstack/react-query": "^5.51.21", - "@trpc/client": "11.1.2", - "@trpc/react-query": "11.1.2", - "@trpc/server": "11.1.2", - "@trpc/tanstack-react-query": "11.1.2", - "axios": "^1.7.2", - "dayjs": "^1.11.12", - "lucide-react": "0.511.0", - "next": "15.3.2", - "next-themes": "^0.4.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "superjson": "^2.2.2" - }, - "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - - "@types/react": "^19.1.4", - "@types/react-dom": "^19.1.5", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "web", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev --turbopack -p 3001", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@repo/client": "workspace:*", + "@repo/db": "workspace:*", + "@repo/ui": "workspace:*", + "@tanstack/react-query": "^5.51.21", + "@trpc/client": "11.1.2", + "@trpc/react-query": "11.1.2", + "@trpc/server": "11.1.2", + "@trpc/tanstack-react-query": "11.1.2", + "axios": "^1.7.2", + "dayjs": "^1.11.12", + "lucide-react": "0.511.0", + "next": "15.3.2", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "tus-js-client": "^4.1.0", + "react-dom": "^19.1.0", + "superjson": "^2.2.2" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@tailwindcss/postcss": "^4", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.5", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/packages/client/src/websocket/client.ts b/packages/client/src/websocket/client.ts index 01d29b1..b206ec9 100644 --- a/packages/client/src/websocket/client.ts +++ b/packages/client/src/websocket/client.ts @@ -37,11 +37,11 @@ export class WebSocketClient { try { const message = JSON.parse(event.data) as WSMessage; - // 只处理系统消息、错误消息,或者当前房间的消息 + // 处理所有系统消息、错误消息,以及当前房间的所有消息 if ( message.action === 'system' || message.action === 'error' || - (message.roomId && message.roomId === this.currentRoom) + (message.action === 'message' && message.roomId === this.currentRoom) ) { console.log('收到消息:', message); // 触发消息处理器 diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 91b3139..4bbd830 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -20,6 +20,6 @@ "@repo/backend/*": ["../../apps/backend/src/*"] } }, - "include": ["src"], + "include": ["src", "../../apps/web/hooks/useTusUpload.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/db/prisma/migrations/20250527111157_init/migration.sql b/packages/db/prisma/migrations/20250527111157_init/migration.sql new file mode 100644 index 0000000..1e4adf9 --- /dev/null +++ b/packages/db/prisma/migrations/20250527111157_init/migration.sql @@ -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"); diff --git a/packages/db/prisma/migrations/20250527115119_init/migration.sql b/packages/db/prisma/migrations/20250527115119_init/migration.sql new file mode 100644 index 0000000..2a11431 --- /dev/null +++ b/packages/db/prisma/migrations/20250527115119_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "resource" ADD COLUMN "storage_type" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6c5b6c2..4f925dd 100755 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -112,3 +112,25 @@ model OidcClient { @@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") +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da40f57..90a6a98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,21 @@ importers: '@repo/db': specifier: workspace:* version: link:../../packages/db + '@repo/tus': + specifier: workspace:* + version: link:../../packages/tus '@trpc/server': specifier: 11.1.2 version: 11.1.2(typescript@5.8.3) '@types/oidc-provider': specifier: ^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: specifier: ^4.7.10 version: 4.7.10 @@ -62,6 +71,9 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.5 + nanoid-cjs: + specifier: ^0.0.7 + version: 0.0.7 node-cron: specifier: ^4.0.7 version: 4.0.7 @@ -71,6 +83,9 @@ importers: superjson: specifier: ^2.2.2 version: 2.2.2 + transliteration: + specifier: ^2.3.5 + version: 2.3.5 zod: specifier: ^3.25.23 version: 3.25.23 @@ -132,6 +147,9 @@ importers: superjson: specifier: ^2.2.2 version: 2.2.2 + tus-js-client: + specifier: ^4.1.0 + version: 4.3.1 devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -2670,6 +2688,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2826,6 +2847,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combine-errors@3.0.3: + resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2933,6 +2957,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + custom-error-instance@2.1.1: + resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3075,6 +3102,10 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3849,6 +3880,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3995,6 +4029,24 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} 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: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -4017,6 +4069,9 @@ packages: lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash.uniqby@4.5.0: + resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4161,6 +4216,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid-cjs@0.0.7: + resolution: {integrity: sha512-z72crZ0JcTb5s40Pm9Vk99qfEw9Oe1qyVjK/kpelCKyZDH8YTX4HejSfp54PMJT8F5rmsiBpG6wfVAGAhLEFhA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4484,6 +4542,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -4499,6 +4560,9 @@ packages: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4595,6 +4659,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4619,6 +4686,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5010,6 +5081,11 @@ packages: tr46@1.0.1: 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: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5122,6 +5198,10 @@ packages: resolution: {integrity: sha512-iHuaNcq5GZZnr3XDZNuu2LSyCzAOPwDuo5Qt+q64DfsTP1i3T2bKfxJhni2ZQxsvAoxRbuUK5QetJki4qc5aYA==} hasBin: true + tus-js-client@4.3.1: + resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==} + engines: {node: '>=18'} + tw-animate-css@1.3.0: resolution: {integrity: sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw==} @@ -5229,6 +5309,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -7861,6 +7944,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -8036,6 +8121,11 @@ snapshots: color-string: 1.9.1 optional: true + combine-errors@3.0.3: + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -8145,6 +8235,8 @@ snapshots: csstype@3.1.3: {} + custom-error-instance@2.1.1: {} + data-uri-to-buffer@6.0.2: {} data-view-buffer@1.0.2: @@ -8279,6 +8371,8 @@ snapshots: dotenv@16.4.5: {} + dotenv@16.5.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9289,6 +9383,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.7: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -9424,6 +9520,25 @@ snapshots: dependencies: 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.defaults@4.2.0: {} @@ -9438,6 +9553,11 @@ snapshots: 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: {} log-symbols@3.0.0: @@ -9579,6 +9699,10 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid-cjs@0.0.7: + dependencies: + nanoid: 5.1.5 + nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -9924,6 +10048,12 @@ snapshots: object-assign: 4.1.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: dependencies: agent-base: 7.1.3 @@ -9948,6 +10078,8 @@ snapshots: split-on-first: 1.1.0 strict-uri-encode: 2.0.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-lru@7.0.1: {} @@ -10051,6 +10183,8 @@ snapshots: require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -10074,6 +10208,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.12.0: {} + reusify@1.1.0: {} rimraf@3.0.2: @@ -10544,6 +10680,10 @@ snapshots: dependencies: punycode: 2.3.1 + transliteration@2.3.5: + dependencies: + yargs: 17.7.2 + tree-kill@1.2.2: {} ts-api-utils@2.1.0(typescript@5.8.3): @@ -10665,6 +10805,16 @@ snapshots: turbo-windows-64: 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: {} type-check@0.4.0: @@ -10775,6 +10925,11 @@ snapshots: dependencies: 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): dependencies: react: 19.1.0 diff --git a/turbo.json b/turbo.json index bed41db..10717c6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,67 +1,48 @@ { - "$schema": "https://turbo.build/schema.json", - "globalDependencies": [ - "**/.env.*local" - ], - "tasks": { - "dev": { - "dependsOn": ["^db:generate"], - "cache": false, - "persistent": true - }, - "build": { - "dependsOn": ["^build", "^db:generate"], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ], - "outputs": [ - "dist/**", - ".next/**", - "!.next/cache/**" - ] - }, - "lint": { - "dependsOn": [ - "^lint" - ] - }, - "check-types": { - "dependsOn": [ - "^check-types" - ] - }, - "db:generate": { - "cache": false - }, - "db:migrate": { - "cache": false, - "persistent": true - }, - "db:deploy": { - "cache": false - }, - "db:push": { - "cache": false - }, - "db:seed": { - "cache": false - }, - "generate": { - "dependsOn": [ - "^generate" - ], - "cache": false - }, - "test": { - "outputs": [ - "coverage/**" - ] - }, - "test:e2e": { - "outputs": [ - "coverage-e2e/**" - ] - } - } -} \ No newline at end of file + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["**/.env.*local"], + "tasks": { + "dev": { + "dependsOn": ["^db:generate"], + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build", "^db:generate"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "lint": { + "dependsOn": ["^lint"] + }, + "check-types": { + "dependsOn": ["^check-types"] + }, + "db:generate": { + "cache": false + }, + "db:migrate": { + "cache": false, + "persistent": true + }, + "db:deploy": { + "cache": false + }, + "db:push": { + "cache": false + }, + "db:seed": { + "cache": false + }, + "generate": { + "dependsOn": ["^generate"], + "cache": false + }, + "test": { + "outputs": ["coverage/**"] + }, + "test:e2e": { + "outputs": ["coverage-e2e/**"] + } + } +}