From 74d95236529790193e65cbc54627c66572ace01a Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 17:43:45 +0800 Subject: [PATCH] Initial commit: GitHub-GCC WeCom bot for Gitea --- .env.example | 5 + .gitignore | 9 + README.md | 138 +++ package-lock.json | 1617 ++++++++++++++++++++++++++++++++++++ package.json | 23 + src/config.ts | 63 ++ src/githubEvents.ts | 320 +++++++ src/index.ts | 1 + src/signature.ts | 70 ++ src/wecomSender.ts | 50 ++ src/worker.ts | 166 ++++ tests/githubEvents.test.ts | 157 ++++ tests/helpers.ts | 21 + tests/signature.test.ts | 25 + tests/wecomSender.test.ts | 43 + tests/worker.test.ts | 169 ++++ tsconfig.json | 15 + wrangler.toml | 16 + 18 files changed, 2908 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/githubEvents.ts create mode 100644 src/index.ts create mode 100644 src/signature.ts create mode 100644 src/wecomSender.ts create mode 100644 src/worker.ts create mode 100644 tests/githubEvents.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/signature.test.ts create mode 100644 tests/wecomSender.test.ts create mode 100644 tests/worker.test.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..09f6d7f --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +WEBHOOK_SECRET=replace-with-a-long-random-secret +WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-key +# ALLOWED_ORGS=my-org,another-org +# MAX_MESSAGE_LENGTH=1800 +# DELIVERY_TTL_SECONDS=600 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..990b3ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.dev.vars +.DS_Store +coverage/ +.wrangler/ +.serena/ +.commandcode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..098d1ba --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# GitHub-GCC Bot + +Cloudflare Worker that forwards GitHub or Gitea repository/organization events to an Enterprise WeChat group robot. + +The Worker receives webhook payloads (GitHub sends GitHub headers natively; Gitea sends GitHub-compatible headers), verifies `X-Hub-Signature-256`, formats a short text notification, and posts it to the 企业微信群机器人 Webhook. + +## What This Version Does + +- Runs on Cloudflare Workers. +- Does not require your own server. +- Sends plain text Enterprise WeChat group robot messages. +- Supports GitHub webhooks and Gitea webhooks (including intranet Gitea instances, as long as the Gitea server has outbound internet access). +- Uses KV when configured for delivery de-duplication. + +## Limits + +- This version only sends text messages. +- It depends on 企业微信群机器人 Webhook availability and platform rate limits. +- The WeCom robot Webhook URL contains a secret key; store it only as a Cloudflare secret. +- Links in messages point to the Git host. For an intranet Gitea (e.g. `https://192.168.87.52:18473`), links are only reachable from the intranet/VPN. + +## Setup + +Install dependencies: + +```bash +npm install +``` + +Create an optional KV namespace: + +```bash +wrangler kv namespace create WEBHOOK_CACHE +``` + +Put the returned namespace id into `wrangler.toml` by uncommenting the `[[kv_namespaces]]` block. + +Set Cloudflare Worker secrets: + +```bash +wrangler secret put WEBHOOK_SECRET +wrangler secret put WECOM_WEBHOOK_URL +``` + +`WEBHOOK_SECRET` is a random string you generate; the same value goes into the Git host's webhook secret field. + +`WECOM_WEBHOOK_URL` should look like: + +```text +https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +Optional non-secret variables can be set in `wrangler.toml`: + +```toml +[vars] +MAX_MESSAGE_LENGTH = "1800" +DELIVERY_TTL_SECONDS = "600" +ALLOWED_ORGS = "my-org" +``` + +## Run + +Local development: + +```bash +npm run dev +``` + +Deploy: + +```bash +npm run deploy +``` + +The webhook endpoint is: + +```text +POST /github/webhook +POST /gitea/webhook +``` + +Both paths accept the same payload. Gitea can use either URL. + +The health endpoint is: + +```text +GET /healthz +``` + +## 企业微信群机器人配置 + +1. 在企业微信 App 中进入一个**内部群**(含微信联系人的外部群不支持群机器人)。 +2. 群聊右上角「群设置」→「群机器人」→「添加机器人」,设置名字和头像后创建。 +3. 复制机器人的 Webhook 地址(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...`)。 +4. 用 `wrangler secret put WECOM_WEBHOOK_URL` 保存该地址。 + +## Gitea Webhook 配置 + +前提:Gitea 服务器能访问外网(能连 `workers.dev` 和 `qyapi.weixin.qq.com`)。如需代理,在 Gitea 的 `app.ini` 中配置 `[webhook] PROXY_URL`。 + +在仓库、组织或系统管理页面添加 webhook(Gitea 类型): + +- 目标 URL: `https://your-worker.your-subdomain.workers.dev/gitea/webhook`(`/github/webhook` 也可以) +- POST Content Type: `application/json` +- Secret: 与 `WEBHOOK_SECRET` 相同的值 +- Trigger On: 按需选择 `Push Events`、`Issues`、`Issue Comment`、`Pull Request`、`Releases`、`Workflow Run` 等 +- 保存后点「Test Delivery」发送一个模拟 push 事件验证;失败时在「最近推送记录」中查看请求/响应详情 + +## GitHub Organization Webhook + +In GitHub organization settings, add a webhook: + +- Payload URL: `https://your-worker.your-subdomain.workers.dev/github/webhook` +- Content type: `application/json` +- Secret: the same value as `WEBHOOK_SECRET` +- SSL verification: enabled +- Events: select `push`, `pull_request`, `issues`, `issue_comment`, `release`, and `workflow_run` + +`ping` events are accepted but do not send WeCom messages. + +## Supported Events + +- `push` +- `pull_request` +- `issues` +- `issue_comment` (and Gitea `pull_request_comment`) +- `release` +- `workflow_run` when completed + +Unsupported events return success without sending a message, so the Git host will not retry them. + +## Verify + +```bash +npm test +npm run build +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9318303 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1617 @@ +{ + "name": "github-gcc", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "github-gcc", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "@types/node": "^24.0.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "wrangler": "^4.107.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260701.1.tgz", + "integrity": "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260701.1.tgz", + "integrity": "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260701.1.tgz", + "integrity": "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260701.1.tgz", + "integrity": "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260701.1.tgz", + "integrity": "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260701.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz", + "integrity": "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260701.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz", + "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==", + "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/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": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260701.1.tgz", + "integrity": "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260701.1", + "@cloudflare/workerd-darwin-arm64": "1.20260701.1", + "@cloudflare/workerd-linux-64": "1.20260701.1", + "@cloudflare/workerd-linux-arm64": "1.20260701.1", + "@cloudflare/workerd-windows-64": "1.20260701.1" + } + }, + "node_modules/wrangler": { + "version": "4.107.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.107.0.tgz", + "integrity": "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260701.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260701.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260701.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..54e0621 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "github-gcc", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "node --import tsx --test tests/**/*.test.ts" + }, + "dependencies": {}, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "@types/node": "^24.0.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "wrangler": "^4.107.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..073069d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,63 @@ +export interface WorkerEnv { + WEBHOOK_SECRET: string; + WECOM_WEBHOOK_URL: string; + WEBHOOK_CACHE?: KVNamespace; + ALLOWED_ORGS?: string; + MAX_MESSAGE_LENGTH?: string; + DELIVERY_TTL_SECONDS?: string; +} + +export interface AppConfig { + webhookSecret: string; + wecomWebhookUrl: string; + allowedOrgs?: Set; + maxMessageLength: number; + deliveryTtlSeconds: number; + cache?: KVNamespace; +} + +export function loadConfig(env: WorkerEnv): AppConfig { + const wecomWebhookUrl = required(env.WECOM_WEBHOOK_URL, "WECOM_WEBHOOK_URL"); + if (!wecomWebhookUrl.startsWith("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=")) { + throw new Error("WECOM_WEBHOOK_URL must be an Enterprise WeChat group robot webhook URL"); + } + + return { + webhookSecret: required(env.WEBHOOK_SECRET, "WEBHOOK_SECRET"), + wecomWebhookUrl, + allowedOrgs: parseAllowedOrgs(env.ALLOWED_ORGS), + maxMessageLength: parsePositiveInt(env.MAX_MESSAGE_LENGTH, 1800, "MAX_MESSAGE_LENGTH"), + deliveryTtlSeconds: parsePositiveInt(env.DELIVERY_TTL_SECONDS, 10 * 60, "DELIVERY_TTL_SECONDS"), + cache: env.WEBHOOK_CACHE + }; +} + +function required(value: string | undefined, name: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new Error(`${name} is required`); + } + return trimmed; +} + +function parsePositiveInt(value: string | undefined, fallback: number, name: string): number { + if (!value?.trim()) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + + return parsed; +} + +function parseAllowedOrgs(value: string | undefined): Set | undefined { + const orgs = value + ?.split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + + return orgs?.length ? new Set(orgs) : undefined; +} diff --git a/src/githubEvents.ts b/src/githubEvents.ts new file mode 100644 index 0000000..966d62c --- /dev/null +++ b/src/githubEvents.ts @@ -0,0 +1,320 @@ +export type GitHubPayload = Record; + +const CARD_BORDER = "━━━━━━━━━━━━━━"; + +export function formatGitHubEvent(eventName: string, payload: GitHubPayload, maxLength: number): string | null { + const message = formatEvent(eventName, payload); + return message ? truncateMessage(message, maxLength) : null; +} + +export function getOrganizationLogin(payload: GitHubPayload): string | undefined { + return ( + stringAt(payload, ["organization", "login"]) ?? + stringAt(payload, ["organization", "username"]) ?? + stringAt(payload, ["repository", "owner", "login"]) ?? + stringAt(payload, ["repository", "owner", "username"]) ?? + stringAt(payload, ["repository", "owner", "name"]) + )?.toLowerCase(); +} + +function formatEvent(eventName: string, payload: GitHubPayload): string | null { + switch (eventName) { + case "ping": + return null; + case "push": + return formatPush(payload); + case "pull_request": + return formatPullRequest(payload); + case "issues": + return formatIssue(payload); + case "issue_comment": + case "pull_request_comment": + return formatIssueComment(payload); + case "release": + return formatRelease(payload); + case "workflow_run": + return formatWorkflowRun(payload); + default: + return null; + } +} + +function formatPush(payload: GitHubPayload): string { + const repo = repoName(payload); + const branch = branchName(stringAt(payload, ["ref"]) ?? "unknown"); + const pusher = + stringAt(payload, ["pusher", "name"]) ?? + stringAt(payload, ["pusher", "login"]) ?? + stringAt(payload, ["pusher", "username"]) ?? + actorLogin(payload); + const commits = arrayAt(payload, ["commits"]); + const compareUrl = stringAt(payload, ["compare"]) ?? stringAt(payload, ["compare_url"]); + const headUrl = stringAt(payload, ["head_commit", "url"]); + const changedFiles = changedFileCount(commits); + const latest = latestCommitSummary(payload, commits); + + return formatCard("📦 GitHub-GCC Push", [ + `📁 ${repo}`, + `🌿 ${branch}`, + `👤 ${pusher}`, + "", + "📝 Changes", + `• ${formatCount(commits.length, "commit")}`, + changedFiles !== undefined ? `• ${formatCount(changedFiles, "file")} changed` : undefined, + latest ? `• latest: ${latest}` : undefined, + "", + compareUrl || headUrl ? `🔗 ${compareUrl ?? headUrl}` : undefined + ]); +} + +function formatPullRequest(payload: GitHubPayload): string { + const action = stringAt(payload, ["action"]) ?? "updated"; + const merged = booleanAt(payload, ["pull_request", "merged"]); + const state = action === "closed" && merged ? "merged" : action; + const title = stringAt(payload, ["pull_request", "title"]) ?? "(no title)"; + const number = numberAt(payload, ["pull_request", "number"]) ?? numberAt(payload, ["number"]); + const user = actorLogin(payload, [ + ["pull_request", "user", "login"], + ["pull_request", "user", "username"] + ]); + const base = stringAt(payload, ["pull_request", "base", "ref"]); + const head = stringAt(payload, ["pull_request", "head", "ref"]); + const url = stringAt(payload, ["pull_request", "html_url"]); + + return formatCard("📦 GitHub-GCC Pull Request", [ + `📁 ${repoName(payload)}`, + `🔀 ${number ? `#${number} ` : ""}${firstLine(title)}`, + `👤 ${user}`, + "", + "📝 Changes", + `• ${state}`, + base && head ? `• ${head} -> ${base}` : undefined, + "", + url ? `🔗 ${url}` : undefined + ]); +} + +function formatIssue(payload: GitHubPayload): string { + const action = stringAt(payload, ["action"]) ?? "updated"; + const title = stringAt(payload, ["issue", "title"]) ?? "(no title)"; + const number = numberAt(payload, ["issue", "number"]) ?? numberAt(payload, ["number"]); + const user = actorLogin(payload, [ + ["issue", "user", "login"], + ["issue", "user", "username"] + ]); + const url = stringAt(payload, ["issue", "html_url"]); + + return formatCard("📦 GitHub-GCC Issue", [ + `📁 ${repoName(payload)}`, + `🎫 ${number ? `#${number} ` : ""}${firstLine(title)}`, + `👤 ${user}`, + "", + "📝 Changes", + `• ${action}`, + "", + url ? `🔗 ${url}` : undefined + ]); +} + +function formatIssueComment(payload: GitHubPayload): string { + const action = stringAt(payload, ["action"]) ?? "updated"; + const issueTitle = stringAt(payload, ["issue", "title"]) ?? "(no title)"; + const issueNumber = numberAt(payload, ["issue", "number"]); + const user = actorLogin(payload, [ + ["comment", "user", "login"], + ["comment", "user", "username"] + ]); + const body = firstLine(stringAt(payload, ["comment", "body"]) ?? "(empty comment)"); + const url = stringAt(payload, ["comment", "html_url"]); + + return formatCard("📦 GitHub-GCC Issue Comment", [ + `📁 ${repoName(payload)}`, + `🎫 ${issueNumber ? `#${issueNumber} ` : ""}${firstLine(issueTitle)}`, + `👤 ${user}`, + "", + "📝 Changes", + `• ${action}`, + `• ${body}`, + "", + url ? `🔗 ${url}` : undefined + ]); +} + +function formatRelease(payload: GitHubPayload): string { + const action = stringAt(payload, ["action"]) ?? "updated"; + const name = stringAt(payload, ["release", "name"]) ?? stringAt(payload, ["release", "tag_name"]) ?? "(unnamed release)"; + const tag = stringAt(payload, ["release", "tag_name"]); + const user = actorLogin(payload, [ + ["release", "author", "login"], + ["release", "author", "username"] + ]); + const url = stringAt(payload, ["release", "html_url"]); + + return formatCard("📦 GitHub-GCC Release", [ + `📁 ${repoName(payload)}`, + `🏷️ ${tag ?? firstLine(name)}`, + `👤 ${user}`, + "", + "📝 Changes", + `• ${action}`, + tag && tag !== name ? `• ${firstLine(name)}` : undefined, + "", + url ? `🔗 ${url}` : undefined + ]); +} + +function formatWorkflowRun(payload: GitHubPayload): string | null { + const action = stringAt(payload, ["action"]) ?? "updated"; + const status = stringAt(payload, ["workflow_run", "status"]); + const conclusion = stringAt(payload, ["workflow_run", "conclusion"]); + const workflow = stringAt(payload, ["workflow_run", "name"]) ?? stringAt(payload, ["workflow", "name"]) ?? "(unnamed workflow)"; + const branch = stringAt(payload, ["workflow_run", "head_branch"]); + const actor = actorLogin(payload, [ + ["workflow_run", "actor", "login"], + ["workflow_run", "actor", "username"] + ]); + const url = stringAt(payload, ["workflow_run", "html_url"]); + + if (action !== "completed") { + return null; + } + + return formatCard("📦 GitHub-GCC Workflow Run", [ + `📁 ${repoName(payload)}`, + `⚙️ ${workflow}`, + `👤 ${actor}`, + "", + "📝 Changes", + `• ${conclusion ?? status ?? "unknown"}`, + branch ? `• ${branch}` : undefined, + "", + url ? `🔗 ${url}` : undefined + ]); +} + +function actorLogin(payload: GitHubPayload, extraPaths: string[][] = []): string { + return ( + stringAt(payload, ["sender", "login"]) ?? + stringAt(payload, ["sender", "username"]) ?? + extraPaths.reduce( + (found, path) => found ?? stringAt(payload, path), + undefined + ) ?? + "unknown" + ); +} + +function repoName(payload: GitHubPayload): string { + return stringAt(payload, ["repository", "full_name"]) ?? stringAt(payload, ["repository", "name"]) ?? "unknown/repo"; +} + +function branchName(ref: string): string { + return ref.replace(/^refs\/heads\//, "").replace(/^refs\/tags\//, "tag:"); +} + +function changedFileCount(commits: unknown[]): number | undefined { + const files = new Set(); + + for (const commit of commits) { + if (!isObject(commit)) { + continue; + } + + for (const key of ["added", "modified", "removed"]) { + for (const file of arrayAt(commit, [key])) { + if (typeof file === "string") { + files.add(file); + } + } + } + } + + return files.size > 0 ? files.size : undefined; +} + +function latestCommitSummary(payload: GitHubPayload, commits: unknown[]): string | undefined { + const headCommit = at(payload, ["head_commit"]); + const latest = isObject(headCommit) ? headCommit : lastObject(commits); + + if (!latest) { + return undefined; + } + + const id = (stringAt(latest, ["id"]) ?? "").slice(0, 7); + const message = firstLine(stringAt(latest, ["message"]) ?? "(no commit message)"); + return id ? `${id} ${message}` : message; +} + +function lastObject(values: unknown[]): GitHubPayload | undefined { + for (let index = values.length - 1; index >= 0; index -= 1) { + const value = values[index]; + if (isObject(value)) { + return value; + } + } + + return undefined; +} + +function firstLine(value: string): string { + return value.split(/\r?\n/, 1)[0]?.trim() || "(empty)"; +} + +function truncateMessage(message: string, maxLength: number): string { + if (message.length <= maxLength) { + return message; + } + + const suffix = "\n... truncated"; + const keep = Math.max(0, maxLength - suffix.length); + return `${message.slice(0, keep).trimEnd()}${suffix}`; +} + +function formatCard(title: string, lines: Array): string { + return compactLines([CARD_BORDER, title, "", ...lines, CARD_BORDER]); +} + +function compactLines(lines: Array): string { + return lines.filter((line): line is string => line !== undefined).join("\n"); +} + +function formatCount(count: number, label: string): string { + return `${count} ${label}${count === 1 ? "" : "s"}`; +} + +function stringAt(value: unknown, path: string[]): string | undefined { + const current = at(value, path); + return typeof current === "string" ? current : undefined; +} + +function numberAt(value: unknown, path: string[]): number | undefined { + const current = at(value, path); + return typeof current === "number" ? current : undefined; +} + +function booleanAt(value: unknown, path: string[]): boolean | undefined { + const current = at(value, path); + return typeof current === "boolean" ? current : undefined; +} + +function arrayAt(value: unknown, path: string[]): unknown[] { + const current = at(value, path); + return Array.isArray(current) ? current : []; +} + +function at(value: unknown, path: string[]): unknown { + let current = value; + + for (const key of path) { + if (!isObject(current)) { + return undefined; + } + current = current[key]; + } + + return current; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2b20cb4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default } from "./worker.js"; diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 0000000..b773391 --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,70 @@ +const signaturePrefix = "sha256="; + +export async function signBody(secret: string, body: ArrayBuffer | Uint8Array): Promise { + const secretBytes = new TextEncoder().encode(secret); + const key = await crypto.subtle.importKey( + "raw", + toArrayBuffer(secretBytes), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, toArrayBuffer(body)); + return `${signaturePrefix}${toHex(new Uint8Array(signature))}`; +} + +export async function verifyGitHubSignature( + secret: string, + body: ArrayBuffer | Uint8Array, + signatureHeader: string | null | undefined +): Promise { + const normalized = normalizeSignature(signatureHeader); + if (!normalized) { + return false; + } + + const expected = await signBody(secret, body); + return constantTimeEqual(expected, normalized); +} + +function normalizeSignature(signatureHeader: string | null | undefined): string | undefined { + if (!signatureHeader) { + return undefined; + } + + if (signatureHeader.startsWith(signaturePrefix)) { + return signatureHeader; + } + + // Gitea/Gogs X-Gitea-Signature is raw HMAC-SHA256 hex without the GitHub prefix. + if (/^[0-9a-f]{64}$/i.test(signatureHeader)) { + return `${signaturePrefix}${signatureHeader}`; + } + + return undefined; +} + +function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer { + if (value instanceof ArrayBuffer) { + return value; + } + + const copy = new Uint8Array(value.byteLength); + copy.set(value); + return copy.buffer; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function constantTimeEqual(a: string, b: string): boolean { + let mismatch = a.length ^ b.length; + const maxLength = Math.max(a.length, b.length); + + for (let index = 0; index < maxLength; index += 1) { + mismatch |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0); + } + + return mismatch === 0; +} diff --git a/src/wecomSender.ts b/src/wecomSender.ts new file mode 100644 index 0000000..5978822 --- /dev/null +++ b/src/wecomSender.ts @@ -0,0 +1,50 @@ +import type { AppConfig } from "./config.js"; + +export interface WeComResponse { + errcode?: number; + errmsg?: string; +} + +export async function sendWeComTextMessage( + config: AppConfig, + content: string, + fetchImpl: typeof fetch = fetch +): Promise { + if (!content.trim()) { + throw new Error("WeCom message content must not be empty"); + } + + const response = await fetchImpl(config.wecomWebhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + msgtype: "text", + text: { + content + } + }) + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`WeCom webhook failed: HTTP ${response.status} ${bodyText.slice(0, 500)}`); + } + + const body = parseJsonObject(bodyText) ?? {}; + if (typeof body.errcode === "number" && body.errcode !== 0) { + throw new Error(`WeCom webhook failed: errcode=${body.errcode} errmsg=${body.errmsg ?? ""}`.trim()); + } + + return body; +} + +function parseJsonObject(value: string): T | undefined { + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as T) : undefined; + } catch { + return undefined; + } +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..614c50b --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,166 @@ +import { loadConfig, type AppConfig, type WorkerEnv } from "./config.js"; +import { formatGitHubEvent, getOrganizationLogin, type GitHubPayload } from "./githubEvents.js"; +import { verifyGitHubSignature } from "./signature.js"; +import { sendWeComTextMessage } from "./wecomSender.js"; + +export interface ExecutionContextLike { + waitUntil(promise: Promise): void; +} + +export type MessageSender = (config: AppConfig, message: string) => Promise; + +const memoryDeliveryCache = new Map(); + +export default { + async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise { + return handleRequest(request, env, ctx); + } +}; + +export async function handleRequest( + request: Request, + env: WorkerEnv, + ctx?: ExecutionContextLike, + sender: MessageSender = sendWeComTextMessage +): Promise { + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/healthz") { + return json({ ok: true }); + } + + if (request.method !== "POST" || !isWebhookPath(url.pathname)) { + return json({ error: "not found" }, 404); + } + + let config: AppConfig; + try { + config = loadConfig(env); + } catch (error) { + return json({ error: errorMessage(error) }, 500); + } + + const eventName = + request.headers.get("x-github-event") ?? + request.headers.get("x-gitea-event") ?? + request.headers.get("x-gogs-event"); + const deliveryId = + request.headers.get("x-github-delivery") ?? + request.headers.get("x-gitea-delivery") ?? + request.headers.get("x-gogs-delivery"); + const signature = + request.headers.get("x-hub-signature-256") ?? + request.headers.get("x-gitea-signature") ?? + request.headers.get("x-gogs-signature"); + const rawBody = await request.arrayBuffer(); + + if (!eventName || !deliveryId) { + return json({ error: "missing webhook event headers" }, 400); + } + + if (!(await verifyGitHubSignature(config.webhookSecret, rawBody, signature))) { + return json({ error: "invalid webhook signature" }, 401); + } + + if (await hasDelivery(config, deliveryId)) { + return json({ duplicate: true }, 202); + } + + const payload = parsePayload(rawBody); + if (!payload) { + return json({ error: "invalid JSON payload" }, 400); + } + + if (config.allowedOrgs) { + const org = getOrganizationLogin(payload); + if (!org || !config.allowedOrgs.has(org)) { + await rememberDelivery(config, deliveryId, ctx); + return json({ ignored: true, reason: "organization not allowed" }, 202); + } + } + + const message = formatGitHubEvent(eventName, payload, config.maxMessageLength); + if (!message) { + await rememberDelivery(config, deliveryId, ctx); + return new Response(null, { status: 204 }); + } + + try { + await sender(config, message); + } catch (error) { + return json({ error: errorMessage(error) }, 500); + } + + await rememberDelivery(config, deliveryId, ctx); + return json({ sent: true }, 202); +} + +async function hasDelivery(config: AppConfig, deliveryId: string): Promise { + pruneMemoryDeliveries(); + const cacheKey = deliveryCacheKey(deliveryId); + + if (config.cache) { + return (await config.cache.get(cacheKey)) !== null; + } + + const expiresAt = memoryDeliveryCache.get(cacheKey); + return expiresAt !== undefined && expiresAt > Date.now(); +} + +async function rememberDelivery(config: AppConfig, deliveryId: string, ctx?: ExecutionContextLike): Promise { + const cacheKey = deliveryCacheKey(deliveryId); + + if (config.cache) { + const write = config.cache.put(cacheKey, "1", { expirationTtl: config.deliveryTtlSeconds }); + ctx?.waitUntil(write); + if (!ctx) { + await write; + } + return; + } + + memoryDeliveryCache.set(cacheKey, Date.now() + config.deliveryTtlSeconds * 1000); +} + +function isWebhookPath(pathname: string): boolean { + return pathname === "/github/webhook" || pathname === "/gitea/webhook"; +} + +function deliveryCacheKey(deliveryId: string): string { + return `webhook:delivery:${deliveryId}`; +} + +function pruneMemoryDeliveries(): void { + const now = Date.now(); + for (const [key, expiresAt] of memoryDeliveryCache) { + if (expiresAt <= now) { + memoryDeliveryCache.delete(key); + } + } +} + +function parsePayload(rawBody: ArrayBuffer): GitHubPayload | undefined { + try { + const parsed: unknown = JSON.parse(new TextDecoder().decode(rawBody)); + return isGitHubPayload(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function isGitHubPayload(value: unknown): value is GitHubPayload { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8" + } + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/githubEvents.test.ts b/tests/githubEvents.test.ts new file mode 100644 index 0000000..300e355 --- /dev/null +++ b/tests/githubEvents.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatGitHubEvent, getOrganizationLogin } from "../src/githubEvents.js"; + +test("formats push events with commit summaries", () => { + const message = formatGitHubEvent( + "push", + { + ref: "refs/heads/main", + repository: { + full_name: "acme/app", + owner: { login: "acme" } + }, + pusher: { name: "alice" }, + commits: [ + { + id: "1234567890", + message: "Add feature\n\nLong body", + author: { name: "Alice" }, + added: ["src/new.ts"], + modified: ["src/app.ts", "README.md"], + removed: ["old.txt"], + url: "https://github.com/acme/app/commit/1234567" + } + ], + compare: "https://github.com/acme/app/compare/a...b" + }, + 3500 + ); + + assert.ok(message); + assert.match(message, /^━━━━━━━━━━━━━━\n📦 GitHub-GCC Push/); + assert.match(message, /📁 acme\/app/); + assert.match(message, /🌿 main/); + assert.match(message, /👤 alice/); + assert.match(message, /• 1 commit/); + assert.match(message, /• 4 files changed/); + assert.match(message, /• latest: 1234567 Add feature/); + assert.doesNotMatch(message, /https:\/\/github\.com\/acme\/app\/commit\/1234567/); + assert.match(message, /🔗 https:\/\/github\.com\/acme\/app\/compare\/a\.\.\.b/); + assert.match(message, /━━━━━━━━━━━━━━$/); +}); + +test("formats merged pull requests as merged", () => { + const message = formatGitHubEvent( + "pull_request", + { + action: "closed", + repository: { full_name: "acme/app" }, + sender: { login: "bob" }, + pull_request: { + merged: true, + number: 12, + title: "Improve deployment", + base: { ref: "main" }, + head: { ref: "deploy" }, + html_url: "https://github.com/acme/app/pull/12" + } + }, + 3500 + ); + + assert.ok(message); + assert.match(message, /📦 GitHub-GCC Pull Request/); + assert.match(message, /🔀 #12 Improve deployment/); + assert.match(message, /• merged/); + assert.match(message, /• deploy -> main/); +}); + +test("ignores ping and non-completed workflow_run events", () => { + assert.equal(formatGitHubEvent("ping", {}, 3500), null); + assert.equal(formatGitHubEvent("workflow_run", { action: "requested" }, 3500), null); +}); + +test("extracts organization login from organization or repository owner", () => { + assert.equal(getOrganizationLogin({ organization: { login: "Acme" } }), "acme"); + assert.equal(getOrganizationLogin({ repository: { owner: { login: "Other" } } }), "other"); +}); + +test("formats Gitea-style push payloads with compare_url and pusher login", () => { + const message = formatGitHubEvent( + "push", + { + ref: "refs/heads/main", + repository: { full_name: "gcc/app", owner: { login: "gcc" } }, + pusher: { login: "alice", username: "alice" }, + sender: { login: "alice" }, + commits: [], + total_commits: 0, + compare_url: "https://192.168.87.52:18473/gcc/app/compare/a...b" + }, + 3500 + ); + + assert.ok(message); + assert.match(message, /👤 alice/); + assert.match(message, /🔗 https:\/\/192\.168\.87\.52:18473\/gcc\/app\/compare\/a\.\.\.b/); +}); + +test("formats Gitea pull_request_comment events like issue comments", () => { + const message = formatGitHubEvent( + "pull_request_comment", + { + action: "created", + repository: { full_name: "gcc/app" }, + issue: { number: 3, title: "Add login" }, + comment: { body: "LGTM", html_url: "https://192.168.87.52:18473/gcc/app/pulls/3#issuecomment-1" }, + sender: { login: "bob" }, + is_pull: true + }, + 3500 + ); + + assert.ok(message); + assert.match(message, /📦 GitHub-GCC Issue Comment/); + assert.match(message, /🎫 #3 Add login/); + assert.match(message, /• LGTM/); +}); + +test("extracts organization login from Gitea username field", () => { + assert.equal(getOrganizationLogin({ organization: { username: "GCC" } }), "gcc"); + assert.equal(getOrganizationLogin({ repository: { owner: { username: "GCC" } } }), "gcc"); +}); + +test("formats Gitea payloads that only have sender.username", () => { + const message = formatGitHubEvent( + "issues", + { + action: "opened", + repository: { full_name: "gcc/app" }, + issue: { number: 8, title: "Broken login", html_url: "https://192.168.87.52:18473/gcc/app/issues/8" }, + sender: { username: "carol" } + }, + 3500 + ); + + assert.ok(message); + assert.match(message, /👤 carol/); +}); + +test("truncates long messages", () => { + const message = formatGitHubEvent( + "issue_comment", + { + repository: { full_name: "acme/app" }, + action: "created", + issue: { number: 1, title: "Bug" }, + comment: { body: "x".repeat(200), html_url: "https://example.test" }, + sender: { login: "alice" } + }, + 120 + ); + + assert.ok(message); + assert.ok(message.length <= 120); + assert.match(message, /\.\.\. truncated$/); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..4a58f65 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,21 @@ +import type { AppConfig } from "../src/config.js"; + +export function testConfig(overrides: Partial = {}): AppConfig { + return { + webhookSecret: "test-secret", + wecomWebhookUrl: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key", + maxMessageLength: 1800, + deliveryTtlSeconds: 600, + ...overrides + }; +} + +export function testEnv(overrides: Record = {}) { + return { + WEBHOOK_SECRET: "test-secret", + WECOM_WEBHOOK_URL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key", + MAX_MESSAGE_LENGTH: "1800", + DELIVERY_TTL_SECONDS: "600", + ...overrides + } as never; +} diff --git a/tests/signature.test.ts b/tests/signature.test.ts new file mode 100644 index 0000000..06b74d0 --- /dev/null +++ b/tests/signature.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { signBody, verifyGitHubSignature } from "../src/signature.js"; + +test("verifies a valid GitHub sha256 signature", async () => { + const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." })); + const signature = await signBody("secret", body); + + assert.equal(await verifyGitHubSignature("secret", body, signature), true); +}); + +test("rejects an invalid GitHub sha256 signature", async () => { + const body = Buffer.from(JSON.stringify({ ok: true })); + + assert.equal(await verifyGitHubSignature("secret", body, "sha256=bad"), false); + assert.equal(await verifyGitHubSignature("secret", body, undefined), false); + assert.equal(await verifyGitHubSignature("secret", body, "sha1=bad"), false); +}); + +test("verifies a raw Gitea hex signature", async () => { + const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." })); + const signature = await signBody("secret", body); + + assert.equal(await verifyGitHubSignature("secret", body, signature.slice("sha256=".length)), true); +}); diff --git a/tests/wecomSender.test.ts b/tests/wecomSender.test.ts new file mode 100644 index 0000000..7f6ec8b --- /dev/null +++ b/tests/wecomSender.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sendWeComTextMessage } from "../src/wecomSender.js"; +import { testConfig } from "./helpers.js"; + +test("sends a WeCom text message to the configured webhook", async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify({ errcode: 0, errmsg: "ok" }), { status: 200 }); + }; + + const result = await sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch); + + assert.deepEqual(result, { errcode: 0, errmsg: "ok" }); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key"); + assert.deepEqual(JSON.parse(String(requests[0]?.init.body)), { + msgtype: "text", + text: { + content: "hello" + } + }); +}); + +test("throws when WeCom webhook returns an API error", async () => { + const fetchImpl = async () => + new Response(JSON.stringify({ errcode: 93000, errmsg: "invalid webhook" }), { status: 200 }); + + await assert.rejects( + () => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch), + /WeCom webhook failed: errcode=93000/ + ); +}); + +test("throws when WeCom webhook returns an HTTP error", async () => { + const fetchImpl = async () => new Response("bad request", { status: 400 }); + + await assert.rejects( + () => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch), + /WeCom webhook failed: HTTP 400/ + ); +}); diff --git a/tests/worker.test.ts b/tests/worker.test.ts new file mode 100644 index 0000000..057833e --- /dev/null +++ b/tests/worker.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AppConfig } from "../src/config.js"; +import { signBody } from "../src/signature.js"; +import { handleRequest } from "../src/worker.js"; +import { testEnv } from "./helpers.js"; + +test("health check returns ok", async () => { + const response = await handleRequest(new Request("https://worker.test/healthz"), testEnv()); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); +}); + +test("accepts a valid push webhook and sends one WeCom message", async () => { + const sent: string[] = []; + const response = await postGitHubEvent("push", "delivery-1", { + ref: "refs/heads/main", + repository: { full_name: "acme/app", owner: { login: "acme" } }, + pusher: { name: "alice" }, + commits: [] + }, async (_config, message) => { + sent.push(message); + }); + + assert.equal(response.status, 202); + assert.deepEqual(await response.json(), { sent: true }); + assert.equal(sent.length, 1); + assert.match(sent[0] ?? "", /📁 acme\/app/); +}); + +test("rejects invalid signatures", async () => { + const body = JSON.stringify({ repository: { full_name: "acme/app" } }); + const response = await handleRequest( + new Request("https://worker.test/github/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "push", + "x-github-delivery": "bad-sig", + "x-hub-signature-256": "sha256=bad" + }, + body + }), + testEnv(), + undefined, + async () => { + throw new Error("should not send"); + } + ); + + assert.equal(response.status, 401); +}); + +test("deduplicates GitHub delivery ids", async () => { + const sent: string[] = []; + const payload = { + ref: "refs/heads/main", + repository: { full_name: "acme/app", owner: { login: "acme" } }, + commits: [] + }; + + const first = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => { + sent.push(message); + }); + const second = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => { + sent.push(message); + }); + + assert.equal(first.status, 202); + assert.equal(second.status, 202); + assert.deepEqual(await second.json(), { duplicate: true }); + assert.equal(sent.length, 1); +}); + +test("ignores events outside the allowed organization list", async () => { + const sent: string[] = []; + const response = await postGitHubEvent( + "push", + "wrong-org", + { + ref: "refs/heads/main", + repository: { full_name: "other/app", owner: { login: "other" } }, + commits: [] + }, + async (_config, message) => { + sent.push(message); + }, + { ALLOWED_ORGS: "acme" } + ); + + assert.equal(response.status, 202); + assert.equal((await response.json() as { ignored: boolean }).ignored, true); + assert.equal(sent.length, 0); +}); + +test("accepts Gitea event headers on the Gitea webhook path", async () => { + const sent: string[] = []; + const payload = { + ref: "refs/heads/main", + repository: { full_name: "gcc/app", owner: { username: "gcc" } }, + pusher: { username: "alice" }, + commits: [] + }; + const body = JSON.stringify(payload); + const signature = await signBody("test-secret", new TextEncoder().encode(body)); + + const response = await handleRequest( + new Request("https://worker.test/gitea/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-gitea-event": "push", + "x-gitea-delivery": "gitea-delivery-1", + "x-gitea-signature": signature.slice("sha256=".length) + }, + body + }), + testEnv(), + undefined, + async (_config, message) => { + sent.push(message); + } + ); + + assert.equal(response.status, 202); + assert.deepEqual(await response.json(), { sent: true }); + assert.equal(sent.length, 1); + assert.match(sent[0] ?? "", /📁 gcc\/app/); +}); + +test("returns 500 when sending to WeCom fails so GitHub can retry", async () => { + const response = await postGitHubEvent("push", "send-fails", { + ref: "refs/heads/main", + repository: { full_name: "acme/app", owner: { login: "acme" } }, + commits: [] + }, async () => { + throw new Error("WeCom failed"); + }); + + assert.equal(response.status, 500); +}); + +async function postGitHubEvent( + eventName: string, + deliveryId: string, + payload: Record, + sender: (config: AppConfig, message: string) => Promise, + envOverrides: Record = {} +) { + const body = JSON.stringify(payload); + const signature = await signBody("test-secret", new TextEncoder().encode(body)); + + return handleRequest( + new Request("https://worker.test/github/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": eventName, + "x-github-delivery": deliveryId, + "x-hub-signature-256": signature + }, + body + }), + testEnv(envOverrides), + undefined, + sender + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..47ab48f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..1bfa6b8 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,16 @@ +name = "github-gcc" +main = "src/index.ts" +compatibility_date = "2026-07-02" + +# Non-secret defaults. Put secrets in Cloudflare with `wrangler secret put`. +[vars] +MAX_MESSAGE_LENGTH = "1800" +DELIVERY_TTL_SECONDS = "600" + +# Optional KV cache for GitHub delivery de-duplication. +# Create it with: +# wrangler kv namespace create WEBHOOK_CACHE +# Then uncomment and replace the id below. +# [[kv_namespaces]] +# binding = "WEBHOOK_CACHE" +# id = "replace-with-kv-namespace-id"