first commit

This commit is contained in:
feie9456 2025-06-28 15:16:06 +08:00
parent 0072220d05
commit 3c62871948
27 changed files with 2194 additions and 27 deletions

17
.env.example Normal file
View File

@ -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

3
.gitignore vendored
View File

@ -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

247
DEPLOYMENT.md Normal file
View File

@ -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 <repository-url>
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
- 配置防火墙
- 启用访问日志

196
README.md
View File

@ -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 接口,确保客户端代码无需修改即可使用。

74
app/api-test/page.tsx Normal file
View File

@ -0,0 +1,74 @@
export default function ApiTest() {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Winupdate Neo API </h1>
<div className="space-y-4">
<div className="border p-4 rounded">
<h2 className="text-lg font-semibold mb-2">API </h2>
<ul className="space-y-1 text-sm">
<li> GET /api/hosts - </li>
<li> POST /api/hosts/[hostname]/screenshots - </li>
<li> GET /api/hosts/[hostname]/screenshots - </li>
<li> POST /api/hosts/[hostname]/credentials - </li>
<li> GET /api/hosts/[hostname]/credentials - </li>
<li> GET /api/hosts/[hostname]/time-distribution - </li>
<li> GET /api/version - </li>
<li> POST /api/upload/version - </li>
<li> GET /api/screenshots/[fileId] - </li>
<li> GET /api/downloads/[fileId] - </li>
</ul>
</div>
<div className="border p-4 rounded">
<h2 className="text-lg font-semibold mb-2"></h2>
<ul className="space-y-1 text-sm">
<li> Host - </li>
<li> Record - </li>
<li> Window - </li>
<li> Screenshot - </li>
<li> Credential - </li>
<li> Password - </li>
<li> Version - </li>
<li> Nssm - NSSM </li>
</ul>
</div>
<div className="border p-4 rounded">
<h2 className="text-lg font-semibold mb-2"></h2>
<ul className="space-y-1 text-sm">
<li> DATABASE_URL - </li>
<li> AUTH_USERNAME - </li>
<li> AUTH_PASSWORD - </li>
<li> PORT - </li>
</ul>
</div>
<div className="border p-4 rounded">
<h2 className="text-lg font-semibold mb-2">MinIO </h2>
<ul className="space-y-1 text-sm">
<li> 服务器: 192.168.5.13:9000</li>
<li> Bucket: winupdate</li>
<li> 存储结构: 按类型////</li>
<li> 截图路径: screenshots/////</li>
<li> 版本路径: versions///</li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="border p-4 rounded">
<h2 className="text-lg font-semibold mb-2"></h2>
<ul className="space-y-1 text-sm">
<li> </li>
<li> objectName </li>
<li> MinIO </li>
<li> </li>
<li> 访</li>
<li> </li>
</ul>
</div>
</div>
</div>
)
}

View File

@ -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)

View File

@ -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)

View File

@ -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<string, NodeJS.Timeout>()
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)

View File

@ -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)

24
app/api/hosts/route.ts Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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)

31
app/api/version/route.ts Normal file
View File

@ -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)

202
bun.lock
View File

@ -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=="],
}
}

12
lib/config.ts Normal file
View File

@ -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']
}
}

216
lib/fileStorage.ts Normal file
View File

@ -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<void> {
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<StoredFile> {
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<boolean> {
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<void>((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 }
}
}

67
lib/middleware.ts Normal file
View File

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { config } from './config'
export function withAuth(handler: (req: NextRequest) => Promise<NextResponse>) {
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<NextResponse>) {
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
}
}

80
lib/minioClient.ts Normal file
View File

@ -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<boolean> {
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<boolean> {
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
}
}

9
lib/prisma.ts Normal file
View File

@ -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

8
lib/push.ts Normal file
View File

@ -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
}

View File

@ -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",

View File

@ -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;

View File

@ -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");

View File

@ -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"

137
prisma/schema.prisma Normal file
View File

@ -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")
}

84
test-file-operations.ts Normal file
View File

@ -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)

28
test-minio.ts Normal file
View File

@ -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)