commit cfeaafb8f316af3696db5a469f9bb1a662ccb26e Author: WuYongJie Date: Wed Jul 15 17:44:35 2026 +0800 创建由壹点统一通知中心 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7a69eac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +data +*.db +*.log +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..099b047 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +PORT=3088 +# Windows 本机路径包含空格时,Prisma SQLite 请使用完整绝对路径;Docker 会覆盖为 /app/data。 +DATABASE_URL=file:D:/AI Code/YouYiDianNotificationCenter/data/notifications.db + +# “由壹点”机器人所属企业内部应用的凭证,仅允许配置在通知中心。 +DINGTALK_APP_KEY= +DINGTALK_APP_SECRET= +DINGTALK_ROBOT_CODE= + +# 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。 +# 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。 +YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"]}] + +WORKER_ENABLED=true +WORKER_INTERVAL_MS=1000 +READ_STATUS_INTERVAL_MS=5000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2deaed --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +data/ +*.log +*.db +*.db-journal +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d387f17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-alpine AS build +WORKDIR /app +RUN apk add --no-cache openssl +COPY package*.json ./ +RUN npm ci +COPY prisma ./prisma +RUN npx prisma generate +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +RUN apk add --no-cache openssl +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client +COPY --from=build /app/dist ./dist +COPY prisma ./prisma +RUN mkdir -p /app/data +EXPOSE 3088 +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ebeb05 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# 由壹点通知中心 + +由壹点内部项目统一钉钉机器人单聊出口。只有本服务保存钉钉应用密钥和 `RobotCode`;素材库、固定资产、产品开发平台等业务系统使用各自的项目密钥调用本服务。 + +## 安全边界 + +- 每个项目使用独立 HMAC 密钥。 +- 请求签名包含时间、nonce、HTTP 方法、路径和正文摘要。 +- nonce 五分钟内只能使用一次。 +- 每个项目只能跳转到配置的 HTTPS 域名。 +- `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。 +- 项目无法读取其它项目的投递记录。 + +## 本地启动 + +1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。 + Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。 +2. 执行 `npm install`。 +3. 执行 `npm run prisma:generate`。 +4. 执行 `npm run prisma:migrate`。 +5. 执行 `npm run dev`。 + +健康检查:`GET /health`。 + +生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。 + +当前生产部署脚本位于: + +- `scripts/deploy-material-host.sh`:只管理通知中心独立容器、配置和独立 FRP 服务; +- `scripts/deploy-public-nginx.sh`:备份并扩展素材库专用 FRP 端口白名单,然后为素材库公网域名增加 `/notification-center/` 反向代理;脚本会验证主站、S3 和通知中心,失败时回滚。 + +脚本不包含任何密钥。统一机器人凭证从素材库生产环境安全复制到通知中心独立 `.env`,项目 HMAC 密钥在服务器生成并以 `0600` 权限保存。 + +素材库生产 NAS 无法稳定访问 Docker Hub。正式发布应先在构建机或维护工作站构建 `youyidian-notification-center:latest`,将镜像安全传入生产机,再执行部署脚本;脚本检测到预载镜像后会使用 `--no-build` 启动。 + +## 签名算法 + +请求头: + +- `x-yyd-project-code` +- `x-yyd-timestamp`:Unix 秒 +- `x-yyd-nonce`:每次请求生成新的随机字符串 +- `x-yyd-signature`:HMAC-SHA256 十六进制字符串 + +待签名文本: + +```text +timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody) +``` + +发送接口:`POST /v1/notifications`。查询接口:`GET /v1/notifications/:id`。 + +通知中心只接受任务并异步发送,业务系统通过查询接口获取 `pending / sending / sent / unread / read / retrying / failed` 状态。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62ff31e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + notification-center: + image: youyidian-notification-center:${YYD_NOTIFICATION_IMAGE_TAG:-latest} + build: . + container_name: youyidian-notification-center + restart: unless-stopped + ports: + - "127.0.0.1:${PORT:-3088}:3088" + environment: + PORT: 3088 + DATABASE_URL: file:/app/data/notifications.db + DINGTALK_APP_KEY: ${DINGTALK_APP_KEY} + DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET} + DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE} + YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON} + WORKER_ENABLED: ${WORKER_ENABLED:-true} + volumes: + - notification_data:/app/data + +volumes: + notification_data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dcf68b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1587 @@ +{ + "name": "youyidian-notification-center", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "youyidian-notification-center", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^5.22.0", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "prisma": "^5.22.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..911bc0b --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "youyidian-notification-center", + "version": "1.0.0", + "private": true, + "description": "由壹点内部项目统一钉钉单聊通知中心", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "prisma generate && tsc", + "start": "node dist/index.js", + "test": "tsx --test src/__tests__/*.test.ts", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "prisma": "^5.22.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/prisma/migrations/20260715_init/migration.sql b/prisma/migrations/20260715_init/migration.sql new file mode 100644 index 0000000..02c9865 --- /dev/null +++ b/prisma/migrations/20260715_init/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "NotificationDelivery" ( + "id" TEXT NOT NULL PRIMARY KEY, + "projectCode" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "recipientUserId" TEXT NOT NULL, + "businessTitle" TEXT NOT NULL, + "renderedTitle" TEXT NOT NULL, + "content" TEXT NOT NULL, + "actionTitle" TEXT NOT NULL, + "actionUrl" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 4, + "nextAttemptAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "leaseOwner" TEXT, + "leaseExpiresAt" DATETIME, + "processQueryKey" TEXT, + "lastErrorCode" TEXT, + "lastErrorMessage" TEXT, + "sentAt" DATETIME, + "readAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +CREATE TABLE "RequestNonce" ( + "id" TEXT NOT NULL PRIMARY KEY, + "projectCode" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX "NotificationDelivery_projectCode_idempotencyKey_key" ON "NotificationDelivery"("projectCode", "idempotencyKey"); +CREATE INDEX "NotificationDelivery_status_nextAttemptAt_idx" ON "NotificationDelivery"("status", "nextAttemptAt"); +CREATE INDEX "NotificationDelivery_leaseExpiresAt_idx" ON "NotificationDelivery"("leaseExpiresAt"); +CREATE INDEX "NotificationDelivery_processQueryKey_idx" ON "NotificationDelivery"("processQueryKey"); +CREATE UNIQUE INDEX "RequestNonce_projectCode_nonce_key" ON "RequestNonce"("projectCode", "nonce"); +CREATE INDEX "RequestNonce_expiresAt_idx" ON "RequestNonce"("expiresAt"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..6fcf33d --- /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 (i.e. Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7e7c520 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,50 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model NotificationDelivery { + id String @id @default(uuid()) + projectCode String + idempotencyKey String + requestHash String + recipientUserId String + businessTitle String + renderedTitle String + content String + actionTitle String + actionUrl String + status String @default("pending") + attemptCount Int @default(0) + maxAttempts Int @default(4) + nextAttemptAt DateTime @default(now()) + leaseOwner String? + leaseExpiresAt DateTime? + processQueryKey String? + lastErrorCode String? + lastErrorMessage String? + sentAt DateTime? + readAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([projectCode, idempotencyKey]) + @@index([status, nextAttemptAt]) + @@index([leaseExpiresAt]) + @@index([processQueryKey]) +} + +model RequestNonce { + id String @id @default(uuid()) + projectCode String + nonce String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@unique([projectCode, nonce]) + @@index([expiresAt]) +} diff --git a/scripts/deploy-material-host.sh b/scripts/deploy-material-host.sh new file mode 100644 index 0000000..5fa7b33 --- /dev/null +++ b/scripts/deploy-material-host.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail + +target=/mnt/PoolOne/apps/youyidian-notification-center +archive=/tmp/youyidian-notification-center-release.tar.gz +source_env=/mnt/PoolOne/apps/youyidian-3d/.env +backup_root=/mnt/PoolOne/apps/youyidian-notification-center-backups + +test -f "$archive" +test -f "$source_env" +if tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + echo '安装包包含不安全路径' >&2 + exit 1 +fi + +mkdir -p "$target" "$backup_root" +if test -f "$target/package.json"; then + stamp="$(date +%Y%m%d%H%M%S)" + tar -czf "$backup_root/source-$stamp.tar.gz" --exclude=.env --exclude=data -C "$target" . +fi +tar -xzf "$archive" -C "$target" + +python3 - <<'PY' +from pathlib import Path +import json +import os +import secrets +import shutil +import time + + +def parse_env(path: Path): + result = {} + if not path.exists(): + return result + for raw in path.read_text(encoding='utf-8').splitlines(): + line = raw.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + if value.startswith('"') and value.endswith('"'): + try: + value = json.loads(value) + except Exception: + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + result[key] = value + return result + + +def quoted(value): + return json.dumps(str(value), ensure_ascii=False) + + +source_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env') +target_path = Path('/mnt/PoolOne/apps/youyidian-notification-center/.env') +source = parse_env(source_path) +existing = parse_env(target_path) +app_key = ( + source.get('DINGTALK_WORK_NOTICE_CLIENT_ID') + or source.get('DINGTALK_WORK_NOTICE_APP_KEY') + or source.get('DINGTALK_CLIENT_ID') + or source.get('DINGTALK_APP_KEY') +) +app_secret = ( + source.get('DINGTALK_WORK_NOTICE_CLIENT_SECRET') + or source.get('DINGTALK_WORK_NOTICE_APP_SECRET') + or source.get('DINGTALK_CLIENT_SECRET') + or source.get('DINGTALK_APP_SECRET') +) +robot_code = source.get('DINGTALK_ROBOT_CODE') +if not app_key or not app_secret or not robot_code: + raise SystemExit('素材库生产环境缺少统一机器人配置') + +try: + old_projects = json.loads(existing.get('YYD_NOTIFICATION_PROJECTS_JSON', '[]')) +except Exception: + old_projects = [] +by_code = { + str(item.get('code')): item + for item in old_projects + if isinstance(item, dict) +} + + +def secret_for(code): + old = str(by_code.get(code, {}).get('secret') or '') + return old if len(old) >= 24 else secrets.token_hex(32) + + +projects = [ + { + 'code': 'material_library', + 'name': '素材库', + 'secret': secret_for('material_library'), + 'allowedHosts': ['sc.jiansun.vip'], + }, + { + 'code': 'fixed_asset', + 'name': '固定资产', + 'secret': secret_for('fixed_asset'), + 'allowedHosts': ['www.jsgdzc.cn', 'jsgdzc.cn'], + }, +] +center_lines = [ + 'PORT=3088', + 'DINGTALK_APP_KEY=' + quoted(app_key), + 'DINGTALK_APP_SECRET=' + quoted(app_secret), + 'DINGTALK_ROBOT_CODE=' + quoted(robot_code), + 'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted( + json.dumps(projects, ensure_ascii=False, separators=(',', ':')) + ), + 'WORKER_ENABLED=true', + 'WORKER_INTERVAL_MS=1000', + 'READ_STATUS_INTERVAL_MS=5000', +] +target_path.write_text('\n'.join(center_lines) + '\n', encoding='utf-8') +os.chmod(target_path, 0o600) + +material_secret = next( + item['secret'] for item in projects if item['code'] == 'material_library' +) +updates = { + 'YYD_NOTIFICATION_CENTER_URL': 'https://sc.jiansun.vip/notification-center', + 'YYD_NOTIFICATION_CENTER_PROJECT_CODE': 'material_library', + 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET': material_secret, + 'YYD_NOTIFICATION_CENTER_TEST_USER_IDS': '', +} +backup = source_path.with_name( + '.env.before-yyd-notification-' + time.strftime('%Y%m%d%H%M%S') +) +shutil.copy2(source_path, backup) +os.chmod(backup, 0o600) +lines = source_path.read_text(encoding='utf-8').splitlines() +seen = set() +output = [] +for line in lines: + if '=' in line and not line.lstrip().startswith('#'): + key = line.split('=', 1)[0].strip() + if key in updates: + output.append(key + '=' + quoted(updates[key])) + seen.add(key) + continue + output.append(line) +for key, value in updates.items(): + if key not in seen: + output.append(key + '=' + quoted(value)) +source_path.write_text('\n'.join(output) + '\n', encoding='utf-8') +os.chmod(source_path, 0o600) +print('通知中心与素材库适配配置已写入') +PY + +cd "$target" +image="youyidian-notification-center:${YYD_NOTIFICATION_IMAGE_TAG:-latest}" +if docker image inspect "$image" >/dev/null 2>&1; then + docker compose up -d --no-build +else + docker compose up -d --build +fi + +healthy=0 +for _ in $(seq 1 30); do + if curl -fsS --max-time 3 http://127.0.0.1:3088/health >/tmp/yyd-notification-health.json; then + healthy=1 + break + fi + sleep 2 +done +if test "$healthy" != 1; then + docker compose logs --tail 120 + exit 1 +fi + +python3 - <<'PY' +from pathlib import Path + +source = Path('/etc/frp/frpc-sc-entry.toml').read_text(encoding='utf-8') +header = source.split('[[proxies]]', 1)[0] +header = header.replace( + '/root/frp/frpc-sc-entry.log', + '/root/frp/frpc-notify-entry.log', +) +proxy = '''[[proxies]] +name = "youyidian-notification-center" +type = "tcp" +localIP = "127.0.0.1" +localPort = 3088 +remotePort = 2326 +healthCheck.type = "http" +healthCheck.path = "/health" +healthCheck.intervalSeconds = 15 +healthCheck.maxFailed = 3 +healthCheck.timeoutSeconds = 5 +''' +Path('/etc/frp/frpc-notify-entry.toml').write_text( + header.rstrip() + '\n\n' + proxy, + encoding='utf-8', +) +PY +chmod 600 /etc/frp/frpc-notify-entry.toml + +cat > /etc/systemd/system/frpc-notify-entry.service <<'EOF' +[Unit] +Description=FRP Client for Youyidian notification center +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +Restart=always +RestartSec=10s +LimitNOFILE=65535 +ExecStart=/root/frp-upgrade/frp_0.67.0_linux_amd64/frpc -c /etc/frp/frpc-notify-entry.toml +StandardOutput=append:/root/frp/frpc-notify-entry.service.log +StandardError=append:/root/frp/frpc-notify-entry.service.log + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable --now frpc-notify-entry.service +sleep 3 +systemctl is-active --quiet frpc-notify-entry.service + +rm -f "$archive" +curl -fsS http://127.0.0.1:3088/health +echo +printf 'CENTER_CONTAINER=' +docker inspect -f '{{.State.Status}}' youyidian-notification-center +printf 'FRP_SERVICE=' +systemctl is-active frpc-notify-entry.service diff --git a/scripts/deploy-public-nginx.sh b/scripts/deploy-public-nginx.sh new file mode 100644 index 0000000..76f835d --- /dev/null +++ b/scripts/deploy-public-nginx.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +config=/etc/nginx/conf.d/sc.jiansun.vip.conf +frps_config=/etc/frp/frps-sucai.toml +frps_service=frps-sucai.service +test -f "$config" +test -f "$frps_config" +backup="${config}.before-notification-center-$(date +%Y%m%d%H%M%S)" +frps_backup="${frps_config}.before-notification-center-$(date +%Y%m%d%H%M%S)" +cp -a "$config" "$backup" +cp -a "$frps_config" "$frps_backup" + +FRPS_CONFIG="$frps_config" python3 - <<'PY' +from pathlib import Path +import os +import re + +path = Path(os.environ['FRPS_CONFIG']) +text = path.read_text(encoding='utf-8') +match = re.search( + r'allowPorts\s*=\s*\[\s*\{\s*start\s*=\s*2321\s*,\s*end\s*=\s*(\d+)\s*\}\s*,?\s*\]', + text, + re.S, +) +if not match: + raise SystemExit('未识别素材库 FRP 端口白名单,拒绝修改') +if int(match.group(1)) < 2326: + text = text[:match.start(1)] + '2326' + text[match.end(1):] + path.write_text(text, encoding='utf-8') +PY + +if ! /opt/frp-furniture-server/frps verify -c "$frps_config"; then + cp -a "$frps_backup" "$frps_config" + echo 'FRP 服务端配置验证失败,已回滚' >&2 + exit 1 +fi +if ! systemctl restart "$frps_service"; then + cp -a "$frps_backup" "$frps_config" + systemctl restart "$frps_service" + echo 'FRP 服务端重启失败,已回滚' >&2 + exit 1 +fi + +frp_ready=0 +for _ in $(seq 1 20); do + if ss -lnt | grep -q ':2321 ' \ + && ss -lnt | grep -q ':2322 ' \ + && ss -lnt | grep -q ':2323 ' \ + && ss -lnt | grep -q ':2326 '; then + frp_ready=1 + break + fi + sleep 2 +done +if test "$frp_ready" != 1; then + cp -a "$frps_backup" "$frps_config" + systemctl restart "$frps_service" + echo 'FRP 隧道未全部恢复,已回滚' >&2 + exit 1 +fi + +CONFIG="$config" python3 - <<'PY' +from pathlib import Path +import os + +path = Path(os.environ['CONFIG']) +text = path.read_text(encoding='utf-8') +if 'location ^~ /notification-center/' in text: + raise SystemExit(0) +needle = ' location / {\n' +if needle not in text: + raise SystemExit('未找到主站 location,拒绝修改') +block = ''' location ^~ /notification-center/ { + proxy_pass http://127.0.0.1:2326/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + client_max_body_size 128k; + } + +''' +path.write_text(text.replace(needle, block + needle, 1), encoding='utf-8') +PY + +if ! nginx -t; then + cp -a "$backup" "$config" + nginx -t + echo 'Nginx 配置验证失败,已回滚' >&2 + exit 1 +fi +systemctl reload nginx + +curl -fsS --max-time 10 --resolve sc.jiansun.vip:443:127.0.0.1 \ + https://sc.jiansun.vip/api/health >/tmp/yyd-material-public-health.json +curl -fsS --max-time 10 --resolve s3.jiansun.vip:443:127.0.0.1 \ + https://s3.jiansun.vip/minio/health/live >/tmp/yyd-s3-public-health.txt + +healthy=0 +for _ in $(seq 1 20); do + if curl -fsS --max-time 5 --resolve sc.jiansun.vip:443:127.0.0.1 \ + https://sc.jiansun.vip/notification-center/health \ + >/tmp/yyd-notification-public-health.json; then + healthy=1 + break + fi + sleep 2 +done +if test "$healthy" != 1; then + cp -a "$backup" "$config" + nginx -t + systemctl reload nginx + echo '通知中心公网健康检查失败,已回滚 Nginx 配置' >&2 + exit 1 +fi + +cat /tmp/yyd-notification-public-health.json +echo +echo 'PUBLIC_NOTIFICATION_CENTER_OK' diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts new file mode 100644 index 0000000..7c39892 --- /dev/null +++ b/src/__tests__/server.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import test from 'node:test' +import { AddressInfo } from 'node:net' +import { loadProjectConfigs } from '../config' +import prisma from '../prisma' +import { createServer } from '../server' +import { createCanonicalSignature } from '../signature' + +const projectSecret = '123456789012345678901234' +const projects = loadProjectConfigs(JSON.stringify([ + { code: 'fixed_asset', name: '固定资产', secret: projectSecret, allowedHosts: ['asset.example.com'] }, +])) + +const headers = (method: string, path: string, body: string, nonce = crypto.randomBytes(18).toString('base64url')) => { + const timestamp = String(Math.floor(Date.now() / 1000)) + return { + 'Content-Type': 'application/json', + 'x-yyd-project-code': 'fixed_asset', + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': createCanonicalSignature({ secret: projectSecret, timestamp, nonce, method, path, body }), + } +} + +test('通知中心验证签名、项目域名和幂等键', async () => { + await prisma.requestNonce.deleteMany() + await prisma.notificationDelivery.deleteMany() + const server = createServer(projects).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + const path = '/v1/notifications' + const body = JSON.stringify({ + idempotencyKey: 'disposal_applied:53:1', + recipientUserId: 'ding-user-1', + title: '待审批报废', + content: '有一项报废申请等待审批', + actionTitle: '查看详情', + actionUrl: 'https://asset.example.com/workflows/disposals/53', + }) + + try { + const first = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body }) + assert.equal(first.status, 202) + const firstPayload = await first.json() as any + assert.equal(firstPayload.title, '【由壹点·固定资产】待审批报废') + + const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body }) + assert.equal(second.status, 200) + const secondPayload = await second.json() as any + assert.equal(secondPayload.id, firstPayload.id) + + const badBody = JSON.stringify({ ...JSON.parse(body), actionUrl: 'https://evil.example.com/path' }) + const rejected = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, badBody), body: badBody }) + assert.equal(rejected.status, 422) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + await prisma.$disconnect() + } +}) diff --git a/src/__tests__/signature.test.ts b/src/__tests__/signature.test.ts new file mode 100644 index 0000000..859a38b --- /dev/null +++ b/src/__tests__/signature.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { loadProjectConfigs } from '../config' +import { createCanonicalSignature, safeEqual } from '../signature' + +test('每个项目必须使用独立且足够长的密钥', () => { + const projects = loadProjectConfigs(JSON.stringify([ + { code: 'fixed_asset', name: '固定资产', secret: '123456789012345678901234', allowedHosts: ['asset.example.com'] }, + ])) + assert.equal(projects.get('fixed_asset')?.name, '固定资产') + assert.throws(() => loadProjectConfigs(JSON.stringify([ + { code: 'bad', name: '错误项目', secret: 'short', allowedHosts: ['example.com'] }, + ]))) +}) + +test('签名包含项目密钥、时间、nonce、方法、路径和正文', () => { + const input = { + secret: '123456789012345678901234', + timestamp: '1784102400', + nonce: 'nonce_1234567890abcdef', + method: 'POST', + path: '/v1/notifications', + body: JSON.stringify({ title: '测试' }), + } + const signature = createCanonicalSignature(input) + assert.equal(signature.length, 64) + assert.equal(safeEqual(signature, createCanonicalSignature(input)), true) + assert.equal(safeEqual(signature, createCanonicalSignature({ ...input, path: '/v1/other' })), false) +}) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5605d94 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,52 @@ +export type ProjectConfig = { + code: string + name: string + secret: string + allowedHosts: string[] +} + +const clean = (value: unknown) => String(value || '').trim() + +export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_JSON || '') => { + if (!raw.trim()) return new Map() + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 不是有效 JSON') + } + if (!Array.isArray(parsed)) { + throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 必须是数组') + } + + const projects = new Map() + for (const row of parsed) { + const item = row as Record + const code = clean(item.code) + const name = clean(item.name) + const secret = clean(item.secret) + const allowedHosts = Array.isArray(item.allowedHosts) + ? item.allowedHosts.map(clean).filter(Boolean).map((host) => host.toLowerCase()) + : [] + if (!/^[a-z0-9_-]{2,40}$/.test(code)) throw new Error(`项目 code 不合法: ${code || '(空)'}`) + if (!name) throw new Error(`项目 ${code} 缺少 name`) + if (secret.length < 24) throw new Error(`项目 ${code} 的 secret 至少需要 24 个字符`) + if (allowedHosts.length === 0) throw new Error(`项目 ${code} 至少需要一个 allowedHosts`) + if (projects.has(code)) throw new Error(`项目 code 重复: ${code}`) + projects.set(code, { code, name, secret, allowedHosts }) + } + return projects +} + +export const getDingTalkConfig = () => ({ + appKey: clean(process.env.DINGTALK_APP_KEY), + appSecret: clean(process.env.DINGTALK_APP_SECRET), + robotCode: clean(process.env.DINGTALK_ROBOT_CODE), +}) + +export const assertRuntimeConfig = (projects: Map) => { + if (projects.size === 0) throw new Error('未配置任何通知项目') + const dingTalk = getDingTalkConfig() + const missing = Object.entries(dingTalk).filter(([, value]) => !value).map(([key]) => key) + if (missing.length > 0) throw new Error(`钉钉配置缺失: ${missing.join(', ')}`) +} diff --git a/src/dingtalk.ts b/src/dingtalk.ts new file mode 100644 index 0000000..e45d5c8 --- /dev/null +++ b/src/dingtalk.ts @@ -0,0 +1,126 @@ +import { getDingTalkConfig } from './config' + +type DingTalkPayload = Record + +export class DingTalkError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly deterministic = false, + ) { + super(message) + this.name = 'DingTalkError' + } +} + +let tokenCache: { token: string; expiresAt: number } | null = null +let tokenPromise: Promise | null = null + +const requestJson = async (url: string, init?: RequestInit): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + }) + const data = await response.json().catch(() => ({})) as DingTalkPayload + if (!response.ok) { + const code = clean(data.code) || `HTTP_${response.status}` + const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}` + throw new DingTalkError(message, code, response.status >= 400 && response.status < 500 && response.status !== 429) + } + if (data.errcode !== undefined && Number(data.errcode) !== 0) { + throw new DingTalkError(clean(data.errmsg) || '钉钉接口调用失败', String(data.errcode), isDeterministic(data.errcode, data.errmsg)) + } + return data + } catch (error: any) { + if (error instanceof DingTalkError) throw error + if (error?.name === 'AbortError') throw new DingTalkError('钉钉请求超时', 'TIMEOUT') + throw new DingTalkError(clean(error?.message) || '钉钉网络请求失败', 'NETWORK') + } finally { + clearTimeout(timeout) + } +} + +const clean = (value: unknown) => String(value || '').trim() +const stringList = (value: unknown) => Array.isArray(value) ? value.map(clean).filter(Boolean) : [] +const isDeterministic = (code: unknown, message: unknown) => + ['40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020'].includes(String(code)) || + /不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(clean(message)) + +const getToken = async () => { + if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token + if (tokenPromise) return tokenPromise + tokenPromise = (async () => { + const { appKey, appSecret } = getDingTalkConfig() + const data = await requestJson( + `https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`, + ) + const token = clean(data.access_token) + if (!token) throw new DingTalkError('钉钉未返回 access_token', 'TOKEN_MISSING') + tokenCache = { token, expiresAt: Date.now() + Math.max(300, Number(data.expires_in) || 7200) * 1000 } + return token + })() + try { + return await tokenPromise + } finally { + tokenPromise = null + } +} + +export const sendRobotMessage = async (input: { + recipientUserId: string + title: string + content: string + actionTitle: string + actionUrl: string +}) => { + const { robotCode } = getDingTalkConfig() + const token = await getToken() + const data = await requestJson('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', { + method: 'POST', + headers: { 'x-acs-dingtalk-access-token': token }, + body: JSON.stringify({ + robotCode, + userIds: [input.recipientUserId], + msgKey: input.actionUrl ? 'sampleActionCard' : 'sampleMarkdown', + msgParam: JSON.stringify(input.actionUrl ? { + title: input.title.slice(0, 120), + text: input.content.slice(0, 4800), + singleTitle: input.actionTitle.slice(0, 20), + singleURL: input.actionUrl, + } : { + title: input.title.slice(0, 120), + text: input.content.slice(0, 4800), + }), + }), + }) + + const invalid = stringList(data.invalidStaffIdList) + const filtered = stringList(data.filteredStaffIdList) + const flowControlled = stringList(data.flowControlledStaffIdList) + if (invalid.includes(input.recipientUserId)) throw new DingTalkError('收件人钉钉 userId 无效', 'INVALID_USER', true) + if (filtered.includes(input.recipientUserId)) throw new DingTalkError('收件人不在机器人可见范围内', 'FILTERED_USER', true) + if (flowControlled.includes(input.recipientUserId)) throw new DingTalkError('钉钉机器人发送被限流', 'FLOW_CONTROLLED') + const processQueryKey = clean(data.processQueryKey) + if (!processQueryKey) throw new DingTalkError('钉钉未返回 processQueryKey', 'PROCESS_QUERY_KEY_MISSING') + return processQueryKey +} + +export const queryRobotReadStatus = async (processQueryKey: string) => { + const { robotCode } = getDingTalkConfig() + const token = await getToken() + const url = new URL('https://api.dingtalk.com/v1.0/robot/oToMessages/readStatus') + url.searchParams.set('robotCode', robotCode) + url.searchParams.set('processQueryKey', processQueryKey) + const data = await requestJson(url.toString(), { + headers: { 'x-acs-dingtalk-access-token': token }, + }) + const rows = Array.isArray(data.messageReadInfoList) ? data.messageReadInfoList : [] + const read = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'read') + const unread = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'unread') + const failed = clean(data.sendStatus).toLowerCase().includes('fail') + return { read, unread, failed } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3766a42 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +import 'dotenv/config' +import { assertRuntimeConfig, loadProjectConfigs } from './config' +import { createServer } from './server' +import { startWorker } from './worker' + +const projects = loadProjectConfigs() +assertRuntimeConfig(projects) + +const port = Math.max(1, Number(process.env.PORT) || 3088) +createServer(projects).listen(port, () => { + console.log(`[NotificationCenter] listening on ${port}, projects=${projects.size}`) +}) +startWorker() diff --git a/src/prisma.ts b/src/prisma.ts new file mode 100644 index 0000000..4590932 --- /dev/null +++ b/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c09c659 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,157 @@ +import crypto from 'crypto' +import express, { NextFunction, Request, Response } from 'express' +import type { ProjectConfig } from './config' +import prisma from './prisma' +import { createCanonicalSignature, safeEqual, sha256 } from './signature' + +type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig } + +const clean = (value: unknown) => String(value || '').trim() +const deliveryView = (row: any) => ({ + id: row.id, + projectCode: row.projectCode, + status: row.status, + recipientUserId: row.recipientUserId, + title: row.renderedTitle, + processQueryKey: row.processQueryKey, + lastErrorCode: row.lastErrorCode, + lastErrorMessage: row.lastErrorMessage, + sentAt: row.sentAt, + readAt: row.readAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}) + +const validateActionUrl = (value: string, project: ProjectConfig) => { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('actionUrl 必须是完整 HTTPS 地址') + } + if (url.protocol !== 'https:') throw new Error('actionUrl 只允许 HTTPS') + if (!project.allowedHosts.includes(url.hostname.toLowerCase())) { + throw new Error(`actionUrl 域名不在项目白名单内: ${url.hostname}`) + } + return url.toString() +} + +export const createServer = (projects: Map) => { + const app = express() + app.disable('x-powered-by') + app.use(express.json({ + limit: '64kb', + verify: (req, _res, buffer) => { (req as SignedRequest).rawBody = Buffer.from(buffer) }, + })) + + app.get('/health', async (_req, res) => { + try { + await prisma.$queryRaw`SELECT 1` + res.json({ ok: true, service: 'youyidian-notification-center', database: 'ready' }) + } catch { + res.status(503).json({ ok: false, service: 'youyidian-notification-center', database: 'unavailable' }) + } + }) + + const authenticate = async (req: SignedRequest, res: Response, next: NextFunction) => { + try { + const projectCode = clean(req.header('x-yyd-project-code')) + const timestamp = clean(req.header('x-yyd-timestamp')) + const nonce = clean(req.header('x-yyd-nonce')) + const signature = clean(req.header('x-yyd-signature')) + const project = projects.get(projectCode) + if (!project || !timestamp || !nonce || !signature) return res.status(401).json({ error: 'AUTH_REQUIRED' }) + const timestampMs = Number(timestamp) * 1000 + if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > 5 * 60_000) { + return res.status(401).json({ error: 'TIMESTAMP_EXPIRED' }) + } + if (!/^[a-zA-Z0-9_-]{16,100}$/.test(nonce)) return res.status(401).json({ error: 'NONCE_INVALID' }) + const expected = createCanonicalSignature({ + secret: project.secret, + timestamp, + nonce, + method: req.method, + path: req.path, + body: req.rawBody || Buffer.alloc(0), + }) + if (!safeEqual(expected, signature)) return res.status(401).json({ error: 'SIGNATURE_INVALID' }) + + await prisma.requestNonce.deleteMany({ where: { expiresAt: { lt: new Date() } } }) + try { + await prisma.requestNonce.create({ data: { projectCode, nonce, expiresAt: new Date(timestampMs + 5 * 60_000) } }) + } catch { + return res.status(409).json({ error: 'NONCE_REPLAYED' }) + } + req.project = project + next() + } catch (error) { + next(error) + } + } + + app.post('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => { + try { + const project = req.project! + const idempotencyKey = clean(req.body?.idempotencyKey) + const recipientUserId = clean(req.body?.recipientUserId) + const title = clean(req.body?.title) + const content = clean(req.body?.content) + const actionTitle = clean(req.body?.actionTitle) || '查看详情' + let actionUrl: string + try { + actionUrl = validateActionUrl(clean(req.body?.actionUrl), project) + } catch (error: any) { + return res.status(422).json({ error: 'ACTION_URL_INVALID', message: clean(error?.message) }) + } + if (!idempotencyKey || idempotencyKey.length > 160) return res.status(422).json({ error: 'IDEMPOTENCY_KEY_INVALID' }) + if (!recipientUserId || !title || !content) return res.status(422).json({ error: 'MESSAGE_FIELDS_REQUIRED' }) + if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) return res.status(422).json({ error: 'MESSAGE_TOO_LONG' }) + if (title.startsWith('【由壹点')) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) + + const renderedTitle = `【由壹点·${project.name}】${title}` + const normalized = { recipientUserId, title, content, actionTitle, actionUrl } + const requestHash = sha256(JSON.stringify(normalized)) + const existing = await prisma.notificationDelivery.findUnique({ + where: { projectCode_idempotencyKey: { projectCode: project.code, idempotencyKey } }, + }) + if (existing) { + if (existing.requestHash !== requestHash) return res.status(409).json({ error: 'IDEMPOTENCY_CONFLICT' }) + return res.status(200).json(deliveryView(existing)) + } + + const created = await prisma.notificationDelivery.create({ + data: { + id: crypto.randomUUID(), + projectCode: project.code, + idempotencyKey, + requestHash, + recipientUserId, + businessTitle: title, + renderedTitle, + content, + actionTitle, + actionUrl, + }, + }) + res.status(202).json(deliveryView(created)) + } catch (error) { + next(error) + } + }) + + app.get('/v1/notifications/:id', authenticate, async (req: SignedRequest, res, next) => { + try { + const delivery = await prisma.notificationDelivery.findFirst({ where: { id: String(req.params.id), projectCode: req.project!.code } }) + if (!delivery) return res.status(404).json({ error: 'NOT_FOUND' }) + res.json(deliveryView(delivery)) + } catch (error) { + next(error) + } + }) + + app.use((error: any, _req: Request, res: Response, _next: NextFunction) => { + console.error('[NotificationCenter]', error) + res.status(500).json({ error: 'INTERNAL_ERROR', message: clean(error?.message) || '服务异常' }) + }) + return app +} diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 0000000..79a3b3d --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto' + +export const sha256 = (value: string | Buffer) => crypto.createHash('sha256').update(value).digest('hex') + +export const createCanonicalSignature = (input: { + secret: string + timestamp: string + nonce: string + method: string + path: string + body: string | Buffer +}) => { + const canonical = [ + input.timestamp, + input.nonce, + input.method.toUpperCase(), + input.path, + sha256(input.body), + ].join('\n') + return crypto.createHmac('sha256', input.secret).update(canonical).digest('hex') +} + +export const safeEqual = (left: string, right: string) => { + const a = Buffer.from(left) + const b = Buffer.from(right) + return a.length === b.length && crypto.timingSafeEqual(a, b) +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..51c526b --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,123 @@ +import os from 'os' +import prisma from './prisma' +import { DingTalkError, queryRobotReadStatus, sendRobotMessage } from './dingtalk' + +const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 10)}` +const backoffMinutes = [1, 5, 15, 30] +let busy = false +let readBusy = false + +const claimNext = async () => { + const now = new Date() + const claimable = { + OR: [ + { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, + { status: 'sending', leaseExpiresAt: { lte: now } }, + ], + } + const candidate = await prisma.notificationDelivery.findFirst({ where: claimable, orderBy: [{ nextAttemptAt: 'asc' }, { createdAt: 'asc' }] }) + if (!candidate) return null + const claimed = await prisma.notificationDelivery.updateMany({ + where: { id: candidate.id, ...claimable }, + data: { + status: 'sending', + leaseOwner: workerId, + leaseExpiresAt: new Date(Date.now() + 120_000), + attemptCount: { increment: 1 }, + }, + }) + if (claimed.count !== 1) return null + return prisma.notificationDelivery.findUnique({ where: { id: candidate.id } }) +} + +const processOne = async () => { + const delivery = await claimNext() + if (!delivery) return + try { + const processQueryKey = await sendRobotMessage({ + recipientUserId: delivery.recipientUserId, + title: delivery.renderedTitle, + content: delivery.content, + actionTitle: delivery.actionTitle, + actionUrl: delivery.actionUrl, + }) + await prisma.notificationDelivery.updateMany({ + where: { id: delivery.id, status: 'sending', leaseOwner: workerId }, + data: { + status: 'sent', + processQueryKey, + sentAt: new Date(), + nextAttemptAt: new Date(Date.now() + 60_000), + leaseOwner: null, + leaseExpiresAt: null, + lastErrorCode: null, + lastErrorMessage: null, + }, + }) + } catch (error: any) { + const deterministic = error instanceof DingTalkError && error.deterministic + const exhausted = delivery.attemptCount >= delivery.maxAttempts + const failed = deterministic || exhausted + const delay = backoffMinutes[Math.min(Math.max(0, delivery.attemptCount - 1), backoffMinutes.length - 1)] + await prisma.notificationDelivery.updateMany({ + where: { id: delivery.id, status: 'sending', leaseOwner: workerId }, + data: { + status: failed ? 'failed' : 'retrying', + nextAttemptAt: failed ? delivery.nextAttemptAt : new Date(Date.now() + delay * 60_000), + leaseOwner: null, + leaseExpiresAt: null, + lastErrorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN', + lastErrorMessage: String(error?.message || '通知发送失败').slice(0, 2000), + }, + }) + } +} + +const refreshOne = async () => { + const delivery = await prisma.notificationDelivery.findFirst({ + where: { + status: { in: ['sent', 'unread'] }, + processQueryKey: { not: null }, + nextAttemptAt: { lte: new Date() }, + }, + orderBy: { updatedAt: 'asc' }, + }) + if (!delivery?.processQueryKey) return + try { + const result = await queryRobotReadStatus(delivery.processQueryKey) + if (result.read) { + await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: 'read', readAt: new Date() } }) + } else if (result.failed) { + await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastErrorCode: 'SEND_RESULT_FAILED', lastErrorMessage: '钉钉返回发送失败' } }) + } else { + await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: result.unread ? 'unread' : delivery.status, nextAttemptAt: new Date(Date.now() + 5 * 60_000) } }) + } + } catch (error: any) { + await prisma.notificationDelivery.update({ + where: { id: delivery.id }, + data: { lastErrorCode: error instanceof DingTalkError ? error.code : 'READ_STATUS_FAILED', lastErrorMessage: String(error?.message || '查询已读状态失败').slice(0, 2000), nextAttemptAt: new Date(Date.now() + 5 * 60_000) }, + }) + } +} + +export const startWorker = () => { + if (process.env.WORKER_ENABLED === 'false') return + const interval = Math.max(500, Number(process.env.WORKER_INTERVAL_MS) || 1000) + const readInterval = Math.max(2000, Number(process.env.READ_STATUS_INTERVAL_MS) || 5000) + const tick = async () => { + if (busy) return + busy = true + try { + await processOne() + } finally { + busy = false + } + } + setInterval(() => void tick(), interval).unref() + setInterval(() => { + if (readBusy) return + readBusy = true + void refreshOne().finally(() => { readBusy = false }) + }, readInterval).unref() + setTimeout(() => void tick(), 500).unref() +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a51a74a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}