commit 7e927accac3addd21d2862638604f5bd839c54b3 Author: 浪子 Date: Thu Mar 19 11:07:49 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abee82f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.wrangler +.claude \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bd28d8 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Cloudflare 在线聊天室(D1 + KV + R2) + +按 `readme.txt` 需求实现的最小可用在线聊天室:D1 存用户/房间/消息,KV 做会话与房间访问令牌缓存,R2 存图片。 + +## 功能概览 + +- 房间模式:开放/私密 + - 当前版本只有一个房间:大厅(可选设置密码) + - 开放:所有人可浏览;必须注册登录才能发言 + - 私密:需要密码进入;可选“允许游客匿名发言” +- 用户等级:匿名游客 / 注册会员 / 认证会员 / 管理员 + - 注册会员:可发文字/表情;不可上传图片 + - 认证会员:可上传图片(R2) + - 管理员:可编辑用户资料与等级、封禁用户、删除消息 +- 页面:注册、登录、找回密码、房间列表、聊天窗口(左右气泡) +- 实时:SSE(EventSource)推送(简化实现,适合 MVP) + +## 本地开发 + +1) 安装依赖 + +```bash +npm i +``` + +2) 创建并迁移 D1 + +```bash +npx wrangler d1 create chat_db +# 把输出的 database_id 填到 wrangler.toml +npx wrangler d1 execute chat_db --file=./schema.sql +``` + +3) 创建 KV / R2,并填入 `wrangler.toml` + +```bash +npx wrangler kv namespace create CACHE +npx wrangler r2 bucket create chat-bucket +``` + +4) 启动 + +```bash +npm run dev +``` + +## 部署 + +```bash +npm run deploy +``` + +## 管理员 + +生产/开发环境:第一个注册成功的用户会自动提升为管理员(后续用户为注册会员)。 + +开发环境也可用接口创建管理员(`wrangler.toml` 的 `DEV_MODE = "true"`): + +```bash +curl -X POST http://localhost:8787/api/admin/debug/create-admin ^ + -H "content-type: application/json" ^ + -d "{\"username\":\"admin\",\"password\":\"admin123\"}" +``` + +管理员可在页面右侧“管理 → 大厅密码”设置/取消大厅密码:设置后游客登录会要求输入该密码;取消后游客登录直接进入。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..841dbdb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1987 @@ +{ + "name": "cf-chat", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cf-chat", + "dependencies": { + "resend": "^6.9.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241206.0", + "typescript": "^5.6.3", + "wrangler": "^3.100.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251225.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workers-types/-/workers-types-4.20251225.0.tgz", + "integrity": "sha512-ZZl0cNLFcsBRFKtMftKWOsfAybUYSeiTMzpQV1NlTVlByHAs1rGQt45Jw/qz8LrfHoq9PGTieSj9W350Gi4Pvg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@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.7.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "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.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "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.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "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.0.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "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.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "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.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "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.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "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.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "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.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmmirror.com/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/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/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmmirror.com/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mailparser": { + "version": "3.9.1", + "resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.1.tgz", + "integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.0", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.11", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmmirror.com/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resend": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/resend/-/resend-6.9.1.tgz", + "integrity": "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA==", + "license": "MIT", + "dependencies": { + "mailparser": "3.9.1", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmmirror.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/stacktracey/-/stacktracey-2.1.8.tgz", + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmmirror.com/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/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/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.16", + "resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-3.114.16.tgz", + "integrity": "sha512-ve/ULRjrquu5BHNJ+1T0ipJJlJ6pD7qLmhwRkk0BsUIxatNe4HP4odX/R4Mq/RHG6LOnVAFs7SMeSHlz/1mNlQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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": "3.3.4", + "resolved": "https://registry.npmmirror.com/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9256125 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "cf-chat", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241206.0", + "typescript": "^5.6.3", + "wrangler": "^3.100.0" + }, + "dependencies": { + "resend": "^6.9.1" + } +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..416e669 --- /dev/null +++ b/public/app.css @@ -0,0 +1,1092 @@ +:root { + --primary-color: #6366f1; + --primary-hover: #4f46e5; + --bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + --chat-bg: #ffffff; + --text-main: #1f2937; + --text-secondary: #6b7280; + --border: #e5e7eb; + --door-width: 50%; + --transition-speed: 1.1s; + + --surface: #ffffff; + --surface-2: #f3f4f6; + --surface-3: #fafafa; + --page-surface: #eef2f6; + --card-bg: rgba(255, 255, 255, 0.92); + --login-card-bg: rgba(255, 255, 255, 0.95); + --confirm-card-bg: rgba(255, 255, 255, 0.96); + --emoji-panel-bg: rgba(255, 255, 255, 0.98); + --btn-bg: #ffffff; + --btn-border: #d1d5db; + --input-border: #d1d5db; +} + +:root[data-theme="dark"] { + --bg-gradient: linear-gradient(135deg, #0b1220 0%, #141a2a 100%); + --chat-bg: #0f1625; + --text-main: #e5e7eb; + --text-secondary: #9ca3af; + --border: rgba(148, 163, 184, 0.18); + + --surface: #0f1625; + --surface-2: #0b1220; + --surface-3: #0b1220; + --page-surface: #0b1220; + --card-bg: rgba(15, 22, 37, 0.92); + --login-card-bg: rgba(15, 22, 37, 0.95); + --confirm-card-bg: rgba(15, 22, 37, 0.96); + --emoji-panel-bg: rgba(15, 22, 37, 0.98); + --btn-bg: rgba(15, 22, 37, 0.92); + --btn-border: rgba(148, 163, 184, 0.28); + --input-border: rgba(148, 163, 184, 0.28); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; +} + +.srOnly { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg-gradient); + color: var(--text-main); +} + +:root { + color-scheme: light; +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +/* === Scene === */ +.scene-container { + position: relative; + width: 100%; + height: 100%; + perspective: 1500px; + overflow: hidden; +} + +/* === Chat behind doors === */ +.chat-room { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + background: var(--page-surface); + filter: blur(6px); + transition: filter var(--transition-speed) ease; +} + +.chat-room.active { + filter: blur(0); +} + +.chat-interface { + width: 92%; + max-width: 1100px; + height: 88%; + background: var(--chat-bg); + border-radius: 20px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.15); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + opacity: 0; + transform: scale(0.95); + transition: + opacity 1s ease 0.4s, + transform 1s ease 0.4s; +} + +.chat-room.active .chat-interface { + opacity: 1; + transform: scale(1); +} + +/* === Topbar === */ +.topbar { + padding: 14px 18px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + font-weight: 800; + color: var(--text-main); +} + +.me { + color: var(--text-secondary); + font-size: 12px; +} + +.topbarRight { + display: flex; + align-items: center; + gap: 10px; +} + +.menuBtn { + display: inline-grid; +} + +.menuDropdown { + position: absolute; + top: 58px; + right: 12px; + z-index: 60; + width: min(360px, calc(100% - 24px)); +} + +.menuDropdown[hidden] { + display: none !important; +} + +.menuDropdownCard { + max-height: calc(88vh - 70px); + overflow: auto; + padding: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); +} + +.menuDropdownHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.menuDropdownTitle { + font-weight: 900; + color: var(--text-main); +} + +/* === Layout === */ +.layout { + flex: 1; + display: flex; + min-height: 0; +} + +.sidebar { + width: 280px; + background: var(--surface-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px; + gap: 12px; + overflow: auto; +} + +.fold { + padding: 0; +} + +.foldSummary { + list-style: none; + cursor: pointer; + padding: 12px; + font-size: 13px; + font-weight: 800; + color: var(--text-main); + user-select: none; +} + +.foldSummary::-webkit-details-marker { + display: none; +} + +.foldBody { + padding: 0 12px 12px; +} + +.fold[open] .foldSummary { + border-bottom: 1px solid var(--border); +} + +.chat { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--surface); +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; +} + +.admin { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.adminHeader { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + font-size: 12px; + color: var(--text-secondary); + padding: 6px 2px; +} + +.adminHeader > div { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adminRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.adminRow > * { + min-width: 0; +} + +.adminRowName { + font-weight: 800; + color: var(--text-main); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.userNameAdmin { + color: #e74c3c; +} + +.userNameVerified { + color: #8b5cf6; +} + +.userNameMember { + color: #3b82f6; +} + +.adminRowName.userNameAdmin, +.onlineName.userNameAdmin { + color: #e74c3c; +} + +.adminRowName.userNameVerified, +.onlineName.userNameVerified { + color: #8b5cf6; +} + +.adminRowName.userNameMember, +.onlineName.userNameMember { + color: #3b82f6; +} + +.adminRowActions { + justify-self: end; + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.iconBtn { + width: 36px; + height: 36px; + padding: 0; + display: inline-grid; + place-items: center; + border-radius: 10px; +} + +.iconBtn svg { + width: 18px; + height: 18px; + display: block; +} + +.iconBtnDanger { + border-color: rgba(231, 76, 60, 0.35); + color: #e74c3c; +} + +.iconBtnDanger:hover { + background: rgba(231, 76, 60, 0.08); +} + +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-3); + font-size: 12px; + color: var(--text-secondary); + max-width: 84px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.onlineList { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.onlineItem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.onlineDot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14); + flex: 0 0 auto; +} + +.onlineName { + font-weight: 900; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + color: var(--text-main); +} + +.onlineMeTag { + margin-left: auto; + flex: 0 0 auto; + font-size: 12px; + color: var(--text-secondary); +} + +.adminRow .btn { + padding: 8px 10px; + border-radius: 10px; + font-size: 12px; +} + +.cardTitle { + font-size: 13px; + font-weight: 800; + color: var(--text-main); + margin-bottom: 10px; +} + +.row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.hint { + font-size: 12px; + color: var(--text-secondary); +} + +.hint.ok { + color: #10b981; +} + +.hint.bad { + color: #e74c3c; +} + +.link { + font-size: 12px; + color: var(--primary-color); + text-decoration: none; +} + +button.link { + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--input-border); + border-radius: 12px; + outline: none; + background: var(--surface); + color: var(--text-main); +} + +.input:focus { + border-color: var(--primary-color); +} + +.btn { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--btn-border); + background: var(--btn-bg); + cursor: pointer; + font-weight: 600; + color: var(--text-main); +} + +.btnPrimary { + border-color: transparent; + background: var(--primary-color); + color: #fff; +} + +.btnPrimary:hover { + background: var(--primary-hover); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* === Chat area === */ +.chatHeader { + padding: 14px 18px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.chatTitle { + font-size: 15px; + font-weight: 800; + color: var(--text-main); +} + +.chatSubtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +.messages { + flex: 1; + padding: 16px 18px; + overflow: auto; + background: var(--surface-3); + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; +} + +.msgRow { + display: flex; + align-items: flex-end; + gap: 10px; + max-width: 80%; +} + +.msgRow.left { + justify-content: flex-start; +} + +.msgRow.right { + justify-content: flex-end; + align-self: flex-end; +} + +.bubble { + padding: 10px 16px; + border-radius: 12px; + font-size: 14px; + line-height: 1.4; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-main); + word-wrap: break-word; +} + +.bubble.me { + background: var(--primary-color); + border-color: transparent; + color: #fff; +} + +.meta { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.chatName { + font-weight: 900; + color: var(--text-main); +} + +.chatMetaSep { + color: var(--text-secondary); +} + +.chatTime { + color: var(--text-secondary); +} + +.bubble.me .meta, +.bubble.me .chatMetaSep, +.bubble.me .chatTime { + color: rgba(255, 255, 255, 0.78); +} + +.bubble.me .chatName { + color: rgba(255, 255, 255, 0.96); +} + +.bubble img { + width: 200px; + height: 200px; + max-width: 100%; + border-radius: 12px; + display: block; + object-fit: cover; + cursor: zoom-in; +} + +.composer { + padding: 16px 18px; + border-top: 1px solid var(--border); + background: var(--surface); + position: relative; + display: flex; + flex-direction: column; + gap: 10px; +} + +.composerRow { + display: flex; + gap: 10px; + align-items: center; +} + +.msgInputWrap { + flex: 1; + min-width: 0; + position: relative; +} + +.inlineThumb { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + border-radius: 10px; + object-fit: cover; + border: 1px solid var(--border); + background: var(--surface); +} + +.msgInputWrap.hasThumb .input { + padding-left: 54px; +} + +.toolBtn { + width: 40px; + height: 40px; + padding: 0; + display: inline-grid; + place-items: center; + border-radius: 12px; +} + +.toolBtn svg { + width: 18px; + height: 18px; + display: block; +} + +.toolBtn[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +.composerAttach { + display: flex; + gap: 10px; + align-items: center; +} + +.attachChip { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + overflow: hidden; +} + +.attachThumb { + width: 56px; + height: 56px; + border-radius: 12px; + object-fit: cover; + border: 1px solid var(--border); +} + +.attachMeta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.attachName { + font-size: 12px; + font-weight: 800; + color: var(--text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.attachHint { + font-size: 12px; + color: var(--text-secondary); +} + +.emojiPanel { + position: absolute; + left: 18px; + bottom: 74px; + z-index: 20; + width: min(360px, calc(100% - 36px)); + border: 1px solid var(--border); + border-radius: 14px; + background: var(--emoji-panel-bg); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18); + padding: 10px; +} + +.emojiPanel[hidden] { + display: none !important; +} + +.emojiGrid { + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: 6px; +} + +.emojiBtn { + width: 32px; + height: 32px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + cursor: pointer; + display: inline-grid; + place-items: center; + font-size: 18px; + line-height: 1; +} + +.emojiBtn:hover { + background: var(--surface-2); + border-color: var(--border); +} + +/* === Doors === */ +.doors-container { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + pointer-events: none; +} + +.door { + width: var(--door-width); + height: 100%; + position: relative; + transition: transform var(--transition-speed) cubic-bezier(0.645, 0.045, 0.355, 1); + transform-style: preserve-3d; + pointer-events: none; + box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.5); +} + +.door.left { + transform-origin: left; + background: linear-gradient(90deg, #1a252f 0%, #34495e 100%); + border-right: 2px solid #1a252f; +} + +.door.right { + transform-origin: right; + background: linear-gradient(-90deg, #1a252f 0%, #34495e 100%); + border-left: 2px solid #1a252f; +} + +.doors-container.open .door.left { + transform: rotateY(-110deg); +} + +.doors-container.open .door.right { + transform: rotateY(110deg); +} + +.door::after { + content: ""; + position: absolute; + top: 20px; + bottom: 20px; + left: 20px; + right: 20px; + border: 2px solid rgba(255, 255, 255, 0.1); + pointer-events: none; +} + +.door-handle { + position: absolute; + top: 50%; + width: 12px; + height: 60px; + background: #f1c40f; + border-radius: 6px; + box-shadow: 0 0 10px rgba(241, 196, 15, 0.5); + transform: translateY(-50%); +} + +.door.left .door-handle { + right: 30px; +} + +.door.right .door-handle { + left: 30px; +} + +/* === Login card === */ +.login-wrapper { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + justify-content: center; + align-items: center; + pointer-events: none; +} + +.login-card { + background: var(--login-card-bg); + padding: 34px; + border-radius: 16px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + width: min(360px, 92vw); + text-align: center; + pointer-events: auto; + transition: all 0.8s ease; + backdrop-filter: blur(10px); +} + +.login-card h1 { + margin-bottom: 8px; + color: var(--text-main); + font-size: 1.5rem; +} + +.login-card p { + color: var(--text-secondary); + margin-bottom: 18px; + font-size: 0.9rem; +} + +.gateTabs { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.gatePanel { + text-align: left; + margin-top: 10px; +} + +.gateModalCard { + width: min(400px, 92vw) !important; +} + +.gateModalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.input-group { + margin-bottom: 14px; +} + +.input-group label { + display: block; + margin-bottom: 8px; + color: var(--text-main); + font-size: 0.85rem; + font-weight: 700; +} + +.input-group input { + width: 100%; + padding: 12px; + border: 2px solid var(--input-border); + border-radius: 8px; + font-size: 1rem; + outline: none; + transition: border-color 0.2s; + background: var(--surface); + color: var(--text-main); +} + +.input-group input:focus { + border-color: var(--primary-color); +} + +.login-btn { + width: 100%; + padding: 12px; + background: var(--primary-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 800; + cursor: pointer; + transition: background 0.2s, transform 0.1s; + margin-top: 6px; +} + +.login-btn:hover { + background: var(--primary-hover); +} + +.login-btn:active { + transform: scale(0.98); +} + +.error-msg { + color: #e74c3c; + font-size: 0.85rem; + min-height: 1.1rem; + margin: 6px 0 4px; + opacity: 0; + transition: opacity 0.25s; +} + +.error-msg.show { + opacity: 1; +} + +.login-wrapper.fade-out .login-card { + opacity: 0; + transform: scale(0.96) translateY(-10px); +} + +.imgViewer { + position: fixed; + inset: 0; + z-index: 999; + display: grid; + place-items: center; + padding: 20px; +} + +.imgViewer[hidden] { + display: none !important; +} + +.imgViewerBackdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); +} + +.imgViewerImg { + position: relative; + max-width: min(92vw, 980px); + max-height: 88vh; + border-radius: 14px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55); + background: var(--surface); +} + +.imgViewerClose { + position: absolute; + top: 14px; + right: 14px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(0, 0, 0, 0.35); + color: #fff; + cursor: pointer; + backdrop-filter: blur(8px); +} + +/* Toast */ +.toast { + position: fixed; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + z-index: 1000; + padding: 10px 14px; + border-radius: 999px; + background: rgba(17, 24, 39, 0.92); + color: #fff; + font-size: 12px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); + max-width: min(92vw, 520px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toast.ok { + background: rgba(16, 185, 129, 0.95); +} + +.toast.bad { + background: rgba(231, 76, 60, 0.95); +} + +/* Confirm */ +.confirm { + position: fixed; + inset: 0; + z-index: 1001; + display: grid; + place-items: center; + padding: 18px; +} + +.confirm[hidden] { + display: none !important; +} + +.confirm::before { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.confirmCard { + position: relative; + width: min(520px, 92vw); + border-radius: 16px; + background: var(--confirm-card-bg); + border: 1px solid var(--border); + padding: 14px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); +} + +.confirmTitle { + font-weight: 900; + color: var(--text-main); + margin-bottom: 8px; +} + +.confirmMsg { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; + margin-bottom: 12px; + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .layout { + flex-direction: column; + } + .sidebar { + display: none; + } + .msgRow { + max-width: 90%; + } +} + +@media (max-width: 420px) { + .adminHeader { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + } + .adminRow { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + } + .adminRowActions .btn { + padding: 8px 8px; + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..fd451eb --- /dev/null +++ b/public/app.js @@ -0,0 +1,1403 @@ +(() => { + const $ = (id) => document.getElementById(id); + + const state = { + me: null, + myAnonName: null, + accessToken: null, + currentRoom: null, + lastMessageId: 0, + es: null, + pendingGuestName: null, + pendingImageFile: null, + pendingImageUrl: null, + }; + + const GUEST_SESSION_KEY = "guest_session_v1"; + const GUEST_SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour + const THEME_KEY = "theme"; + + function clearGuestSession() { + try { + localStorage.removeItem(GUEST_SESSION_KEY); + } catch {} + } + + function getCurrentTheme() { + const t = document.documentElement?.dataset?.theme; + return t === "dark" ? "dark" : "light"; + } + + function setTheme(theme) { + const t = theme === "dark" ? "dark" : "light"; + document.documentElement.dataset.theme = t; + try { + localStorage.setItem(THEME_KEY, t); + } catch {} + renderThemeBtn(); + } + + function renderThemeBtn() { + const btn = $("themeBtn"); + if (!btn) return; + const isDark = getCurrentTheme() === "dark"; + btn.setAttribute("aria-pressed", isDark ? "true" : "false"); + btn.setAttribute("aria-label", isDark ? "切换亮色模式" : "切换黑夜模式"); + btn.innerHTML = isDark + ? ` + + ` + : ` + + `; + } + + function loadGuestSession() { + let raw = null; + try { + raw = localStorage.getItem(GUEST_SESSION_KEY); + } catch { + return { exists: false, session: null }; + } + + if (!raw) return { exists: false, session: null }; + + try { + const data = JSON.parse(raw); + const expired = typeof data?.expiresAt !== "number" || data.expiresAt <= Date.now(); + if (data?.v !== 1 || expired) { + clearGuestSession(); + return { exists: true, session: null }; + } + const roomId = typeof data.roomId === "string" ? data.roomId : ""; + const nickname = typeof data.nickname === "string" ? data.nickname.trim() : ""; + const accessToken = typeof data.accessToken === "string" ? data.accessToken : null; + if (!roomId || !nickname) { + clearGuestSession(); + return { exists: true, session: null }; + } + return { exists: true, session: { roomId, nickname, accessToken } }; + } catch { + clearGuestSession(); + return { exists: true, session: null }; + } + } + + function saveGuestSession({ roomId, nickname, accessToken }) { + try { + localStorage.setItem( + GUEST_SESSION_KEY, + JSON.stringify({ + v: 1, + roomId, + nickname, + accessToken, + expiresAt: Date.now() + GUEST_SESSION_TTL_MS, + }), + ); + } catch {} + } + + function randomGuestName() { + return `游客-${Math.floor(1000 + Math.random() * 9000)}`; + } + + let toastTimer = null; + function toast(message, kind = "info", ms = 2200) { + const el = $("toast"); + if (!el) return; + el.classList.remove("ok", "bad"); + if (kind === "ok") el.classList.add("ok"); + if (kind === "bad") el.classList.add("bad"); + el.textContent = message; + el.hidden = false; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.hidden = true; + }, ms); + } + + function confirmDialog({ title, message, okText = "确定", cancelText = "取消" }) { + const overlay = $("confirm"); + const titleEl = $("confirmTitle"); + const msgEl = $("confirmMsg"); + const okBtn = $("confirmOk"); + const cancelBtn = $("confirmCancel"); + if (!overlay || !titleEl || !msgEl || !okBtn || !cancelBtn) return Promise.resolve(false); + titleEl.textContent = title || "确认"; + msgEl.textContent = message || ""; + okBtn.textContent = okText; + cancelBtn.textContent = cancelText; + overlay.hidden = false; + + return new Promise((resolve) => { + const cleanup = () => { + overlay.hidden = true; + okBtn.removeEventListener("click", onOk); + cancelBtn.removeEventListener("click", onCancel); + window.removeEventListener("keydown", onKey); + }; + const onOk = () => { + cleanup(); + resolve(true); + }; + const onCancel = () => { + cleanup(); + resolve(false); + }; + const onKey = (e) => { + if (e.key === "Escape") onCancel(); + }; + okBtn.addEventListener("click", onOk); + cancelBtn.addEventListener("click", onCancel); + window.addEventListener("keydown", onKey); + }); + } + + function setFoldDefaultsOnce() { + const isSmall = window.matchMedia("(max-width: 900px)").matches; + const accountFold = $("accountFold"); + if (accountFold) accountFold.open = !isSmall; + const adminFold = $("adminCard"); + if (adminFold && adminFold.style.display !== "none") adminFold.open = !isSmall; + } + + function setupMobileMenu() { + const menuBtn = $("menuBtn"); + const menu = $("menuDropdown"); + const menuClose = $("menuCloseBtn"); + const menuHost = $("menuHost"); + const sidebar = document.querySelector(".sidebar"); + const onlineCard = $("onlineCard"); + if (!menuBtn || !menu || !menuClose || !menuHost || !sidebar || !onlineCard) return; + + const mq = window.matchMedia("(max-width: 900px)"); + + const closeMenu = () => { + menu.hidden = true; + }; + + const openMenu = () => { + menu.hidden = false; + setFoldDefaultsOnce(); + }; + + const syncPlacement = () => { + if (mq.matches) { + if (onlineCard.parentElement !== menuHost) menuHost.prepend(onlineCard); + closeMenu(); + } else { + if (onlineCard.parentElement !== sidebar) sidebar.prepend(onlineCard); + closeMenu(); + } + }; + + syncPlacement(); + if (mq.addEventListener) mq.addEventListener("change", syncPlacement); + else mq.addListener(syncPlacement); + + menuBtn.addEventListener("click", () => { + if (menu.hidden) openMenu(); + else closeMenu(); + }); + menuClose.addEventListener("click", closeMenu); + + document.addEventListener("click", (e) => { + if (menu.hidden) return; + const target = e.target; + if (target === menuBtn || menuBtn.contains(target)) return; + if (menu.contains(target)) return; + closeMenu(); + }); + + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeMenu(); + }); + } + + function renderOnlineList() { + const list = $("onlineList"); + const count = $("onlineCount"); + if (!list || !count) return; + + const items = []; + if (state.me) { + items.push({ name: state.me.username, level: state.me.level, isMe: true }); + } else if (state.myAnonName) { + items.push({ name: state.myAnonName, level: 0, isMe: true }); + } + + count.textContent = items.length ? String(items.length) : ""; + list.innerHTML = ""; + + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "hint"; + empty.textContent = "未进入聊天室"; + list.appendChild(empty); + return; + } + + for (const it of items) { + const row = document.createElement("div"); + row.className = "onlineItem"; + const nameClass = it.level === 3 ? "userNameAdmin" : it.level === 2 ? "userNameVerified" : it.level === 1 ? "userNameMember" : ""; + row.innerHTML = ` + +
${escapeHtml(it.name)}
+
${it.isMe ? "(我)" : ""}
+ `; + list.appendChild(row); + } + } + + function prepareGuestName() { + const hint = $("gateGuestNameHint"); + const userInput = $("gateGuestUser"); + const name = randomGuestName(); + state.pendingGuestName = name; + if (hint) hint.textContent = `将以随机昵称进入:${name}`; + if (userInput) userInput.value = name; + } + + function closeEmojiPanel() { + const panel = $("emojiPanel"); + if (panel) panel.hidden = true; + } + + function buildEmojiPanelOnce() { + const panel = $("emojiPanel"); + if (!panel || panel.childElementCount) return; + + const emojis = [ + "😀", + "😁", + "😂", + "🤣", + "😊", + "😍", + "😭", + "😡", + "👍", + "👎", + "🙏", + "🎉", + "❤️", + "🔥", + "💯", + "🤔", + "🙃", + "😅", + "😎", + "😴", + "😱", + "👀", + "🙌", + "🥳", + "🤝", + "🤖", + "🐱", + "🐶", + "🌟", + "✅", + "❌", + ]; + + const grid = document.createElement("div"); + grid.className = "emojiGrid"; + for (const e of emojis) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "emojiBtn"; + btn.textContent = e; + btn.setAttribute("aria-label", e); + btn.addEventListener("click", () => { + insertIntoMsgInput(e); + closeEmojiPanel(); + }); + grid.appendChild(btn); + } + panel.appendChild(grid); + } + + function insertIntoMsgInput(text) { + const input = $("msgInput"); + if (!input) return; + input.focus(); + const value = input.value || ""; + const start = typeof input.selectionStart === "number" ? input.selectionStart : value.length; + const end = typeof input.selectionEnd === "number" ? input.selectionEnd : value.length; + input.value = value.slice(0, start) + text + value.slice(end); + const next = start + text.length; + try { + input.setSelectionRange(next, next); + } catch {} + } + + function clearPendingImage() { + if (state.pendingImageUrl) { + try { + URL.revokeObjectURL(state.pendingImageUrl); + } catch {} + } + state.pendingImageFile = null; + state.pendingImageUrl = null; + renderAttachment(); + } + + function setPendingImage(file) { + if (!file) return; + if (state.pendingImageUrl) { + try { + URL.revokeObjectURL(state.pendingImageUrl); + } catch {} + } + state.pendingImageFile = file; + state.pendingImageUrl = URL.createObjectURL(file); + renderAttachment(); + } + + function renderAttachment() { + const box = $("composerAttach"); + const thumb = $("inlineThumb"); + const wrap = $("msgInputWrap"); + if (!box) return; + if (!state.pendingImageFile || !state.pendingImageUrl) { + box.hidden = true; + box.innerHTML = ""; + if (thumb) { + thumb.hidden = true; + thumb.removeAttribute("src"); + } + if (wrap) wrap.classList.remove("hasThumb"); + return; + } + + box.hidden = false; + box.innerHTML = ` +
+ 待发送图片 +
+
${escapeHtml(state.pendingImageFile.name || "图片")}
+
将随本次发送一起发送
+
+ +
+ `; + + if (thumb) { + thumb.src = state.pendingImageUrl; + thumb.hidden = false; + } + if (wrap) wrap.classList.add("hasThumb"); + + const removeBtn = $("attachRemoveBtn"); + if (removeBtn) removeBtn.addEventListener("click", () => clearPendingImage()); + } + + function levelName(level) { + if (level === 3) return "管理员"; + if (level === 2) return "认证会员"; + if (level === 1) return "注册会员"; + return "匿名游客"; + } + + function gateError(msg) { + const el = $("gateError"); + if (!el) return; + el.textContent = msg || ""; + el.classList.toggle("show", Boolean(msg)); + } + + function showGate() { + gateError(""); + const loginWrapper = $("loginWrapper"); + const doors = $("doorsContainer"); + const chatRoom = $("chatRoom"); + if (loginWrapper) { + loginWrapper.style.display = ""; + loginWrapper.classList.remove("fade-out"); + } + if (doors) { + doors.style.display = ""; + doors.classList.remove("open"); + } + if (chatRoom) chatRoom.classList.remove("active"); + refreshGateLobbyHint().catch(() => {}); + prepareGuestName(); + } + + function hideGate() { + gateError(""); + closeGateModal(); + const loginWrapper = $("loginWrapper"); + const doors = $("doorsContainer"); + const chatRoom = $("chatRoom"); + if (loginWrapper) loginWrapper.classList.add("fade-out"); + if (doors) doors.classList.add("open"); + if (chatRoom) chatRoom.classList.add("active"); + setTimeout(() => { + const w = $("loginWrapper"); + const d = $("doorsContainer"); + if (w) w.style.display = "none"; + if (d) d.style.display = "none"; + }, 1300); + } + + function setGateView(view) { + const login = $("gateLoginForm"); + const reg = $("gateRegisterForm"); + const guest = $("gateGuestForm"); + if (!login || !reg || !guest) return; + login.hidden = view !== "login"; + reg.hidden = view !== "register"; + guest.hidden = view !== "guest"; + $("gateTabLogin").classList.toggle("btnPrimary", view === "login"); + $("gateTabRegister").classList.toggle("btnPrimary", view === "register"); + $("gateTabGuest").classList.toggle("btnPrimary", view === "guest"); + const titles = { login: "登录", register: "注册", guest: "游客登录" }; + const titleEl = $("gateModalTitle"); + if (titleEl) titleEl.textContent = titles[view] || ""; + $("gateModal").hidden = false; + gateError(""); + if (view === "guest") prepareGuestName(); + } + + function closeGateModal() { + $("gateModal").hidden = true; + gateError(""); + } + + async function api(path, opts = {}) { + const resp = await fetch(path, { credentials: "include", ...opts }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || data?.error) throw new Error(data.error || `请求失败: ${resp.status}`); + return data; + } + + async function refreshMe() { + const data = await api("/api/me"); + state.me = data.user; + if (state.me) { + state.myAnonName = null; + clearGuestSession(); + hideGate(); + } + renderMe(); + renderAuth(); + renderOnlineList(); + $("adminCard").style.display = state.me && state.me.level >= 3 ? "" : "none"; + if (state.me && state.me.level >= 3) { + loadUsersAdmin().catch(() => {}); + } + setFoldDefaultsOnce(); + updateComposerState(); + } + + async function getLobby() { + const data = await api("/api/lobby"); + return data.room; + } + + async function refreshGateLobbyHint() { + const hint = $("gateGuestHint"); + if (!hint) return; + const pass = $("gateGuestPass"); + const passGroup = pass ? pass.closest(".input-group") : null; + try { + const lobby = await getLobby(); + if (lobby.is_private) { + hint.textContent = "该聊天室已设置密码:游客进入需要输入密码。"; + if (passGroup) passGroup.hidden = false; + if (pass) pass.disabled = false; + } else { + hint.textContent = "该聊天室未设置密码:游客可直接进入(开放聊天室仅可浏览,发言需注册登录)。"; + if (passGroup) passGroup.hidden = true; + if (pass) { + pass.disabled = true; + pass.value = ""; + } + } + } catch { + hint.textContent = ""; + if (passGroup) passGroup.hidden = false; + if (pass) pass.disabled = false; + } + } + + function stopStream() { + if (state.es) { + state.es.close(); + state.es = null; + } + } + + function resetChatView() { + state.currentRoom = null; + state.accessToken = null; + state.lastMessageId = 0; + $("roomTitle").textContent = "未进入聊天室"; + $("roomSubtitle").textContent = ""; + $("messages").innerHTML = ""; + $("msgInput").disabled = true; + $("sendBtn").disabled = true; + $("emojiBtn").disabled = true; + $("imageBtn").disabled = true; + $("imageBtn").hidden = true; + $("leaveRoomBtn").disabled = true; + clearPendingImage(); + closeEmojiPanel(); + renderOnlineList(); + } + + function renderMe() { + const meBox = $("meBox"); + if (!meBox) return; + if (state.me) { + meBox.textContent = `${state.me.username} · ${levelName(state.me.level)}`; + return; + } + if (state.myAnonName) { + meBox.textContent = `${state.myAnonName} · 匿名游客`; + return; + } + meBox.textContent = "未登录"; + } + + function renderAuth() { + const box = $("authBox"); + if (!box) return; + + if (state.me) { + box.innerHTML = ` +
+
已登录:${state.me.username}
+ +
+ `; + $("logoutBtn").addEventListener("click", async () => { + await api("/api/auth/logout", { method: "POST" }).catch(() => {}); + state.me = null; + state.myAnonName = null; + clearGuestSession(); + stopStream(); + resetChatView(); + await refreshMe(); + showGate(); + }); + return; + } + + if (state.myAnonName) { + box.innerHTML = ` +
+
游客:${state.myAnonName}
+ +
+ `; + $("guestExitBtn").addEventListener("click", () => { + state.myAnonName = null; + state.accessToken = null; + clearGuestSession(); + stopStream(); + resetChatView(); + renderMe(); + renderAuth(); + renderOnlineList(); + showGate(); + }); + return; + } + + box.innerHTML = ` +
+
请在门口登录/注册/游客进入
+ +
+ `; + $("openGateBtn").addEventListener("click", () => showGate()); + } + + function appendMessage(msg) { + const isMe = + (state.me && msg.user_id && state.me.id === msg.user_id) || + (!state.me && state.myAnonName && msg.user_id === null && msg.sender_name === state.myAnonName); + const row = document.createElement("div"); + row.className = `msgRow ${isMe ? "right" : "left"}`; + const bubble = document.createElement("div"); + bubble.className = `bubble ${isMe ? "me" : ""}`; + + const meta = document.createElement("div"); + meta.className = "meta"; + const time = new Date(msg.created_at).toLocaleString(); + const lvl = typeof msg.sender_level === "number" ? msg.sender_level : 0; + const nameClass = lvl === 3 ? "userNameAdmin" : lvl === 2 ? "userNameVerified" : lvl === 1 ? "userNameMember" : ""; + meta.innerHTML = ` + ${escapeHtml(msg.sender_name)} + · + ${escapeHtml(time)} + `; + bubble.appendChild(meta); + + if (msg.type === "image" && msg.r2_key) { + const img = document.createElement("img"); + const access = state.currentRoom?.is_private ? `?accessToken=${encodeURIComponent(state.accessToken || "")}` : ""; + const src = `/api/images/${encodeURIComponent(msg.r2_key)}${access}`; + img.src = src; + img.loading = "lazy"; + img.addEventListener("click", () => openImageViewer(src)); + bubble.appendChild(img); + } else { + const text = document.createElement("div"); + text.textContent = msg.content || ""; + bubble.appendChild(text); + } + + row.appendChild(bubble); + $("messages").appendChild(row); + $("messages").scrollTop = $("messages").scrollHeight; + } + + async function loadMessages() { + if (!state.currentRoom) return; + const q = new URLSearchParams(); + q.set("after", String(state.lastMessageId || 0)); + if (state.currentRoom.is_private) q.set("accessToken", state.accessToken || ""); + const data = await api(`/api/rooms/${state.currentRoom.id}/messages?${q.toString()}`); + for (const msg of data.messages) { + state.lastMessageId = Math.max(state.lastMessageId, msg.id); + appendMessage(msg); + } + } + + function startStream() { + if (!state.currentRoom) return; + const q = new URLSearchParams(); + q.set("after", String(state.lastMessageId || 0)); + if (state.currentRoom.is_private) q.set("accessToken", state.accessToken || ""); + const es = new EventSource(`/api/rooms/${state.currentRoom.id}/stream?${q.toString()}`); + state.es = es; + es.addEventListener("message", (evt) => { + const msg = JSON.parse(evt.data); + if (typeof msg?.id === "number" && msg.id <= state.lastMessageId) return; + state.lastMessageId = Math.max(state.lastMessageId, msg.id); + appendMessage(msg); + }); + es.onerror = () => { + // 浏览器会自动重连;这里避免刷屏 + }; + } + + function updateComposerState() { + const hasRoom = Boolean(state.currentRoom); + const canText = + hasRoom && + (state.currentRoom.is_private + ? Boolean(state.me) || (state.currentRoom.allow_anonymous && Boolean(state.accessToken)) + : Boolean(state.me)); + + $("msgInput").disabled = !canText; + $("sendBtn").disabled = !canText; + $("emojiBtn").disabled = !canText; + + const canImage = hasRoom && state.me && state.me.level >= 2; + $("imageBtn").hidden = !canImage; + $("imageBtn").disabled = !canText; + + if (hasRoom && !state.currentRoom.is_private && !state.me) { + $("msgInput").placeholder = "开放聊天室需要登录才能发言"; + } else { + $("msgInput").placeholder = "输入消息…"; + } + } + + async function enterLobbyAsLoggedIn() { + const lobby = await getLobby(); + const joined = await api(`/api/rooms/${lobby.id}/join`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + state.accessToken = joined.accessToken || null; + state.myAnonName = null; + clearGuestSession(); + await enterLobbyView(lobby, lobby.is_private ? "私密" : "开放"); + } + + async function enterLobbyAsGuest({ nickname, password }) { + const lobby = await getLobby(); + if (lobby.is_private) { + const joined = await api(`/api/rooms/${lobby.id}/join`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: password || "", nickname }), + }); + state.myAnonName = joined.me?.nickname || nickname; + state.accessToken = joined.accessToken || null; + if (state.accessToken) { + saveGuestSession({ roomId: lobby.id, nickname: state.myAnonName, accessToken: state.accessToken }); + } + await enterLobbyView(lobby, "私密 · 游客"); + return; + } + state.myAnonName = nickname; + state.accessToken = null; + clearGuestSession(); + await enterLobbyView(lobby, "开放 · 游客"); + } + + async function enterLobbyView(lobby, subtitle) { + stopStream(); + $("messages").innerHTML = ""; + state.lastMessageId = 0; + state.currentRoom = lobby; + $("roomTitle").textContent = lobby.name; + $("roomSubtitle").textContent = subtitle; + $("leaveRoomBtn").disabled = false; + await loadMessages(); + startStream(); + updateComposerState(); + hideGate(); + renderMe(); + renderAuth(); + renderOnlineList(); + clearPendingImage(); + closeEmojiPanel(); + } + + async function sendMessage() { + const room = state.currentRoom; + if (!room) return; + + const text = ($("msgInput").value || "").trim(); + const file = state.pendingImageFile; + if (!text && !file) return; + + if (text) { + const payload = { + type: "text", + content: text, + accessToken: room.is_private ? state.accessToken : undefined, + }; + const data = await api(`/api/rooms/${room.id}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + $("msgInput").value = ""; + if (data?.message?.id && data.message.id > state.lastMessageId) { + state.lastMessageId = data.message.id; + appendMessage(data.message); + } + } + + if (file) { + await uploadImageFile(file); + clearPendingImage(); + } + + closeEmojiPanel(); + } + + async function uploadImageFile(file) { + const room = state.currentRoom; + if (!room) return; + if (!state.me) throw new Error("登录后可发送图片"); + if (state.me.level < 2) throw new Error("认证会员才可以发送图片"); + const form = new FormData(); + form.set("file", file); + if (room.is_private) form.set("accessToken", state.accessToken || ""); + const data = await api(`/api/rooms/${room.id}/upload`, { method: "POST", body: form }); + if (data?.message?.id && data.message.id > state.lastMessageId) { + state.lastMessageId = data.message.id; + appendMessage(data.message); + } + } + + async function loadUsersAdmin() { + const box = $("adminUsersBox"); + if (!box) return; + box.innerHTML = `
加载中…
`; + try { + const data = await api("/api/admin/users"); + const users = data.users || []; + box.innerHTML = ""; + + const header = document.createElement("div"); + header.className = "adminHeader"; + header.innerHTML = `
用户
操作
`; + box.appendChild(header); + + for (const u of users) { + const editIcon = ` + + `; + const trashIcon = ` + + `; + + const row = document.createElement("div"); + row.className = "adminRow"; + const nameClass = + u.level === 3 ? "userNameAdmin" : u.level === 2 ? "userNameVerified" : u.level === 1 ? "userNameMember" : ""; + row.innerHTML = ` +
${escapeHtml(u.username)}
+
+ + +
+ `; + + row.querySelector('[data-action="edit"]').addEventListener("click", () => { + openUserEditor(u); + }); + row.querySelector('[data-action="delete"]').addEventListener("click", async () => { + const ok = await confirmDialog({ + title: "删除用户", + message: `确定删除用户 ${u.username}?\n历史消息会保留,但账号会被删除。`, + okText: "删除", + cancelText: "取消", + }); + if (!ok) return; + try { + await api(`/api/admin/users/${u.id}`, { method: "DELETE" }); + await loadUsersAdmin(); + } catch (err) { + toast(err.message, "bad"); + } + }); + + box.appendChild(row); + } + } catch (err) { + box.innerHTML = `
失败:${escapeHtml(err.message)}
`; + } + } + + function openUserEditor(user) { + const tools = $("adminToolsBox"); + if (!tools) return; + tools.hidden = false; + tools.innerHTML = ` +
+
编辑用户:${escapeHtml(user.username)}
+
+ 等级 + +
+ + + +
+ + +
+
+
+ `; + + const levelEl = $("editUserLevel"); + const emailEl = $("editUserEmail"); + const qqEl = $("editUserQq"); + const phoneEl = $("editUserPhone"); + const statusEl = $("editUserStatus"); + + levelEl.value = String(user.level); + emailEl.value = user.email || ""; + qqEl.value = user.qq || ""; + phoneEl.value = user.phone || ""; + + $("editUserCloseBtn").addEventListener("click", (e) => { + e.preventDefault(); + tools.hidden = true; + tools.innerHTML = ""; + }); + + $("editUserSaveBtn").addEventListener("click", async (e) => { + e.preventDefault(); + statusEl.textContent = "保存中…"; + try { + await api(`/api/admin/users/${user.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + level: Number(levelEl.value), + email: emailEl.value, + qq: qqEl.value, + phone: phoneEl.value, + }), + }); + statusEl.textContent = "已保存"; + toast("已保存", "ok"); + await refreshMe(); + await loadUsersAdmin(); + } catch (err) { + statusEl.textContent = err.message; + toast(err.message, "bad"); + } + }); + } + + async function showLobbyPasswordAdmin() { + if (!state.me || state.me.level < 3) return toast("需要管理员权限", "bad"); + const tools = $("adminToolsBox"); + if (!tools) return; + tools.hidden = !tools.hidden; + if (tools.hidden) return; + + tools.innerHTML = ` +
+
+ +
+ + +
+
+
+ `; + + const lobbyPwdMode = $("lobbyPwdMode"); + const lobbyPwdInput = $("lobbyPwdInput"); + const lobbyPwdSaveBtn = $("lobbyPwdSaveBtn"); + const lobbyPwdCloseBtn = $("lobbyPwdCloseBtn"); + const lobbyPwdStatus = $("lobbyPwdStatus"); + + const refreshLobbyPwdMode = async () => { + if (!lobbyPwdMode || !lobbyPwdStatus) return; + lobbyPwdStatus.textContent = ""; + lobbyPwdStatus.classList.remove("ok", "bad"); + try { + const lobby = await getLobby(); + lobbyPwdMode.textContent = lobby.is_private ? "当前:大厅已设置密码(游客进入需要密码)" : "当前:大厅未设置密码"; + } catch { + lobbyPwdMode.textContent = "当前:未知(加载失败)"; + } + }; + + const saveLobbyPassword = async () => { + if (!lobbyPwdStatus || !lobbyPwdInput) return; + lobbyPwdStatus.textContent = "保存中…"; + lobbyPwdStatus.classList.remove("ok", "bad"); + try { + const password = (lobbyPwdInput.value || "").trim(); + await api("/api/admin/lobby/password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password }), + }); + + lobbyPwdStatus.textContent = "已保存"; + lobbyPwdStatus.classList.add("ok"); + lobbyPwdInput.value = ""; + toast("已保存", "ok"); + + await refreshLobbyPwdMode(); + await refreshGateLobbyHint(); + + if (state.currentRoom && state.currentRoom.id === "lobby") { + const lobby = await getLobby(); + state.currentRoom = lobby; + $("roomSubtitle").textContent = state.me ? (lobby.is_private ? "私密" : "开放") : $("roomSubtitle").textContent; + updateComposerState(); + } + } catch (err) { + lobbyPwdStatus.textContent = err.message || "保存失败"; + lobbyPwdStatus.classList.add("bad"); + toast(err.message || "保存失败", "bad"); + } + }; + + if (lobbyPwdCloseBtn) { + lobbyPwdCloseBtn.addEventListener("click", (e) => { + e.preventDefault(); + tools.hidden = true; + tools.innerHTML = ""; + }); + } + if (lobbyPwdSaveBtn) { + lobbyPwdSaveBtn.addEventListener("click", (e) => { + e.preventDefault(); + saveLobbyPassword().catch((e2) => toast(e2.message, "bad")); + }); + } + + await refreshLobbyPwdMode(); + if (lobbyPwdInput) lobbyPwdInput.focus(); + } + + function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function openImageViewer(src) { + const viewer = $("imgViewer"); + const img = $("imgViewerImg"); + if (!viewer || !img) return; + img.src = src; + viewer.hidden = false; + } + + function closeImageViewer() { + const viewer = $("imgViewer"); + const img = $("imgViewerImg"); + if (!viewer || !img) return; + viewer.hidden = true; + img.removeAttribute("src"); + } + + function bindUi() { + const imgViewerBackdrop = $("imgViewerBackdrop"); + const imgViewerClose = $("imgViewerClose"); + if (imgViewerBackdrop) imgViewerBackdrop.addEventListener("click", () => closeImageViewer()); + if (imgViewerClose) imgViewerClose.addEventListener("click", () => closeImageViewer()); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeImageViewer(); + if (e.key === "Escape") closeEmojiPanel(); + }); + + // 找回密码模态框 + const forgotModal = $("forgotModal"); + const forgotStep1 = $("forgotStep1"); + const forgotStep2 = $("forgotStep2"); + const forgotStep1Msg = $("forgotStep1Msg"); + const forgotStep2Msg = $("forgotStep2Msg"); + + function openForgotModal() { + $("forgotUsername").value = ""; + $("forgotToken").value = ""; + $("forgotNewPass").value = ""; + forgotStep1Msg.textContent = ""; + forgotStep2Msg.textContent = ""; + forgotStep1.style.display = ""; + forgotStep2.style.display = "none"; + forgotModal.hidden = false; + $("forgotUsername").focus(); + } + function closeForgotModal() { + forgotModal.hidden = true; + } + + $("forgotPasswordBtn").addEventListener("click", openForgotModal); + $("forgotCancelBtn").addEventListener("click", closeForgotModal); + + $("forgotSendBtn").addEventListener("click", async () => { + const username = $("forgotUsername").value.trim(); + if (!username) { forgotStep1Msg.textContent = "请输入用户名"; return; } + $("forgotSendBtn").disabled = true; + forgotStep1Msg.textContent = "发送中..."; + try { + const resp = await fetch("/api/auth/request-password-reset", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username }), + }); + const data = await resp.json().catch(() => ({})); + if (resp.ok) { + if (data.devResetLink) { + forgotStep1Msg.innerHTML = `开发模式:点击这里重置密码`; + } else { + forgotStep1Msg.textContent = data.message || "✅ 验证码已发送到邮箱,请查收"; + setTimeout(() => { + forgotStep1.style.display = "none"; + forgotStep2.style.display = ""; + $("forgotToken").focus(); + }, 1500); + } + } else { + forgotStep1Msg.textContent = data.error || "请求失败,请稍后重试"; + } + } catch { + forgotStep1Msg.textContent = "网络错误,请稍后重试"; + } finally { + $("forgotSendBtn").disabled = false; + } + }); + + $("forgotBackBtn").addEventListener("click", () => { + forgotStep2.style.display = "none"; + forgotStep1.style.display = ""; + forgotStep1Msg.textContent = ""; + forgotStep2Msg.textContent = ""; + }); + + $("forgotResetBtn").addEventListener("click", async () => { + const token = $("forgotToken").value.trim(); + const newPassword = $("forgotNewPass").value; + if (!token || !/^\d{6}$/.test(token)) { forgotStep2Msg.textContent = "请输入 6 位数字验证码"; return; } + if (newPassword.length < 6) { forgotStep2Msg.textContent = "密码至少需要 6 位"; return; } + $("forgotResetBtn").disabled = true; + forgotStep2Msg.textContent = "重置中..."; + try { + const resp = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token, newPassword }), + }); + const data = await resp.json().catch(() => ({})); + if (resp.ok) { + forgotStep2Msg.textContent = "✅ 密码已重置成功!"; + setTimeout(closeForgotModal, 1800); + } else { + forgotStep2Msg.textContent = data.error || "重置失败,验证码可能已过期"; + } + } catch { + forgotStep2Msg.textContent = "网络错误,请稍后重试"; + } finally { + $("forgotResetBtn").disabled = false; + } + }); + + renderThemeBtn(); + const themeBtn = $("themeBtn"); + if (themeBtn) { + themeBtn.addEventListener("click", () => { + setTheme(getCurrentTheme() === "dark" ? "light" : "dark"); + }); + } + + $("sendBtn").addEventListener("click", () => sendMessage().catch((e) => toast(e.message, "bad"))); + $("msgInput").addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage().catch((err) => toast(err.message, "bad")); + } + }); + + const emojiBtn = $("emojiBtn"); + const emojiPanel = $("emojiPanel"); + if (emojiBtn && emojiPanel) { + buildEmojiPanelOnce(); + emojiBtn.addEventListener("click", () => { + if (emojiBtn.disabled) return; + emojiPanel.hidden = !emojiPanel.hidden; + }); + + document.addEventListener("click", (e) => { + if (emojiPanel.hidden) return; + const target = e.target; + if (target === emojiBtn || emojiBtn.contains(target)) return; + if (emojiPanel.contains(target)) return; + closeEmojiPanel(); + }); + } + + const imageBtn = $("imageBtn"); + const fileInput = $("fileInput"); + if (imageBtn && fileInput) { + imageBtn.addEventListener("click", () => { + if (imageBtn.disabled || imageBtn.hidden) return; + fileInput.click(); + }); + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0] || null; + fileInput.value = ""; + if (!file) return; + setPendingImage(file); + }); + } + $("leaveRoomBtn").addEventListener("click", () => { + (async () => { + if (state.me) { + const ok = await confirmDialog({ + title: "离开聊天室", + message: "当前版本只有一个聊天室:离开将同时退出登录。确定退出吗?", + okText: "退出登录", + cancelText: "取消", + }); + if (!ok) return; + await api("/api/auth/logout", { method: "POST" }).catch(() => {}); + state.me = null; + clearGuestSession(); + stopStream(); + resetChatView(); + await refreshMe(); + showGate(); + return; + } + + if (state.myAnonName) { + const ok = await confirmDialog({ + title: "离开聊天室", + message: "离开将同时退出游客身份。确定退出吗?", + okText: "退出游客", + cancelText: "取消", + }); + if (!ok) return; + state.myAnonName = null; + state.accessToken = null; + clearGuestSession(); + stopStream(); + resetChatView(); + renderMe(); + renderAuth(); + renderOnlineList(); + showGate(); + return; + } + + stopStream(); + resetChatView(); + showGate(); + })().catch((e) => toast(e.message, "bad")); + }); + $("loadUsersBtn").addEventListener("click", () => loadUsersAdmin().catch((e) => toast(e.message, "bad"))); + $("lobbyPwdBtn").addEventListener("click", () => showLobbyPasswordAdmin().catch((e) => toast(e.message, "bad"))); + + $("gateTabLogin").addEventListener("click", () => setGateView("login")); + $("gateTabRegister").addEventListener("click", () => setGateView("register")); + $("gateTabGuest").addEventListener("click", () => setGateView("guest")); + + $("gateModalClose").addEventListener("click", closeGateModal); + $("gateModal").addEventListener("click", (e) => { + if (e.target === $("gateModal")) closeGateModal(); + }); + + $("gateLoginForm").addEventListener("submit", async (e) => { + e.preventDefault(); + gateError(""); + try { + await api("/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: $("gateLoginUser").value, + password: $("gateLoginPass").value, + }), + }); + await refreshMe(); + await enterLobbyAsLoggedIn(); + } catch (err) { + gateError(err.message); + } + }); + + $("gateRegisterForm").addEventListener("submit", async (e) => { + e.preventDefault(); + gateError(""); + try { + await api("/api/auth/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: $("gateRegUser").value, + password: $("gateRegPass").value, + email: $("gateRegEmail").value, + qq: $("gateRegQq").value, + phone: $("gateRegPhone").value, + }), + }); + await refreshMe(); + await enterLobbyAsLoggedIn(); + } catch (err) { + gateError(err.message); + } + }); + + $("gateGuestForm").addEventListener("submit", async (e) => { + e.preventDefault(); + gateError(""); + const nickname = state.pendingGuestName || randomGuestName(); + const password = ($("gateGuestPass").value || "").trim(); + try { + await enterLobbyAsGuest({ nickname, password }); + prepareGuestName(); + } catch (err) { + gateError(err.message); + } + }); + + setupMobileMenu(); + } + + async function tryRestoreGuestSession() { + const { exists, session } = loadGuestSession(); + if (!exists) return null; + if (!session) return false; + + try { + const lobby = await getLobby(); + if (lobby.id !== session.roomId) { + clearGuestSession(); + return false; + } + state.me = null; + state.myAnonName = session.nickname; + state.accessToken = lobby.is_private ? session.accessToken : null; + if (lobby.is_private && !state.accessToken) throw new Error("需要重新输入聊天室密码"); + await enterLobbyView(lobby, lobby.is_private ? "私密 · 游客" : "开放 · 游客"); + return true; + } catch { + clearGuestSession(); + state.myAnonName = null; + state.accessToken = null; + return false; + } + } + + async function main() { + bindUi(); + resetChatView(); + await refreshMe(); + if (state.me) { + await enterLobbyAsLoggedIn(); + } else { + const restored = await tryRestoreGuestSession(); + if (restored === true) return; + if (restored === false) toast("需要重新输入聊天室密码(已过期)", "bad", 2600); + closeGateModal(); + showGate(); + } + } + + window.addEventListener("error", (e) => { + // 让你在“没反应”时也能看到原因 + if (String(e?.filename || "").includes("bootstrap-autofill-overlay")) return; + console.error(e.error || e.message || e); + }); + + main().catch((err) => { + console.error(err); + alert(err instanceof Error ? err.message : String(err)); + }); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..99d8c20 --- /dev/null +++ b/public/index.html @@ -0,0 +1,282 @@ + + + + + + 在线聊天室 + + + + +
+
+
+
+
聊天室
+
+
+ + +
+
+ + + +
+ + +
+
+
+
未进入聊天室
+
+
+
+ +
+
+ +
+ +
+
+ + + + +
+ + +
+ +
+ + + + +
+
+
+
+
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/public/reset.html b/public/reset.html new file mode 100644 index 0000000..346126c --- /dev/null +++ b/public/reset.html @@ -0,0 +1,153 @@ + + + + + + 找回密码 + + + +
+
+
找回密码
+ + +
+
请输入你的用户名,我们将向你注册时填写的邮箱发送 6 位数字验证码。
+ + +
+ 返回聊天室 +
+ + + +
+
+ + + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..82de70f --- /dev/null +++ b/schema.sql @@ -0,0 +1,52 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + email TEXT, + qq TEXT, + phone TEXT, + level INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_private INTEGER NOT NULL, + password_hash TEXT, + allow_anonymous INTEGER NOT NULL, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + user_id TEXT, + sender_name TEXT NOT NULL, + type TEXT NOT NULL, + content TEXT, + r2_key TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id); + +CREATE TABLE IF NOT EXISTS bans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + reason TEXT, + until INTEGER, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS password_resets ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + email TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..c38842a --- /dev/null +++ b/src/env.ts @@ -0,0 +1,29 @@ +export type Env = { + DB: D1Database; + CACHE?: KVNamespace; + MEMOS_CACHE?: KVNamespace; + BUCKET: R2Bucket; + ASSETS: Fetcher; + SESSION_TTL_SECONDS?: string; + ROOM_ACCESS_TTL_SECONDS?: string; + DEV_MODE?: string; + RESEND_API_KEY?: string; + RESEND_FROM_EMAIL?: string; +}; + +export function getCache(env: Env): KVNamespace { + const kv = env.CACHE ?? env.MEMOS_CACHE; + if (!kv) throw new Error('KV 未绑定:请在 wrangler.toml 里绑定 "CACHE"'); + return kv; +} + +export function getNumberVar(env: Env, key: keyof Env, fallback: number): number { + const value = env[key]; + if (typeof value !== "string") return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function isDevMode(env: Env): boolean { + return String(env.DEV_MODE ?? "").toLowerCase() === "true"; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e341f0a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,748 @@ +import type { Env } from "./env"; +import { isDevMode } from "./env"; +import { clearCookie, setCookie } from "./lib/cookies"; +import { hashPassword, randomNumericCode, randomToken, verifyPassword } from "./lib/crypto"; +import { sendPasswordResetEmail } from "./lib/email"; +import { forbidden, json, notFound, readJson, unauthorized } from "./lib/http"; +import { createRoomAccessToken, verifyRoomAccessToken } from "./lib/roomAccess"; +import { createSession, destroySession, getSession } from "./lib/sessions"; +import { sleep } from "./lib/sleep"; + +type User = { + id: string; + username: string; + email: string | null; + qq: string | null; + phone: string | null; + level: number; + created_at: number; +}; + +type Room = { + id: string; + name: string; + is_private: number; + allow_anonymous: number; + created_by: string; + created_at: number; +}; + +type Message = { + id: number; + room_id: string; + user_id: string | null; + sender_name: string; + sender_level: number; + type: string; + content: string | null; + r2_key: string | null; + created_at: number; +}; + +function pickPublicUser(user: User) { + return { + id: user.id, + username: user.username, + email: user.email, + qq: user.qq, + phone: user.phone, + level: user.level, + created_at: user.created_at, + }; +} + +async function getUserById(env: Env, id: string): Promise { + const row = await env.DB.prepare( + "SELECT id, username, email, qq, phone, level, created_at FROM users WHERE id = ?", + ) + .bind(id) + .first(); + return row ?? null; +} + +async function getUserByUsernameForLogin( + env: Env, + username: string, +): Promise<(User & { password_hash: string }) | null> { + const row = await env.DB.prepare( + "SELECT id, username, password_hash, email, qq, phone, level, created_at FROM users WHERE username = ?", + ) + .bind(username) + .first(); + return row ?? null; +} + +async function isUserBanned(env: Env, userId: string): Promise<{ banned: boolean; reason?: string | null }> { + const now = Date.now(); + const row = await env.DB.prepare( + "SELECT reason, until FROM bans WHERE user_id = ? ORDER BY id DESC LIMIT 1", + ) + .bind(userId) + .first<{ reason: string | null; until: number | null }>(); + if (!row) return { banned: false }; + if (row.until && row.until < now) return { banned: false }; + return { banned: true, reason: row.reason }; +} + +async function getRoom(env: Env, roomId: string): Promise<(Room & { password_hash: string | null }) | null> { + const row = await env.DB.prepare( + "SELECT id, name, is_private, password_hash, allow_anonymous, created_by, created_at FROM rooms WHERE id = ?", + ) + .bind(roomId) + .first(); + return row ?? null; +} + +const LOBBY_ROOM_ID = "lobby"; + +async function ensureLobbyRoom(env: Env): Promise { + const existing = await getRoom(env, LOBBY_ROOM_ID); + if (existing) { + if (existing.is_private && !existing.password_hash) { + await env.DB.prepare("UPDATE rooms SET is_private = 0, password_hash = NULL WHERE id = ?") + .bind(existing.id) + .run(); + const fixed = await getRoom(env, LOBBY_ROOM_ID); + if (!fixed) throw new Error("Failed to load lobby room"); + return fixed; + } + return existing; + } + const now = Date.now(); + await env.DB.prepare( + "INSERT INTO rooms (id, name, is_private, password_hash, allow_anonymous, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(LOBBY_ROOM_ID, "大厅", 0, null, 1, "system", now) + .run(); + const created = await getRoom(env, LOBBY_ROOM_ID); + if (!created) throw new Error("Failed to create lobby room"); + return created; +} + +async function requireAdmin(env: Env, request: Request): Promise { + const session = await getSession(env, request); + if (!session) return unauthorized(); + const user = await getUserById(env, session.userId); + if (!user) return unauthorized(); + if (user.level < 3) return forbidden("需要管理员权限"); + return user; +} + +async function requireLogin(env: Env, request: Request): Promise { + const session = await getSession(env, request); + if (!session) return unauthorized(); + const user = await getUserById(env, session.userId); + if (!user) return unauthorized(); + const ban = await isUserBanned(env, user.id); + if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `:${ban.reason}` : ""}`); + return user; +} + +async function requireRoomAccessIfPrivate( + env: Env, + request: Request, + room: Room & { password_hash: string | null }, +): Promise<{ access: { userId?: string; nickname?: string } | null } | Response> { + if (!room.is_private) return { access: null }; + const url = new URL(request.url); + const accessToken = url.searchParams.get("accessToken") ?? request.headers.get("x-room-access") ?? null; + if (!accessToken) return forbidden("私密房间需要密码进入"); + const access = await verifyRoomAccessToken(env, room.id, accessToken); + if (!access) return forbidden("房间访问令牌无效/已过期,请重新输入密码进入"); + return { access }; +} + +function sanitizeUsername(username: string): string | null { + const u = username.trim(); + if (u.length < 2 || u.length > 20) return null; + if (!/^[\p{L}\p{N}_-]+$/u.test(u)) return null; + return u; +} + +function sanitizeNickname(nickname: string): string | null { + const v = nickname.trim(); + if (v.length < 1 || v.length > 20) return null; + return v; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith("/api/")) { + return env.ASSETS.fetch(request); + } + try { + return await handleApi(request, env, ctx); + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + return json({ error: isDevMode(env) ? message : "服务器错误" }, { status: 500 }); + } + }, +}; + +async function handleApi(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const { pathname } = url; + + if (pathname === "/api/health") return json({ ok: true }); + + if (pathname === "/api/lobby" && request.method === "GET") { + const lobby = await ensureLobbyRoom(env); + return json({ + room: { + id: lobby.id, + name: lobby.name, + is_private: lobby.is_private, + allow_anonymous: lobby.allow_anonymous, + created_by: lobby.created_by, + created_at: lobby.created_at, + }, + guestPasswordRequired: Boolean(lobby.is_private), + }); + } + + if (pathname === "/api/me" && request.method === "GET") { + const session = await getSession(env, request); + if (!session) return json({ user: null }); + const user = await getUserById(env, session.userId); + if (!user) return json({ user: null }); + return json({ user: pickPublicUser(user) }); + } + + if (pathname === "/api/auth/register" && request.method === "POST") { + const body = await readJson<{ + username?: string; + password?: string; + email?: string; + qq?: string; + phone?: string; + }>(request); + if (!body) return json({ error: "无效 JSON" }, { status: 400 }); + const username = body.username ? sanitizeUsername(body.username) : null; + const password = (body.password ?? "").trim(); + if (!username) return json({ error: "用户名不合法(2-20 位,仅字母数字 _-)" }, { status: 400 }); + if (username.startsWith("游客")) return json({ error: "用户名不能以“游客”开头(该前缀保留给匿名游客)" }, { status: 400 }); + if (password.length < 6) return json({ error: "密码至少 6 位" }, { status: 400 }); + const passwordHash = await hashPassword(password); + const id = crypto.randomUUID(); + const now = Date.now(); + try { + // 首个注册用户自动成为管理员(level=3),其余为注册会员(level=1) + await env.DB.prepare( + "INSERT INTO users (id, username, password_hash, email, qq, phone, level, created_at) VALUES (?, ?, ?, ?, ?, ?, (CASE WHEN (SELECT COUNT(1) FROM users) = 0 THEN 3 ELSE 1 END), ?)", + ) + .bind( + id, + username, + passwordHash, + body.email?.trim() || null, + body.qq?.trim() || null, + body.phone?.trim() || null, + now, + ) + .run(); + } catch { + return json({ error: "用户名已存在" }, { status: 409 }); + } + + let sessionCookie: string; + try { + ({ setCookie: sessionCookie } = await createSession(env, id)); + } catch (err) { + console.error(err); + return json({ error: "会话服务不可用(KV 未绑定或异常),请检查 KV 绑定" }, { status: 500 }); + } + const headers = new Headers(); + setCookie(headers, sessionCookie); + return json({ ok: true }, { headers }); + } + + if (pathname === "/api/auth/login" && request.method === "POST") { + const body = await readJson<{ username?: string; password?: string }>(request); + if (!body) return json({ error: "无效 JSON" }); + const username = body.username ? sanitizeUsername(body.username) : null; + const password = (body.password ?? "").trim(); + if (!username || !password) return json({ error: "用户名或密码错误" }); + const user = await getUserByUsernameForLogin(env, username); + if (!user) return json({ error: "用户名或密码错误" }); + const ok = await verifyPassword(password, user.password_hash); + if (!ok) return json({ error: "用户名或密码错误" }); + const ban = await isUserBanned(env, user.id); + if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `:${ban.reason}` : ""}`); + let sessionCookie: string; + try { + ({ setCookie: sessionCookie } = await createSession(env, user.id)); + } catch (err) { + console.error(err); + return json({ error: "会话服务不可用(KV 未绑定或异常),请检查 KV 绑定" }, { status: 500 }); + } + const headers = new Headers(); + setCookie(headers, sessionCookie); + return json({ ok: true }, { headers }); + } + + if (pathname === "/api/auth/debug/user" && request.method === "GET") { + if (!isDevMode(env)) return forbidden("仅 DEV_MODE 可用"); + const usernameRaw = url.searchParams.get("username") ?? ""; + const username = sanitizeUsername(usernameRaw) ?? ""; + if (!username) return json({ exists: false, reason: "invalid_username" }); + const row = await env.DB.prepare("SELECT id, username, email, level, created_at FROM users WHERE username = ?") + .bind(username) + .first<{ id: string; username: string; email: string | null; level: number; created_at: number }>(); + if (!row) return json({ exists: false }); + return json({ + exists: true, + user: { + id: row.id, + username: row.username, + hasEmail: Boolean(row.email), + level: row.level, + created_at: row.created_at, + }, + }); + } + + if (pathname === "/api/auth/logout" && request.method === "POST") { + const cookie = await destroySession(env, request); + const headers = new Headers(); + if (cookie) setCookie(headers, cookie); + return json({ ok: true }, { headers }); + } + + if (pathname === "/api/auth/request-password-reset" && request.method === "POST") { + const body = await readJson<{ username?: string }>(request); + if (!body?.username) return json({ error: "请输入用户名" }, { status: 400 }); + const username = sanitizeUsername(body.username); + if (!username) return json({ error: "用户名格式不正确" }, { status: 400 }); + + const user = await env.DB.prepare("SELECT id, username, email FROM users WHERE username = ?") + .bind(username) + .first<{ id: string; username: string; email: string | null }>(); + + if (!user) { + return json({ error: "用户不存在" }, { status: 404 }); + } + + if (!user.email) { + return json({ error: "该账号未绑定邮箱,无法找回密码" }, { status: 400 }); + } + + const token = randomNumericCode(6); // 生成 6 位数字验证码 + const now = Date.now(); + const expiresAt = now + 1000 * 60 * 30; + await env.DB.prepare( + "INSERT INTO password_resets (token, user_id, email, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(token, user.id, user.email, expiresAt, now) + .run(); + + if (isDevMode(env)) { + return json({ ok: true, devResetLink: `/reset.html?token=${encodeURIComponent(token)}` }); + } + + // 生产模式:发送邮件 + const emailResult = await sendPasswordResetEmail(env, user.email, token, user.username); + if (!emailResult.success) { + console.error("发送密码重置邮件失败:", emailResult.error); + return json({ error: "邮件发送失败,请稍后重试" }, { status: 500 }); + } + + return json({ ok: true, message: "重置链接已发送到你的邮箱,请查收" }); + } + + if (pathname === "/api/auth/reset-password" && request.method === "POST") { + const body = await readJson<{ token?: string; newPassword?: string }>(request); + const token = (body?.token ?? "").trim(); + const newPassword = (body?.newPassword ?? "").trim(); + if (!token || newPassword.length < 6) return json({ error: "参数错误" }, { status: 400 }); + const row = await env.DB.prepare("SELECT user_id, expires_at FROM password_resets WHERE token = ?") + .bind(token) + .first<{ user_id: string; expires_at: number }>(); + if (!row || row.expires_at < Date.now()) return json({ error: "链接无效或已过期" }, { status: 400 }); + const newHash = await hashPassword(newPassword); + await env.DB.batch([ + env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(newHash, row.user_id), + env.DB.prepare("DELETE FROM password_resets WHERE token = ?").bind(token), + ]); + return json({ ok: true }); + } + + if (pathname === "/api/rooms" && request.method === "GET") { + const lobby = await ensureLobbyRoom(env); + return json({ + rooms: [ + { + id: lobby.id, + name: lobby.name, + is_private: lobby.is_private, + allow_anonymous: lobby.allow_anonymous, + created_by: lobby.created_by, + created_at: lobby.created_at, + }, + ], + }); + } + + if (pathname === "/api/rooms" && request.method === "POST") { + return forbidden("当前版本仅支持一个聊天室(大厅)"); + } + + const joinMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/join$/); + if (joinMatch && request.method === "POST") { + const roomId = joinMatch[1]!; + const room = await getRoom(env, roomId); + if (!room) return json({ error: "房间不存在" }, { status: 404 }); + const body = await readJson<{ password?: string; nickname?: string }>(request); + + const session = await getSession(env, request); + if (!session) { + const password = (body?.password ?? "").trim(); + if (room.is_private) { + if (!room.password_hash) return json({ error: "房间配置错误:缺少密码" }, { status: 500 }); + const ok = await verifyPassword(password, room.password_hash); + if (!ok) return forbidden("密码错误"); + } + if (!room.is_private) return unauthorized("开放房间发言需要登录(可浏览无需加入)"); + if (!room.allow_anonymous) return unauthorized("该私密房间不允许游客匿名发言,请登录"); + const nickname = body?.nickname ? sanitizeNickname(body.nickname) : null; + if (!nickname) return json({ error: "请输入昵称(1-20)" }, { status: 400 }); + const { token, ttlSeconds } = await createRoomAccessToken(env, roomId, { nickname }); + return json({ ok: true, accessToken: token, ttlSeconds, me: { nickname } }); + } + + if (room.is_private && room.id !== LOBBY_ROOM_ID) { + const password = (body?.password ?? "").trim(); + if (!room.password_hash) return json({ error: "房间配置错误:缺少密码" }, { status: 500 }); + const ok = await verifyPassword(password, room.password_hash); + if (!ok) return forbidden("密码错误"); + } + + const user = await getUserById(env, session.userId); + if (!user) return unauthorized(); + const ban = await isUserBanned(env, user.id); + if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `:${ban.reason}` : ""}`); + const { token, ttlSeconds } = await createRoomAccessToken(env, roomId, { userId: user.id }); + return json({ ok: true, accessToken: token, ttlSeconds, me: pickPublicUser(user) }); + } + + const messagesMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/); + if (messagesMatch && request.method === "GET") { + const roomId = messagesMatch[1]!; + const room = await getRoom(env, roomId); + if (!room) return json({ error: "房间不存在" }, { status: 404 }); + const accessResp = await requireRoomAccessIfPrivate(env, request, room); + if (accessResp instanceof Response) return accessResp; + + const after = Number(url.searchParams.get("after") ?? "0"); + const afterId = Number.isFinite(after) && after >= 0 ? after : 0; + const rows = await env.DB.prepare( + "SELECT m.id, m.room_id, m.user_id, m.sender_name, COALESCE(u.level, 0) as sender_level, m.type, m.content, m.r2_key, m.created_at FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.room_id = ? AND m.id > ? ORDER BY m.id ASC LIMIT 200", + ) + .bind(roomId, afterId) + .all(); + return json({ messages: rows.results }); + } + + if (messagesMatch && request.method === "POST") { + const roomId = messagesMatch[1]!; + const room = await getRoom(env, roomId); + if (!room) return json({ error: "房间不存在" }, { status: 404 }); + + const body = await readJson<{ type?: string; content?: string; accessToken?: string }>(request); + if (!body) return json({ error: "无效 JSON" }, { status: 400 }); + const type = (body.type ?? "text").trim(); + const content = (body.content ?? "").trim(); + if (!content) return json({ error: "内容不能为空" }, { status: 400 }); + if (content.length > 2000) return json({ error: "内容过长" }, { status: 400 }); + + let senderUserId: string | null = null; + let senderName: string | null = null; + let level = 0; + + const session = await getSession(env, request); + if (session) { + const user = await getUserById(env, session.userId); + if (!user) return unauthorized(); + const ban = await isUserBanned(env, user.id); + if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `:${ban.reason}` : ""}`); + senderUserId = user.id; + senderName = user.username; + level = user.level; + } + + if (!room.is_private && !senderUserId) { + return unauthorized("开放房间必须登录才能发言"); + } + + if (room.is_private) { + const accessToken = (body.accessToken ?? "").trim(); + if (!accessToken) return forbidden("私密房间需要先输入密码进入"); + const access = await verifyRoomAccessToken(env, roomId, accessToken); + if (!access) return forbidden("房间访问令牌无效/已过期,请重新进入"); + if (!senderUserId) { + if (!room.allow_anonymous) return unauthorized("该私密房间不允许游客匿名发言,请登录"); + senderName = access.nickname ?? "游客"; + } + } + + if (!senderName) return unauthorized(); + + if (type !== "text" && type !== "emoji" && type !== "link" && type !== "note") { + return json({ error: "不支持的消息类型" }, { status: 400 }); + } + if (!senderUserId && type !== "text" && type !== "emoji") { + return forbidden("游客仅支持文字/表情"); + } + if (senderUserId && level < 1) return forbidden("无权限发言"); + + const now = Date.now(); + const result = await env.DB.prepare( + "INSERT INTO messages (room_id, user_id, sender_name, type, content, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(roomId, senderUserId, senderName, type, content, null, now) + .run(); + + return json({ + ok: true, + message: { + id: result.meta.last_row_id as number, + room_id: roomId, + user_id: senderUserId, + sender_name: senderName, + sender_level: level, + type, + content, + r2_key: null, + created_at: now, + } satisfies Message, + }); + } + + const uploadMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/upload$/); + if (uploadMatch && request.method === "POST") { + const roomId = uploadMatch[1]!; + const room = await getRoom(env, roomId); + if (!room) return json({ error: "房间不存在" }, { status: 404 }); + const userOrResp = await requireLogin(env, request); + if (userOrResp instanceof Response) return userOrResp; + if (userOrResp.level < 2) return forbidden("只有认证会员及以上可以上传图片"); + const form = await request.formData(); + const accessToken = String(form.get("accessToken") ?? ""); + if (room.is_private) { + const access = await verifyRoomAccessToken(env, roomId, accessToken); + if (!access) return forbidden("私密房间需要先输入密码进入"); + } + const file = form.get("file"); + if (!(file instanceof File)) return json({ error: "缺少文件" }, { status: 400 }); + if (!file.type.startsWith("image/")) return json({ error: "仅支持图片" }, { status: 400 }); + if (file.size > 5 * 1024 * 1024) return json({ error: "图片最大 5MB" }, { status: 400 }); + + const safeName = (file.name || "image").replace(/[^\p{L}\p{N}._-]/gu, "_").slice(0, 80); + const key = `${roomId}/${userOrResp.id}/${Date.now()}_${safeName}`; + const buf = await file.arrayBuffer(); + await env.BUCKET.put(key, buf, { httpMetadata: { contentType: file.type } }); + + const now = Date.now(); + const result = await env.DB.prepare( + "INSERT INTO messages (room_id, user_id, sender_name, type, content, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(roomId, userOrResp.id, userOrResp.username, "image", null, key, now) + .run(); + + return json({ + ok: true, + message: { + id: result.meta.last_row_id as number, + room_id: roomId, + user_id: userOrResp.id, + sender_name: userOrResp.username, + sender_level: userOrResp.level, + type: "image", + content: null, + r2_key: key, + created_at: now, + } satisfies Message, + }); + } + + const streamMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/stream$/); + if (streamMatch && request.method === "GET") { + const roomId = streamMatch[1]!; + const room = await getRoom(env, roomId); + if (!room) return json({ error: "房间不存在" }, { status: 404 }); + const accessResp = await requireRoomAccessIfPrivate(env, request, room); + if (accessResp instanceof Response) return accessResp; + + const after = Number(url.searchParams.get("after") ?? "0"); + let afterId = Number.isFinite(after) && after >= 0 ? after : 0; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(`event: hello\ndata: {}\n\n`)); + while (!request.signal.aborted) { + const rows = await env.DB.prepare( + "SELECT m.id, m.room_id, m.user_id, m.sender_name, COALESCE(u.level, 0) as sender_level, m.type, m.content, m.r2_key, m.created_at FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.room_id = ? AND m.id > ? ORDER BY m.id ASC LIMIT 100", + ) + .bind(roomId, afterId) + .all(); + + for (const msg of rows.results) { + afterId = Math.max(afterId, msg.id); + controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(msg)}\n\n`)); + } + await sleep(300, request.signal); + } + controller.close(); + }, + cancel() {}, + }); + + return new Response(stream, { + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); + } + + const imageMatch = pathname.match(/^\/api\/images\/(.+)$/); + if (imageMatch && request.method === "GET") { + const key = decodeURIComponent(imageMatch[1]!); + const roomId = key.split("/")[0] ?? ""; + if (!roomId) return notFound(); + const room = await getRoom(env, roomId); + if (!room) return notFound(); + if (room.is_private) { + const accessToken = url.searchParams.get("accessToken") ?? ""; + const access = await verifyRoomAccessToken(env, roomId, accessToken); + if (!access) return forbidden("私密房间图片需要先输入密码进入"); + } + const obj = await env.BUCKET.get(key); + if (!obj) return notFound(); + const headers = new Headers(); + obj.writeHttpMetadata(headers); + headers.set("cache-control", "public, max-age=31536000, immutable"); + return new Response(obj.body, { headers }); + } + + // --- Admin APIs --- + if (pathname === "/api/admin/users" && request.method === "GET") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const users = await env.DB.prepare( + "SELECT id, username, email, qq, phone, level, created_at FROM users ORDER BY created_at DESC LIMIT 200", + ).all(); + return json({ users: users.results }); + } + + if (pathname === "/api/admin/lobby/password" && request.method === "POST") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const lobby = await ensureLobbyRoom(env); + const body = await readJson<{ password?: string | null }>(request); + if (!body) return json({ error: "无效 JSON" }, { status: 400 }); + const password = (body.password ?? "").toString().trim(); + if (password) { + const passwordHash = await hashPassword(password); + await env.DB.prepare( + "UPDATE rooms SET is_private = 1, password_hash = ?, allow_anonymous = 1 WHERE id = ?", + ) + .bind(passwordHash, lobby.id) + .run(); + return json({ ok: true, mode: "private" }); + } + await env.DB.prepare("UPDATE rooms SET is_private = 0, password_hash = NULL WHERE id = ?").bind(lobby.id).run(); + return json({ ok: true, mode: "public" }); + } + + const patchUserMatch = pathname.match(/^\/api\/admin\/users\/([^/]+)$/); + if (patchUserMatch && request.method === "PATCH") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const userId = patchUserMatch[1]!; + const body = await readJson<{ level?: number; email?: string; qq?: string; phone?: string }>(request); + if (!body) return json({ error: "无效 JSON" }, { status: 400 }); + const level = typeof body.level === "number" ? body.level : null; + if (level !== null && (level < 0 || level > 3)) return json({ error: "level 必须是 0-3" }, { status: 400 }); + await env.DB.prepare( + "UPDATE users SET level = COALESCE(?, level), email = COALESCE(?, email), qq = COALESCE(?, qq), phone = COALESCE(?, phone) WHERE id = ?", + ) + .bind(level, body.email?.trim() || null, body.qq?.trim() || null, body.phone?.trim() || null, userId) + .run(); + return json({ ok: true }); + } + + if (patchUserMatch && request.method === "DELETE") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const userId = patchUserMatch[1]!; + + // 保护:避免误删最后一个管理员 + const adminCountRow = await env.DB.prepare("SELECT COUNT(1) as c FROM users WHERE level = 3").first<{ c: number }>(); + const targetLevelRow = await env.DB.prepare("SELECT level FROM users WHERE id = ?").bind(userId).first<{ level: number }>(); + if (!targetLevelRow) return json({ error: "用户不存在" }, { status: 404 }); + if (targetLevelRow.level === 3 && (adminCountRow?.c ?? 0) <= 1) return forbidden("不能删除最后一个管理员"); + + // 删除用户:保留历史消息(sender_name 仍保留),仅将 user_id 置空 + await env.DB.batch([ + env.DB.prepare("UPDATE messages SET user_id = NULL WHERE user_id = ?").bind(userId), + env.DB.prepare("DELETE FROM bans WHERE user_id = ?").bind(userId), + env.DB.prepare("DELETE FROM password_resets WHERE user_id = ?").bind(userId), + env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId), + ]); + return json({ ok: true }); + } + + const banMatch = pathname.match(/^\/api\/admin\/users\/([^/]+)\/ban$/); + if (banMatch && request.method === "POST") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const userId = banMatch[1]!; + const body = await readJson<{ reason?: string; minutes?: number }>(request); + const minutes = typeof body?.minutes === "number" ? body.minutes : null; + const until = minutes && minutes > 0 ? Date.now() + minutes * 60 * 1000 : null; + await env.DB.prepare("INSERT INTO bans (user_id, reason, until, created_at) VALUES (?, ?, ?, ?)") + .bind(userId, body?.reason?.trim() || null, until, Date.now()) + .run(); + return json({ ok: true }); + } + + const delMsgMatch = pathname.match(/^\/api\/admin\/messages\/(\d+)$/); + if (delMsgMatch && request.method === "DELETE") { + const adminOrResp = await requireAdmin(env, request); + if (adminOrResp instanceof Response) return adminOrResp; + const id = Number(delMsgMatch[1]!); + await env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run(); + return json({ ok: true }); + } + + if (pathname === "/api/admin/debug/create-admin" && request.method === "POST") { + if (!isDevMode(env)) return forbidden("仅 DEV_MODE 可用"); + const body = await readJson<{ username?: string; password?: string }>(request); + if (!body) return json({ error: "无效 JSON" }, { status: 400 }); + const username = body.username ? sanitizeUsername(body.username) : null; + const password = (body.password ?? "").trim(); + if (!username || password.length < 6) return json({ error: "参数错误" }, { status: 400 }); + if (username.startsWith("游客")) return json({ error: "用户名不能以“游客”开头(该前缀保留给匿名游客)" }, { status: 400 }); + const passwordHash = await hashPassword(password); + const id = crypto.randomUUID(); + const now = Date.now(); + await env.DB.prepare( + "INSERT INTO users (id, username, password_hash, email, qq, phone, level, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id, username, passwordHash, null, null, null, 3, now) + .run(); + return json({ ok: true, id }); + } + + if (pathname === "/api/_clear_session" && request.method === "POST") { + const headers = new Headers(); + setCookie(headers, clearCookie("sid", !isDevMode(env))); + return json({ ok: true }, { headers }); + } + + return notFound(); +} diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts new file mode 100644 index 0000000..5175d05 --- /dev/null +++ b/src/lib/cookies.ts @@ -0,0 +1,43 @@ +export function parseCookieHeader(headerValue: string | null): Record { + const out: Record = {}; + if (!headerValue) return out; + for (const part of headerValue.split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + if (!rawName) continue; + out[rawName] = decodeURIComponent(rawValue.join("=") ?? ""); + } + return out; +} + +export function getCookie(request: Request, name: string): string | null { + const cookies = parseCookieHeader(request.headers.get("cookie")); + return cookies[name] ?? null; +} + +export function setCookie(headers: Headers, cookie: string): void { + headers.append("set-cookie", cookie); +} + +export function makeCookie( + name: string, + value: string, + options: { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Lax" | "Strict" | "None"; + path?: string; + maxAgeSeconds?: number; + } = {}, +): string { + const parts = [`${name}=${encodeURIComponent(value)}`]; + parts.push(`Path=${options.path ?? "/"}`); + if (options.httpOnly) parts.push("HttpOnly"); + if (options.secure ?? true) parts.push("Secure"); + parts.push(`SameSite=${options.sameSite ?? "Lax"}`); + if (typeof options.maxAgeSeconds === "number") parts.push(`Max-Age=${Math.floor(options.maxAgeSeconds)}`); + return parts.join("; "); +} + +export function clearCookie(name: string, secure = true): string { + return `${name}=; Path=/; Max-Age=0; SameSite=Lax;${secure ? " Secure;" : ""} HttpOnly`; +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..3c5b569 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,117 @@ +function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +function bytesFromBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function randomToken(bytes = 32): string { + const buf = new Uint8Array(new ArrayBuffer(bytes)); + crypto.getRandomValues(buf); + return base64FromBytes(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +/** + * 生成数字验证码 + * @param length 验证码长度(默认 6 位) + * @returns 数字验证码字符串 + */ +export function randomNumericCode(length = 6): string { + const digits = "0123456789"; + let code = ""; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + + for (let i = 0; i < length; i++) { + code += digits[randomValues[i] % 10]; + } + + return code; +} + +// Cloudflare Workers PBKDF2 iterations have an upper bound; keep within supported range. +const PBKDF2_ITERATIONS = 100_000; + +export async function hashPassword(password: string): Promise { + const saltBytes = new Uint8Array(new ArrayBuffer(16)); + crypto.getRandomValues(saltBytes); + + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + + const bits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: saltBytes, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + keyMaterial, + 256, + ); + + const hashBytes = new Uint8Array(bits); + return ["pbkdf2", String(PBKDF2_ITERATIONS), base64FromBytes(saltBytes), base64FromBytes(hashBytes)].join("$"); +} + +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split("$"); + let algo: string; + let iterStr: string; + let saltB64: string; + let hashB64: string; + + // Backward/forward compatibility: + // - Current format: pbkdf2$150000$$ + // - Legacy (buggy parser expectation): pbkdf2$150000$$$$ + if (parts.length === 4) { + [algo, iterStr, saltB64, hashB64] = parts; + } else if (parts.length === 6) { + [algo, iterStr, , saltB64, , hashB64] = parts; + } else { + return false; + } + if (algo !== "pbkdf2") return false; + const iterations = Number(iterStr); + if (!Number.isFinite(iterations) || iterations < 1) return false; + if (iterations > PBKDF2_ITERATIONS) return false; + + const saltBytes = bytesFromBase64(saltB64); + const expectedHash = bytesFromBase64(hashB64); + + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + + const bits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: saltBytes, + iterations, + hash: "SHA-256", + }, + keyMaterial, + expectedHash.length * 8, + ); + + const actual = new Uint8Array(bits); + if (actual.length !== expectedHash.length) return false; + let mismatch = 0; + for (let i = 0; i < actual.length; i++) mismatch |= actual[i] ^ expectedHash[i]; + return mismatch === 0; +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..0cdefab --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,172 @@ +import { Resend } from "resend"; +import type { Env } from "../env"; + +/** + * 发送密码重置邮件 + * @param env 环境变量 + * @param to 收件人邮箱 + * @param resetToken 重置令牌 + * @param username 用户名 + * @returns 发送结果 + */ +export async function sendPasswordResetEmail( + env: Env, + to: string, + resetToken: string, + username: string +): Promise<{ success: boolean; error?: string }> { + const apiKey = env.RESEND_API_KEY; + const fromEmail = env.RESEND_FROM_EMAIL || "noreply@yourdomain.com"; + + if (!apiKey) { + return { + success: false, + error: "RESEND_API_KEY 未配置,请运行: npx wrangler secret put RESEND_API_KEY", + }; + } + + try { + const resend = new Resend(apiKey); + + // 发送邮件 + const { data, error } = await resend.emails.send({ + from: fromEmail, + to: [to], + subject: "密码重置验证码 - 聊天室", + html: ` + + + + + + + +
+
+

🔐 密码重置验证码

+
+ +
+

你好,${username}

+ +

我们收到了你的密码重置请求。请使用以下验证码来重置你的密码:

+ +
+
验证码
+
${resetToken}
+
+ +

+ 请在密码重置页面输入此验证码 +

+ +
+ ⚠️ 重要提示: +
    +
  • 此验证码将在 30 分钟后失效
  • +
  • 如果你没有请求重置密码,请忽略此邮件
  • +
  • 请勿将此验证码分享给他人
  • +
+
+
+ + +
+ + + `, + text: ` +你好,${username}! + +我们收到了你的密码重置请求。请使用以下验证码来重置你的密码: + +验证码:${resetToken} + +重要提示: +- 此验证码将在 30 分钟后失效 +- 如果你没有请求重置密码,请忽略此邮件 +- 请勿将此验证码分享给他人 + +此邮件由系统自动发送,请勿回复。 + `.trim(), + }); + + if (error) { + console.error("Resend 发送邮件失败:", error); + return { + success: false, + error: error.message || "邮件发送失败", + }; + } + + console.log("密码重置邮件已发送:", data); + return { success: true }; + } catch (error) { + console.error("发送邮件时出错:", error); + return { + success: false, + error: error instanceof Error ? error.message : "未知错误", + }; + } +} diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..0070ebe --- /dev/null +++ b/src/lib/http.ts @@ -0,0 +1,30 @@ +export function json(data: unknown, init?: ResponseInit): Response { + const headers = new Headers(init?.headers); + headers.set("content-type", "application/json; charset=utf-8"); + return new Response(JSON.stringify(data), { ...init, headers }); +} + +export function badRequest(message: string): Response { + return json({ error: message }, { status: 400 }); +} + +export function unauthorized(message = "未登录"): Response { + return json({ error: message }, { status: 401 }); +} + +export function forbidden(message = "无权限"): Response { + return json({ error: message }, { status: 403 }); +} + +export function notFound(): Response { + return json({ error: "Not found" }, { status: 404 }); +} + +export async function readJson(request: Request): Promise { + try { + return (await request.json()) as T; + } catch { + return null; + } +} + diff --git a/src/lib/roomAccess.ts b/src/lib/roomAccess.ts new file mode 100644 index 0000000..15159d1 --- /dev/null +++ b/src/lib/roomAccess.ts @@ -0,0 +1,31 @@ +import type { Env } from "../env"; +import { getCache, getNumberVar } from "../env"; +import { randomToken } from "./crypto"; + +export type RoomAccess = { + userId?: string; + nickname?: string; +}; + +export async function createRoomAccessToken( + env: Env, + roomId: string, + value: RoomAccess, +): Promise<{ token: string; ttlSeconds: number }> { + const token = randomToken(24); + const ttlSeconds = getNumberVar(env, "ROOM_ACCESS_TTL_SECONDS", 60 * 60 * 24); + const kv = getCache(env); + await kv.put(`room_access:${roomId}:${token}`, JSON.stringify(value), { expirationTtl: ttlSeconds }); + return { token, ttlSeconds }; +} + +export async function verifyRoomAccessToken(env: Env, roomId: string, token: string): Promise { + const kv = getCache(env); + const raw = await kv.get(`room_access:${roomId}:${token}`); + if (!raw) return null; + try { + return JSON.parse(raw) as RoomAccess; + } catch { + return null; + } +} diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts new file mode 100644 index 0000000..acab6aa --- /dev/null +++ b/src/lib/sessions.ts @@ -0,0 +1,39 @@ +import type { Env } from "../env"; +import { getCache, getNumberVar, isDevMode } from "../env"; +import { getCookie, makeCookie } from "./cookies"; +import { randomToken } from "./crypto"; + +const SESSION_COOKIE = "sid"; + +export type Session = { + sid: string; + userId: string; +}; + +export async function createSession(env: Env, userId: string): Promise<{ sid: string; setCookie: string }> { + const sid = randomToken(32); + const ttl = getNumberVar(env, "SESSION_TTL_SECONDS", 60 * 60 * 24 * 7); + const kv = getCache(env); + await kv.put(`session:${sid}`, userId, { expirationTtl: ttl }); + return { + sid, + setCookie: makeCookie(SESSION_COOKIE, sid, { httpOnly: true, maxAgeSeconds: ttl, secure: !isDevMode(env) }), + }; +} + +export async function getSession(env: Env, request: Request): Promise { + const sid = getCookie(request, SESSION_COOKIE); + if (!sid) return null; + const kv = getCache(env); + const userId = await kv.get(`session:${sid}`); + if (!userId) return null; + return { sid, userId }; +} + +export async function destroySession(env: Env, request: Request): Promise { + const sid = getCookie(request, SESSION_COOKIE); + if (!sid) return null; + const kv = getCache(env); + await kv.delete(`session:${sid}`); + return makeCookie(SESSION_COOKIE, "", { httpOnly: true, maxAgeSeconds: 0, secure: !isDevMode(env) }); +} diff --git a/src/lib/sleep.ts b/src/lib/sleep.ts new file mode 100644 index 0000000..d607f13 --- /dev/null +++ b/src/lib/sleep.ts @@ -0,0 +1,15 @@ +export async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + await new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(t); + resolve(); + }, + { once: true }, + ); + }); +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7980e8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "WebWorker"], + "module": "ES2022", + "moduleResolution": "Bundler", + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} + diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..d169fd9 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,30 @@ +name = "cf-chat" +main = "src/index.ts" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +SESSION_TTL_SECONDS = "604800" +ROOM_ACCESS_TTL_SECONDS = "86400" +DEV_MODE = "false" +# Resend 邮件配置 +RESEND_FROM_EMAIL = "noreply@zxd.im" # 修改为你的发件人邮箱 +# RESEND_API_KEY 应该使用 secret 设置,运行: npx wrangler secret put RESEND_API_KEY + +[[d1_databases]] +binding = "DB" +database_name = "douban" +database_id = "dea24df8-6551-473e-9b1e-b2f2e2211090" + +[[kv_namespaces]] +binding = "CACHE" +id = "56ef01a9d92e42688e91a75bc9a7c534" + +[[r2_buckets]] +binding = "BUCKET" +bucket_name = "paimian" + +[assets] +directory = "./public" +binding = "ASSETS" +not_found_handling = "single-page-application"