diff --git a/.gitignore b/.gitignore index a7ee1dc..55c9015 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,29 @@ debug-logs/ *.log .DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ + +# Frontend (Vite/React) +node_modules/ +dist/ +build/ +.vite/ +coverage/ + +# Backend (Go) +*.exe +*.dll +*.so +*.dylib +*.test +*.out + +# Env +.env +.env.* +.env.local +.env.*.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..507f647 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, styles in `src/styles.css`. +- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/`, domain models in `internal/models/`, storage in `internal/storage/`. +- `sproutgate-backend/data/`: JSON-backed config and data. Config files live in `data/config/`, user records in `data/users/`. +- Root scripts `sproutgate.sh` and `sproutgate.bat` provide dev and build shortcuts. + +## Build, Test, and Development Commands +- `./sproutgate.sh dev` or `sproutgate.bat dev`: start backend and frontend dev servers. +- `./sproutgate.sh build` or `sproutgate.bat build`: build the frontend bundle. +- `cd sproutgate-backend && go mod tidy`: sync Go dependencies. +- `cd sproutgate-backend && go run .`: run the API (default port `8080`). +- `cd sproutgate-frontend && npm install`: install frontend dependencies. +- `cd sproutgate-frontend && npm run dev`: run Vite dev server (default `5173`). +- `cd sproutgate-frontend && npm run build`: create a production build. +- `cd sproutgate-frontend && npm run preview`: preview the build locally. + +## Coding Style & Naming Conventions +- Go code should be `gofmt`-formatted; packages are lowercase, exported types use PascalCase, JSON tags use lowerCamelCase. +- Frontend code follows existing `src/*.jsx` patterns: 2-space indentation, double quotes, components in PascalCase, hooks prefixed with `use`. +- File naming mirrors current structure: Go files are lowercase (use underscores when needed, e.g., `secondary_email.go`); React components use `.jsx`. + +## Testing Guidelines +- No automated test suites are configured yet (no `*_test.go` and no `npm test` script). +- When adding backend tests, use Go's standard testing (`*_test.go`) and run `go test ./...` in `sproutgate-backend/`. +- If frontend tests are introduced, document the runner and command in `sproutgate-frontend/package.json` and update this guide. + +## Commit & Pull Request Guidelines +- Git history currently contains only `Initial commit`; there is no established commit convention yet. +- Use short, imperative commit summaries; add a scope when helpful (e.g., `frontend: refine admin table`). +- PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates. + +## Security & Configuration Tips +- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email). +- Frontend API base URL can be set via `sproutgate-frontend/.env` using `VITE_API_BASE`. +- Avoid committing real credentials or production tokens; use local overrides for secrets. +- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`. diff --git a/README.md b/README.md index c5569d2..ca14112 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ -# SproutGate +# 萌芽账户认证中心(SproutGate) -Project scaffold repo. +前后端分离的统一账户认证中心: +- 前端:React(`sproutgate-frontend`) +- 后端:Golang + Gin(`sproutgate-backend`) +- 数据:`data/` 与子目录 JSON 文件存储 + +## 快速启动 + +### 后端 +```bash +cd sproutgate-backend +go mod tidy +go run . +``` + +默认端口 `8080`,默认管理员 Token:`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。 +邮件发送配置位于 `sproutgate-backend/data/config/email.json`。 + +### 前端 +```bash +cd sproutgate-frontend +npm install +npm run dev +``` + +如需自定义后端地址,新增 `sproutgate-frontend/.env`: +``` +VITE_API_BASE=http://localhost:8080 +``` + +### 管理员地址 +``` +http://localhost:5173/admin?token=shumengya520 +``` + +## API 文档 + +- 文件:`sproutgate-backend/API_DOCS.md` +- 在线:`GET /api/docs` diff --git a/sproutgate-backend/.dockerignore b/sproutgate-backend/.dockerignore new file mode 100644 index 0000000..c792fb2 --- /dev/null +++ b/sproutgate-backend/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.idea +.vscode + +data +node_modules +dist + +*.log diff --git a/sproutgate-backend/API_DOCS.md b/sproutgate-backend/API_DOCS.md new file mode 100644 index 0000000..276269a --- /dev/null +++ b/sproutgate-backend/API_DOCS.md @@ -0,0 +1,296 @@ +# 萌芽账户认证中心 API 文档 + +基础地址:`http://:8080` + +## 认证与统一登录 + +### 登录获取统一令牌 +`POST /api/auth/login` + +请求: +```json +{ + "account": "demo", + "password": "demo123" +} +``` + +响应: +```json +{ + "token": "jwt-token", + "expiresAt": "2026-03-14T12:00:00Z", + "user": { + "account": "demo", + "username": "示例用户", + "email": "demo@example.com", + "level": 0, + "sproutCoins": 10, + "secondaryEmails": ["demo2@example.com"], + "phone": "13800000000", + "avatarUrl": "https://example.com/avatar.png", + "bio": "### 简介", + "createdAt": "2026-03-14T12:00:00Z", + "updatedAt": "2026-03-14T12:00:00Z" + } +} +``` + +### 校验令牌 +`POST /api/auth/verify` + +请求: +```json +{ + "token": "jwt-token" +} +``` + +响应: +```json +{ + "valid": true, + "user": { "account": "demo", "...": "..." } +} +``` + +### 获取当前用户信息 +`GET /api/auth/me` + +请求头: +`Authorization: Bearer ` + +响应: +```json +{ + "user": { "account": "demo", "...": "..." } +} +``` + +> 说明:密码不会返回。 + +### 更新当前用户资料 +`PUT /api/auth/profile` + +请求头: +`Authorization: Bearer ` + +请求(字段可选): +```json +{ + "password": "newpass", + "username": "新昵称", + "phone": "13800000000", + "avatarUrl": "https://example.com/avatar.png", + "bio": "### 新简介" +} +``` + +响应: +```json +{ + "user": { "account": "demo", "...": "..." } +} +``` + +### 注册账号(发送邮箱验证码) +`POST /api/auth/register` + +请求: +```json +{ + "account": "demo", + "password": "demo123", + "username": "示例用户", + "email": "demo@example.com" +} +``` + +响应: +```json +{ + "sent": true, + "expiresAt": "2026-03-14T12:10:00Z" +} +``` + +### 验证邮箱并完成注册 +`POST /api/auth/verify-email` + +请求: +```json +{ + "account": "demo", + "code": "123456" +} +``` + +响应: +```json +{ + "created": true, + "user": { "account": "demo", "...": "..." } +} +``` + +### 忘记密码(发送重置验证码) +`POST /api/auth/forgot-password` + +请求: +```json +{ + "account": "demo", + "email": "demo@example.com" +} +``` + +响应: +```json +{ + "sent": true, + "expiresAt": "2026-03-14T12:10:00Z" +} +``` + +### 重置密码 +`POST /api/auth/reset-password` + +请求: +```json +{ + "account": "demo", + "code": "123456", + "newPassword": "newpass" +} +``` + +响应: +```json +{ "reset": true } +``` + +### 申请添加辅助邮箱(发送验证码) +`POST /api/auth/secondary-email/request` + +请求头: +`Authorization: Bearer ` + +请求: +```json +{ + "email": "demo2@example.com" +} +``` + +响应: +```json +{ + "sent": true, + "expiresAt": "2026-03-14T12:10:00Z" +} +``` + +### 验证辅助邮箱 +`POST /api/auth/secondary-email/verify` + +请求头: +`Authorization: Bearer ` + +请求: +```json +{ + "email": "demo2@example.com", + "code": "123456" +} +``` + +响应: +```json +{ + "verified": true, + "user": { "account": "demo", "...": "..." } +} +``` + +## 管理端接口(需要管理员 Token) + +管理员 Token 存放在 `data/config/admin.json` 中,默认值为 `shumengya520`。 +请求时可使用以下任一方式携带: +- Query:`?token=shumengya520` +- Header:`X-Admin-Token: shumengya520` +- Header:`Authorization: Bearer shumengya520` + +### 获取用户列表 +`GET /api/admin/users` + +响应: +```json +{ + "total": 1, + "users": [{ "account": "demo", "...": "..." }] +} +``` + +### 新建用户 +`POST /api/admin/users` + +请求: +```json +{ + "account": "demo", + "password": "demo123", + "username": "示例用户", + "email": "demo@example.com", + "level": 0, + "sproutCoins": 10, + "secondaryEmails": ["demo2@example.com"], + "phone": "13800000000", + "avatarUrl": "https://example.com/avatar.png", + "bio": "### 简介" +} +``` + +### 更新用户 +`PUT /api/admin/users/{account}` + +请求(字段可选): +```json +{ + "password": "newpass", + "username": "新昵称", + "level": 1, + "secondaryEmails": ["demo2@example.com"], + "sproutCoins": 99 +} +``` + +### 删除用户 +`DELETE /api/admin/users/{account}` + +响应: +```json +{ "deleted": true } +``` + +## 数据存储说明 + +- 用户数据:`data/users/*.json` +- 注册待验证:`data/pending/*.json` +- 密码重置记录:`data/reset/*.json` +- 辅助邮箱验证:`data/secondary/*.json` +- 管理员 Token:`data/config/admin.json` +- JWT 配置:`data/config/auth.json` +- 邮件配置:`data/config/email.json` + +## 快速联调用示例 + +```bash +# 登录 +curl -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"account":"demo","password":"demo123"}' + +# 使用令牌获取用户信息 +curl http://localhost:8080/api/auth/me \ + -H 'Authorization: Bearer ' +``` diff --git a/sproutgate-backend/Dockerfile b/sproutgate-backend/Dockerfile new file mode 100644 index 0000000..4098e0b --- /dev/null +++ b/sproutgate-backend/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.20-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/sproutgate-backend . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app +ENV PORT=8080 +ENV DATA_DIR=/data + +COPY --from=builder /out/sproutgate-backend /usr/local/bin/sproutgate-backend +COPY API_DOCS.md /app/API_DOCS.md + +EXPOSE 8080 +VOLUME ["/data"] + +ENTRYPOINT ["sproutgate-backend"] diff --git a/sproutgate-backend/data/config/admin.json b/sproutgate-backend/data/config/admin.json new file mode 100644 index 0000000..277cd24 --- /dev/null +++ b/sproutgate-backend/data/config/admin.json @@ -0,0 +1,3 @@ +{ + "token": "shumengya520" +} diff --git a/sproutgate-backend/data/config/auth.json b/sproutgate-backend/data/config/auth.json new file mode 100644 index 0000000..9a8c075 --- /dev/null +++ b/sproutgate-backend/data/config/auth.json @@ -0,0 +1,4 @@ +{ + "jwtSecret": "c3Byb3V0Z2F0ZS1zZWNyZXQ=", + "issuer": "sproutgate" +} diff --git a/sproutgate-backend/data/config/email.json b/sproutgate-backend/data/config/email.json new file mode 100644 index 0000000..f20c1b8 --- /dev/null +++ b/sproutgate-backend/data/config/email.json @@ -0,0 +1,9 @@ +{ + "fromName": "萌芽账户认证中心", + "fromAddress": "notice@smyhub.com", + "username": "notice@smyhub.com", + "password": "tyh@19900420", + "smtpHost": "smtp.qiye.aliyun.com", + "smtpPort": 465, + "encryption": "SSL" +} \ No newline at end of file diff --git a/sproutgate-backend/data/users/YWRtaW4.json b/sproutgate-backend/data/users/YWRtaW4.json new file mode 100644 index 0000000..6484fbe --- /dev/null +++ b/sproutgate-backend/data/users/YWRtaW4.json @@ -0,0 +1,16 @@ +{ + "account": "admin", + "passwordHash": "$2a$10$T3XCFYOldB7b3RLuu.oxJeXTIdifjXIRyZdf/nHFIEwWAFRedysHi", + "username": "管理员", + "email": "admin@smyhub.com", + "level": 0, + "sproutCoins": 0, + "secondaryEmails": [ + "mail@smyhub.com" + ], + "phone": "74074091740", + "avatarUrl": "https://img.shumengya.top/i/2025/11/02/69073c02060d3.webp", + "bio": "我是管理员", + "createdAt": "2026-03-14T18:38:07+08:00", + "updatedAt": "2026-03-14T19:26:11+08:00" +} \ No newline at end of file diff --git a/sproutgate-backend/data/users/c2h1bWVuZ3lh.json b/sproutgate-backend/data/users/c2h1bWVuZ3lh.json new file mode 100644 index 0000000..070c181 --- /dev/null +++ b/sproutgate-backend/data/users/c2h1bWVuZ3lh.json @@ -0,0 +1,13 @@ +{ + "account": "shumengya", + "passwordHash": "$2a$10$f6JZ6S26BdfK8dxHQ/eeb.q9adTbkBmyprta8WlMCR3v5gMpERlgO", + "username": "树萌芽", + "email": "mail@smyhub.com", + "level": 0, + "sproutCoins": 100, + "secondaryEmails": [], + "avatarUrl": "https://img.shumengya.top/i/2025/11/02/69073c018174e.webp", + "bio": "(=^・ω・^=) 喵~", + "createdAt": "2026-03-14T18:12:20+08:00", + "updatedAt": "2026-03-14T18:12:20+08:00" +} diff --git a/sproutgate-backend/docker-compose.yml b/sproutgate-backend/docker-compose.yml new file mode 100644 index 0000000..d81a187 --- /dev/null +++ b/sproutgate-backend/docker-compose.yml @@ -0,0 +1,14 @@ +services: + sproutgate-auth: + build: + context: . + dockerfile: Dockerfile + container_name: sproutgate-auth + restart: unless-stopped + environment: + PORT: "8080" + DATA_DIR: /data + volumes: + - ./data:/data + ports: + - "${AUTH_API_PORT:-18080}:8080" diff --git a/sproutgate-backend/go.mod b/sproutgate-backend/go.mod new file mode 100644 index 0000000..b4b94b2 --- /dev/null +++ b/sproutgate-backend/go.mod @@ -0,0 +1,39 @@ +module sproutgate-backend + +go 1.20 + +require ( + github.com/gin-contrib/cors v1.5.0 + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.23.0 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/sproutgate-backend/go.sum b/sproutgate-backend/go.sum new file mode 100644 index 0000000..fd392ce --- /dev/null +++ b/sproutgate-backend/go.sum @@ -0,0 +1,95 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= +github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/sproutgate-backend/internal/auth/jwt.go b/sproutgate-backend/internal/auth/jwt.go new file mode 100644 index 0000000..bc568ce --- /dev/null +++ b/sproutgate-backend/internal/auth/jwt.go @@ -0,0 +1,49 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + Account string `json:"account"` + jwt.RegisteredClaims +} + +func GenerateToken(secret []byte, issuer string, account string, ttl time.Duration) (string, time.Time, error) { + if account == "" { + return "", time.Time{}, errors.New("account is required") + } + expiresAt := time.Now().Add(ttl) + claims := Claims{ + Account: account, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: issuer, + Subject: account, + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(secret) + return signed, expiresAt, err +} + +func ParseToken(secret []byte, issuer string, tokenString string) (*Claims, error) { + if tokenString == "" { + return nil, errors.New("token is required") + } + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + return secret, nil + }, jwt.WithIssuer(issuer)) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/sproutgate-backend/internal/email/mailer.go b/sproutgate-backend/internal/email/mailer.go new file mode 100644 index 0000000..26ea4c4 --- /dev/null +++ b/sproutgate-backend/internal/email/mailer.go @@ -0,0 +1,167 @@ +package email + +import ( + "bytes" + "crypto/tls" + "fmt" + "mime" + "net/smtp" + "strings" + "time" + + "sproutgate-backend/internal/storage" +) + +func SendVerificationEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error { + if strings.TrimSpace(to) == "" { + return fmt.Errorf("email is required") + } + fromName := strings.TrimSpace(cfg.FromName) + if fromName == "" { + fromName = "萌芽账户认证中心" + } + fromAddress := strings.TrimSpace(cfg.FromAddress) + if fromAddress == "" { + return fmt.Errorf("from address is required") + } + username := strings.TrimSpace(cfg.Username) + if username == "" { + username = fromAddress + } + subject := "萌芽账户认证中心 - 邮箱验证" + encodedName := mime.QEncoding.Encode("UTF-8", fromName) + fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress) + + body := fmt.Sprintf("您的验证码是:%s\n有效期:%d 分钟\n\n如非本人操作,请忽略此邮件。", + code, int(expiresIn.Minutes())) + + var msg bytes.Buffer + msg.WriteString("From: " + fromHeader + "\r\n") + msg.WriteString("To: " + to + "\r\n") + msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n") + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + msg.WriteString("Content-Transfer-Encoding: 8bit\r\n") + msg.WriteString("\r\n") + msg.WriteString(body) + + addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort) + auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost) + encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption)) + + if cfg.SMTPPort == 465 || encryption == "SSL" { + tlsConfig := &tls.Config{ + ServerName: cfg.SMTPHost, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: false, + } + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, cfg.SMTPHost) + if err != nil { + return err + } + defer client.Close() + if err := client.Auth(auth); err != nil { + return err + } + if err := client.Mail(fromAddress); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write(msg.Bytes()); err != nil { + return err + } + if err := writer.Close(); err != nil { + return err + } + return client.Quit() + } + + return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes()) +} + +func SendResetPasswordEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error { + if strings.TrimSpace(to) == "" { + return fmt.Errorf("email is required") + } + fromName := strings.TrimSpace(cfg.FromName) + if fromName == "" { + fromName = "萌芽账户认证中心" + } + fromAddress := strings.TrimSpace(cfg.FromAddress) + if fromAddress == "" { + return fmt.Errorf("from address is required") + } + username := strings.TrimSpace(cfg.Username) + if username == "" { + username = fromAddress + } + subject := "萌芽账户认证中心 - 重置密码" + encodedName := mime.QEncoding.Encode("UTF-8", fromName) + fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress) + + body := fmt.Sprintf("您的重置密码验证码是:%s\n有效期:%d 分钟\n\n如非本人操作,请忽略此邮件。", + code, int(expiresIn.Minutes())) + + var msg bytes.Buffer + msg.WriteString("From: " + fromHeader + "\r\n") + msg.WriteString("To: " + to + "\r\n") + msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n") + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + msg.WriteString("Content-Transfer-Encoding: 8bit\r\n") + msg.WriteString("\r\n") + msg.WriteString(body) + + addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort) + auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost) + encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption)) + + if cfg.SMTPPort == 465 || encryption == "SSL" { + tlsConfig := &tls.Config{ + ServerName: cfg.SMTPHost, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: false, + } + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, cfg.SMTPHost) + if err != nil { + return err + } + defer client.Close() + if err := client.Auth(auth); err != nil { + return err + } + if err := client.Mail(fromAddress); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write(msg.Bytes()); err != nil { + return err + } + if err := writer.Close(); err != nil { + return err + } + return client.Quit() + } + + return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes()) +} diff --git a/sproutgate-backend/internal/handlers/handlers.go b/sproutgate-backend/internal/handlers/handlers.go new file mode 100644 index 0000000..01a536c --- /dev/null +++ b/sproutgate-backend/internal/handlers/handlers.go @@ -0,0 +1,751 @@ +package handlers + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "sproutgate-backend/internal/auth" + "sproutgate-backend/internal/email" + "sproutgate-backend/internal/models" + "sproutgate-backend/internal/storage" +) + +type Handler struct { + store *storage.Store +} + +func NewHandler(store *storage.Store) *Handler { + return &Handler{store: store} +} + +type loginRequest struct { + Account string `json:"account"` + Password string `json:"password"` +} + +type verifyRequest struct { + Token string `json:"token"` +} + +type registerRequest struct { + Account string `json:"account"` + Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` +} + +type verifyEmailRequest struct { + Account string `json:"account"` + Code string `json:"code"` +} + +type updateProfileRequest struct { + Password *string `json:"password"` + Username *string `json:"username"` + Phone *string `json:"phone"` + AvatarURL *string `json:"avatarUrl"` + Bio *string `json:"bio"` +} + +type forgotPasswordRequest struct { + Account string `json:"account"` + Email string `json:"email"` +} + +type resetPasswordRequest struct { + Account string `json:"account"` + Code string `json:"code"` + NewPassword string `json:"newPassword"` +} + +type secondaryEmailRequest struct { + Email string `json:"email"` +} + +type verifySecondaryEmailRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +type createUserRequest struct { + Account string `json:"account"` + Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + SecondaryEmails []string `json:"secondaryEmails"` + Phone string `json:"phone"` + AvatarURL string `json:"avatarUrl"` + Bio string `json:"bio"` +} + +type updateUserRequest struct { + Password *string `json:"password"` + Username *string `json:"username"` + Email *string `json:"email"` + Level *int `json:"level"` + SproutCoins *int `json:"sproutCoins"` + SecondaryEmails *[]string `json:"secondaryEmails"` + Phone *string `json:"phone"` + AvatarURL *string `json:"avatarUrl"` + Bio *string `json:"bio"` +} + +func (h *Handler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + if req.Account == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) + return + } + user, found, err := h.store.GetUser(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "token": token, + "expiresAt": expiresAt.Format(time.RFC3339), + "user": user.Public(), + }) +} + +func (h *Handler) Verify(c *gin.Context) { + var req verifyRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"}) + return + } + user, found, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()}) +} + +func (h *Handler) Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Email = strings.TrimSpace(req.Email) + if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"}) + return + } + if _, found, err := h.store.GetUser(req.Account); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } else if found { + c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"}) + return + } + + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + pending := models.PendingUser{ + Account: req.Account, + PasswordHash: string(hash), + Username: req.Username, + Email: req.Email, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if err := h.store.SavePending(pending); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"}) + return + } + if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil { + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) VerifyEmail(c *gin.Context) { + var req verifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Code = strings.TrimSpace(req.Code) + if req.Account == "" || req.Code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"}) + return + } + pending, found, err := h.store.GetPending(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) + return + } + if !verifyCode(req.Code, pending.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) + return + } + record := models.UserRecord{ + Account: pending.Account, + PasswordHash: pending.PasswordHash, + Username: pending.Username, + Email: pending.Email, + Level: 0, + SproutCoins: 0, + SecondaryEmails: []string{}, + CreatedAt: models.NowISO(), + UpdatedAt: models.NowISO(), + } + if err := h.store.CreateUser(record); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()}) +} + +func (h *Handler) ForgotPassword(c *gin.Context) { + var req forgotPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Email = strings.TrimSpace(req.Email) + if req.Account == "" || req.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"}) + return + } + user, found, err := h.store.GetUser(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email { + c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"}) + return + } + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + resetRecord := models.ResetPassword{ + Account: user.Account, + Email: user.Email, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if err := h.store.SaveReset(resetRecord); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"}) + return + } + if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil { + _ = h.store.DeleteReset(user.Account) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) ResetPassword(c *gin.Context) { + var req resetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Code = strings.TrimSpace(req.Code) + if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"}) + return + } + resetRecord, found, err := h.store.GetReset(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeleteReset(req.Account) + c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"}) + return + } + if !verifyCode(req.Code, resetRecord.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"}) + return + } + user, found, err := h.store.GetUser(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + _ = h.store.DeleteReset(req.Account) + c.JSON(http.StatusOK, gin.H{"reset": true}) +} + +func (h *Handler) RequestSecondaryEmail(c *gin.Context) { + token := bearerToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + var req secondaryEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + emailAddr := strings.TrimSpace(req.Email) + if emailAddr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) + return + } + user, found, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + if strings.TrimSpace(user.Email) == emailAddr { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"}) + return + } + for _, e := range user.SecondaryEmails { + if e == emailAddr { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"}) + return + } + } + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + record := models.SecondaryEmailVerification{ + Account: user.Account, + Email: emailAddr, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if err := h.store.SaveSecondaryVerification(record); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"}) + return + } + if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil { + _ = h.store.DeleteSecondaryVerification(user.Account, emailAddr) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) VerifySecondaryEmail(c *gin.Context) { + token := bearerToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + var req verifySecondaryEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + emailAddr := strings.TrimSpace(req.Email) + code := strings.TrimSpace(req.Code) + if emailAddr == "" || code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"}) + return + } + record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) + return + } + if !verifyCode(code, record.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) + return + } + user, found, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + for _, e := range user.SecondaryEmails { + if e == emailAddr { + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) + return + } + } + user.SecondaryEmails = append(user.SecondaryEmails, emailAddr) + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) +} + +func (h *Handler) Me(c *gin.Context) { + token := bearerToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + user, found, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user.Public()}) +} + +func (h *Handler) UpdateProfile(c *gin.Context) { + token := bearerToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + user, found, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + if req.Password != nil && strings.TrimSpace(*req.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + } + if req.Username != nil { + user.Username = *req.Username + } + if req.Phone != nil { + user.Phone = *req.Phone + } + if req.AvatarURL != nil { + user.AvatarURL = *req.AvatarURL + } + if req.Bio != nil { + user.Bio = *req.Bio + } + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user.Public()}) +} + +func (h *Handler) ListUsers(c *gin.Context) { + users, err := h.store.ListUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) + return + } + publicUsers := make([]models.UserPublic, 0, len(users)) + for _, u := range users { + publicUsers = append(publicUsers, u.Public()) + } + c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers}) +} + +func (h *Handler) CreateUser(c *gin.Context) { + var req createUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + if req.Account == "" || strings.TrimSpace(req.Password) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + record := models.UserRecord{ + Account: req.Account, + PasswordHash: string(hash), + Username: req.Username, + Email: req.Email, + Level: req.Level, + SproutCoins: req.SproutCoins, + SecondaryEmails: req.SecondaryEmails, + Phone: req.Phone, + AvatarURL: req.AvatarURL, + Bio: req.Bio, + CreatedAt: models.NowISO(), + UpdatedAt: models.NowISO(), + } + if err := h.store.CreateUser(record); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"user": record.Public()}) +} + +func (h *Handler) UpdateUser(c *gin.Context) { + account := strings.TrimSpace(c.Param("account")) + if account == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) + return + } + var req updateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + user, found, err := h.store.GetUser(account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if req.Password != nil && strings.TrimSpace(*req.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + } + if req.Username != nil { + user.Username = *req.Username + } + if req.Email != nil { + user.Email = *req.Email + } + if req.Level != nil { + user.Level = *req.Level + } + if req.SproutCoins != nil { + user.SproutCoins = *req.SproutCoins + } + if req.SecondaryEmails != nil { + user.SecondaryEmails = *req.SecondaryEmails + } + if req.Phone != nil { + user.Phone = *req.Phone + } + if req.AvatarURL != nil { + user.AvatarURL = *req.AvatarURL + } + if req.Bio != nil { + user.Bio = *req.Bio + } + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user.Public()}) +} + +func (h *Handler) DeleteUser(c *gin.Context) { + account := strings.TrimSpace(c.Param("account")) + if account == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) + return + } + if err := h.store.DeleteUser(account); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (h *Handler) AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := adminTokenFromRequest(c) + if token == "" || token != h.store.AdminToken() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"}) + c.Abort() + return + } + c.Next() + } +} + +func adminTokenFromRequest(c *gin.Context) string { + if token := strings.TrimSpace(c.Query("token")); token != "" { + return token + } + if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" { + return token + } + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + return bearerToken(authHeader) +} + +func bearerToken(header string) string { + if header == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(header), "bearer ") { + return strings.TrimSpace(header[7:]) + } + return "" +} + +func generateVerificationCode() (string, error) { + randomBytes := make([]byte, 3) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2]) + return fmt.Sprintf("%06d", number%1000000), nil +} + +func hashCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +func verifyCode(code string, hash string) bool { + return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1 +} diff --git a/sproutgate-backend/internal/models/pending.go b/sproutgate-backend/internal/models/pending.go new file mode 100644 index 0000000..eafe25e --- /dev/null +++ b/sproutgate-backend/internal/models/pending.go @@ -0,0 +1,11 @@ +package models + +type PendingUser struct { + Account string `json:"account"` + PasswordHash string `json:"passwordHash"` + Username string `json:"username"` + Email string `json:"email"` + CodeHash string `json:"codeHash"` + ExpiresAt string `json:"expiresAt"` + CreatedAt string `json:"createdAt"` +} diff --git a/sproutgate-backend/internal/models/reset.go b/sproutgate-backend/internal/models/reset.go new file mode 100644 index 0000000..773f131 --- /dev/null +++ b/sproutgate-backend/internal/models/reset.go @@ -0,0 +1,9 @@ +package models + +type ResetPassword struct { + Account string `json:"account"` + Email string `json:"email"` + CodeHash string `json:"codeHash"` + ExpiresAt string `json:"expiresAt"` + CreatedAt string `json:"createdAt"` +} diff --git a/sproutgate-backend/internal/models/secondary_email.go b/sproutgate-backend/internal/models/secondary_email.go new file mode 100644 index 0000000..1d0d60a --- /dev/null +++ b/sproutgate-backend/internal/models/secondary_email.go @@ -0,0 +1,9 @@ +package models + +type SecondaryEmailVerification struct { + Account string `json:"account"` + Email string `json:"email"` + CodeHash string `json:"codeHash"` + ExpiresAt string `json:"expiresAt"` + CreatedAt string `json:"createdAt"` +} diff --git a/sproutgate-backend/internal/models/user.go b/sproutgate-backend/internal/models/user.go new file mode 100644 index 0000000..feb1c07 --- /dev/null +++ b/sproutgate-backend/internal/models/user.go @@ -0,0 +1,52 @@ +package models + +import "time" + +type UserRecord struct { + Account string `json:"account"` + PasswordHash string `json:"passwordHash"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + SecondaryEmails []string `json:"secondaryEmails,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Bio string `json:"bio,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type UserPublic struct { + Account string `json:"account"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + SecondaryEmails []string `json:"secondaryEmails,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Bio string `json:"bio,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (u UserRecord) Public() UserPublic { + return UserPublic{ + Account: u.Account, + Username: u.Username, + Email: u.Email, + Level: u.Level, + SproutCoins: u.SproutCoins, + SecondaryEmails: u.SecondaryEmails, + Phone: u.Phone, + AvatarURL: u.AvatarURL, + Bio: u.Bio, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func NowISO() string { + return time.Now().Format(time.RFC3339) +} diff --git a/sproutgate-backend/internal/storage/pending.go b/sproutgate-backend/internal/storage/pending.go new file mode 100644 index 0000000..b2712ab --- /dev/null +++ b/sproutgate-backend/internal/storage/pending.go @@ -0,0 +1,55 @@ +package storage + +import ( + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + + "sproutgate-backend/internal/models" +) + +func (s *Store) SavePending(record models.PendingUser) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.pendingFilePath(record.Account) + return writeJSONFile(path, record) +} + +func (s *Store) GetPending(account string) (models.PendingUser, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + path := s.pendingFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return models.PendingUser{}, false, nil + } + var record models.PendingUser + if err := readJSONFile(path, &record); err != nil { + return models.PendingUser{}, false, err + } + return record, true, nil +} + +func (s *Store) DeletePending(account string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.pendingFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } + return os.Remove(path) +} + +func (s *Store) pendingFilePath(account string) string { + return filepath.Join(s.pendingDir, pendingFileName(account)) +} + +func pendingFileName(account string) string { + safe := strings.TrimSpace(account) + if safe == "" { + safe = "unknown" + } + encoded := base64.RawURLEncoding.EncodeToString([]byte(safe)) + return encoded + ".json" +} diff --git a/sproutgate-backend/internal/storage/reset.go b/sproutgate-backend/internal/storage/reset.go new file mode 100644 index 0000000..547222a --- /dev/null +++ b/sproutgate-backend/internal/storage/reset.go @@ -0,0 +1,55 @@ +package storage + +import ( + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + + "sproutgate-backend/internal/models" +) + +func (s *Store) SaveReset(record models.ResetPassword) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.resetFilePath(record.Account) + return writeJSONFile(path, record) +} + +func (s *Store) GetReset(account string) (models.ResetPassword, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + path := s.resetFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return models.ResetPassword{}, false, nil + } + var record models.ResetPassword + if err := readJSONFile(path, &record); err != nil { + return models.ResetPassword{}, false, err + } + return record, true, nil +} + +func (s *Store) DeleteReset(account string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.resetFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } + return os.Remove(path) +} + +func (s *Store) resetFilePath(account string) string { + return filepath.Join(s.resetDir, resetFileName(account)) +} + +func resetFileName(account string) string { + safe := strings.TrimSpace(account) + if safe == "" { + safe = "unknown" + } + encoded := base64.RawURLEncoding.EncodeToString([]byte(safe)) + return encoded + ".json" +} diff --git a/sproutgate-backend/internal/storage/secondary.go b/sproutgate-backend/internal/storage/secondary.go new file mode 100644 index 0000000..911a637 --- /dev/null +++ b/sproutgate-backend/internal/storage/secondary.go @@ -0,0 +1,60 @@ +package storage + +import ( + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + + "sproutgate-backend/internal/models" +) + +func (s *Store) SaveSecondaryVerification(record models.SecondaryEmailVerification) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.secondaryFilePath(record.Account, record.Email) + return writeJSONFile(path, record) +} + +func (s *Store) GetSecondaryVerification(account string, email string) (models.SecondaryEmailVerification, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + path := s.secondaryFilePath(account, email) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return models.SecondaryEmailVerification{}, false, nil + } + var record models.SecondaryEmailVerification + if err := readJSONFile(path, &record); err != nil { + return models.SecondaryEmailVerification{}, false, err + } + return record, true, nil +} + +func (s *Store) DeleteSecondaryVerification(account string, email string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.secondaryFilePath(account, email) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } + return os.Remove(path) +} + +func (s *Store) secondaryFilePath(account string, email string) string { + return filepath.Join(s.secondaryDir, secondaryFileName(account, email)) +} + +func secondaryFileName(account string, email string) string { + accountSafe := strings.TrimSpace(account) + emailSafe := strings.TrimSpace(email) + if accountSafe == "" { + accountSafe = "unknown" + } + if emailSafe == "" { + emailSafe = "unknown" + } + raw := accountSafe + "::" + emailSafe + encoded := base64.RawURLEncoding.EncodeToString([]byte(raw)) + return encoded + ".json" +} diff --git a/sproutgate-backend/internal/storage/storage.go b/sproutgate-backend/internal/storage/storage.go new file mode 100644 index 0000000..2779903 --- /dev/null +++ b/sproutgate-backend/internal/storage/storage.go @@ -0,0 +1,340 @@ +package storage + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + + "sproutgate-backend/internal/models" +) + +type AdminConfig struct { + Token string `json:"token"` +} + +type AuthConfig struct { + JWTSecret string `json:"jwtSecret"` + Issuer string `json:"issuer"` +} + +type EmailConfig struct { + FromName string `json:"fromName"` + FromAddress string `json:"fromAddress"` + Username string `json:"username"` + Password string `json:"password"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + Encryption string `json:"encryption"` +} + +type Store struct { + dataDir string + usersDir string + pendingDir string + resetDir string + secondaryDir string + adminConfigPath string + authConfigPath string + emailConfigPath string + adminToken string + jwtSecret []byte + issuer string + emailConfig EmailConfig + mu sync.Mutex +} + +func NewStore(dataDir string) (*Store, error) { + if dataDir == "" { + dataDir = "./data" + } + absDir, err := filepath.Abs(dataDir) + if err != nil { + return nil, err + } + usersDir := filepath.Join(absDir, "users") + pendingDir := filepath.Join(absDir, "pending") + resetDir := filepath.Join(absDir, "reset") + secondaryDir := filepath.Join(absDir, "secondary") + configDir := filepath.Join(absDir, "config") + if err := os.MkdirAll(usersDir, 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(pendingDir, 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(resetDir, 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(secondaryDir, 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + store := &Store{ + dataDir: absDir, + usersDir: usersDir, + pendingDir: pendingDir, + resetDir: resetDir, + secondaryDir: secondaryDir, + adminConfigPath: filepath.Join(configDir, "admin.json"), + authConfigPath: filepath.Join(configDir, "auth.json"), + emailConfigPath: filepath.Join(configDir, "email.json"), + } + if err := store.loadOrCreateAdminConfig(); err != nil { + return nil, err + } + if err := store.loadOrCreateAuthConfig(); err != nil { + return nil, err + } + if err := store.loadOrCreateEmailConfig(); err != nil { + return nil, err + } + return store, nil +} + +func (s *Store) DataDir() string { + return s.dataDir +} + +func (s *Store) AdminToken() string { + return s.adminToken +} + +func (s *Store) JWTSecret() []byte { + return s.jwtSecret +} + +func (s *Store) JWTIssuer() string { + return s.issuer +} + +func (s *Store) EmailConfig() EmailConfig { + return s.emailConfig +} + +func (s *Store) loadOrCreateAdminConfig() error { + defaultToken := "shumengya520" + if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) { + cfg := AdminConfig{Token: defaultToken} + if err := writeJSONFile(s.adminConfigPath, cfg); err != nil { + return err + } + s.adminToken = cfg.Token + return nil + } + var cfg AdminConfig + if err := readJSONFile(s.adminConfigPath, &cfg); err != nil { + return err + } + if strings.TrimSpace(cfg.Token) == "" { + cfg.Token = defaultToken + if err := writeJSONFile(s.adminConfigPath, cfg); err != nil { + return err + } + } + s.adminToken = cfg.Token + return nil +} + +func (s *Store) loadOrCreateAuthConfig() error { + if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) { + secret, err := generateSecret() + if err != nil { + return err + } + cfg := AuthConfig{ + JWTSecret: base64.StdEncoding.EncodeToString(secret), + Issuer: "sproutgate", + } + if err := writeJSONFile(s.authConfigPath, cfg); err != nil { + return err + } + s.jwtSecret = secret + s.issuer = cfg.Issuer + return nil + } + var cfg AuthConfig + if err := readJSONFile(s.authConfigPath, &cfg); err != nil { + return err + } + secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret) + if err != nil || len(secretBytes) == 0 { + secretBytes, err = generateSecret() + if err != nil { + return err + } + cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes) + if strings.TrimSpace(cfg.Issuer) == "" { + cfg.Issuer = "sproutgate" + } + if err := writeJSONFile(s.authConfigPath, cfg); err != nil { + return err + } + } + if strings.TrimSpace(cfg.Issuer) == "" { + cfg.Issuer = "sproutgate" + if err := writeJSONFile(s.authConfigPath, cfg); err != nil { + return err + } + } + s.jwtSecret = secretBytes + s.issuer = cfg.Issuer + return nil +} + +func (s *Store) loadOrCreateEmailConfig() error { + if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) { + cfg := EmailConfig{ + FromName: "萌芽账户认证中心", + FromAddress: "notice@smyhub.com", + Username: "", + Password: "tyh@19900420", + SMTPHost: "smtp.qiye.aliyun.com", + SMTPPort: 465, + Encryption: "SSL", + } + if err := writeJSONFile(s.emailConfigPath, cfg); err != nil { + return err + } + if cfg.Username == "" { + cfg.Username = cfg.FromAddress + } + s.emailConfig = cfg + return nil + } + var cfg EmailConfig + if err := readJSONFile(s.emailConfigPath, &cfg); err != nil { + return err + } + if strings.TrimSpace(cfg.FromName) == "" { + cfg.FromName = "萌芽账户认证中心" + } + if strings.TrimSpace(cfg.FromAddress) == "" { + cfg.FromAddress = "notice@smyhub.com" + } + if strings.TrimSpace(cfg.Username) == "" { + cfg.Username = cfg.FromAddress + } + if strings.TrimSpace(cfg.SMTPHost) == "" { + cfg.SMTPHost = "smtp.qiye.aliyun.com" + } + if cfg.SMTPPort == 0 { + cfg.SMTPPort = 465 + } + if strings.TrimSpace(cfg.Encryption) == "" { + cfg.Encryption = "SSL" + } + if err := writeJSONFile(s.emailConfigPath, cfg); err != nil { + return err + } + s.emailConfig = cfg + return nil +} + +func generateSecret() ([]byte, error) { + secret := make([]byte, 32) + _, err := rand.Read(secret) + return secret, err +} + +func (s *Store) ListUsers() ([]models.UserRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := os.ReadDir(s.usersDir) + if err != nil { + return nil, err + } + users := make([]models.UserRecord, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + var record models.UserRecord + path := filepath.Join(s.usersDir, entry.Name()) + if err := readJSONFile(path, &record); err != nil { + return nil, err + } + users = append(users, record) + } + return users, nil +} + +func (s *Store) GetUser(account string) (models.UserRecord, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + path := s.userFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return models.UserRecord{}, false, nil + } + var record models.UserRecord + if err := readJSONFile(path, &record); err != nil { + return models.UserRecord{}, false, err + } + return record, true, nil +} + +func (s *Store) CreateUser(record models.UserRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.userFilePath(record.Account) + if _, err := os.Stat(path); err == nil { + return errors.New("account already exists") + } + if record.CreatedAt == "" { + record.CreatedAt = models.NowISO() + } + record.UpdatedAt = record.CreatedAt + return writeJSONFile(path, record) +} + +func (s *Store) SaveUser(record models.UserRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.userFilePath(record.Account) + record.UpdatedAt = models.NowISO() + return writeJSONFile(path, record) +} + +func (s *Store) DeleteUser(account string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.userFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } + return os.Remove(path) +} + +func (s *Store) userFilePath(account string) string { + return filepath.Join(s.usersDir, userFileName(account)) +} + +func userFileName(account string) string { + encoded := base64.RawURLEncoding.EncodeToString([]byte(account)) + return encoded + ".json" +} + +func readJSONFile(path string, target any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, target) +} + +func writeJSONFile(path string, value any) error { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, raw, 0644) +} diff --git a/sproutgate-backend/main.go b/sproutgate-backend/main.go new file mode 100644 index 0000000..3374cac --- /dev/null +++ b/sproutgate-backend/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/handlers" + "sproutgate-backend/internal/storage" +) + +func main() { + dataDir := os.Getenv("DATA_DIR") + store, err := storage.NewStore(dataDir) + if err != nil { + log.Fatalf("failed to init storage: %v", err) + } + + router := gin.Default() + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token"}, + MaxAge: 12 * time.Hour, + })) + + handler := handlers.NewHandler(store) + + router.GET("/api/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "dataDir": store.DataDir(), + }) + }) + router.GET("/api/docs", func(c *gin.Context) { + c.File("API_DOCS.md") + }) + + router.POST("/api/auth/login", handler.Login) + router.POST("/api/auth/register", handler.Register) + router.POST("/api/auth/verify-email", handler.VerifyEmail) + router.POST("/api/auth/forgot-password", handler.ForgotPassword) + router.POST("/api/auth/reset-password", handler.ResetPassword) + router.POST("/api/auth/secondary-email/request", handler.RequestSecondaryEmail) + router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail) + router.POST("/api/auth/verify", handler.Verify) + router.GET("/api/auth/me", handler.Me) + router.PUT("/api/auth/profile", handler.UpdateProfile) + + admin := router.Group("/api/admin") + admin.Use(handler.AdminMiddleware()) + admin.GET("/users", handler.ListUsers) + admin.POST("/users", handler.CreateUser) + admin.PUT("/users/:account", handler.UpdateUser) + admin.DELETE("/users/:account", handler.DeleteUser) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + if err := router.Run(":" + port); err != nil { + log.Fatalf("server stopped: %v", err) + } +} diff --git a/sproutgate-frontend/favicon.ico b/sproutgate-frontend/favicon.ico new file mode 100644 index 0000000..e08df18 Binary files /dev/null and b/sproutgate-frontend/favicon.ico differ diff --git a/sproutgate-frontend/index.html b/sproutgate-frontend/index.html new file mode 100644 index 0000000..b612645 --- /dev/null +++ b/sproutgate-frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + 萌芽账户认证中心 + + +
+ + + diff --git a/sproutgate-frontend/logo.png b/sproutgate-frontend/logo.png new file mode 100644 index 0000000..11d4272 Binary files /dev/null and b/sproutgate-frontend/logo.png differ diff --git a/sproutgate-frontend/package-lock.json b/sproutgate-frontend/package-lock.json new file mode 100644 index 0000000..20f2210 --- /dev/null +++ b/sproutgate-frontend/package-lock.json @@ -0,0 +1,1690 @@ +{ + "name": "sproutgate-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sproutgate-frontend", + "version": "0.1.0", + "dependencies": { + "marked": "^12.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/sproutgate-frontend/package.json b/sproutgate-frontend/package.json new file mode 100644 index 0000000..3baa3ae --- /dev/null +++ b/sproutgate-frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "sproutgate-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "vite build", + "preview": "vite preview --host" + }, + "dependencies": { + "marked": "^12.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.8" + } +} diff --git a/sproutgate-frontend/public/apple-touch-icon.png b/sproutgate-frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..f575558 Binary files /dev/null and b/sproutgate-frontend/public/apple-touch-icon.png differ diff --git a/sproutgate-frontend/public/favicon.ico b/sproutgate-frontend/public/favicon.ico new file mode 100644 index 0000000..e08df18 Binary files /dev/null and b/sproutgate-frontend/public/favicon.ico differ diff --git a/sproutgate-frontend/public/icon-192.png b/sproutgate-frontend/public/icon-192.png new file mode 100644 index 0000000..ce2968b Binary files /dev/null and b/sproutgate-frontend/public/icon-192.png differ diff --git a/sproutgate-frontend/public/icon-512.png b/sproutgate-frontend/public/icon-512.png new file mode 100644 index 0000000..a9388c7 Binary files /dev/null and b/sproutgate-frontend/public/icon-512.png differ diff --git a/sproutgate-frontend/public/logo.png b/sproutgate-frontend/public/logo.png new file mode 100644 index 0000000..11d4272 Binary files /dev/null and b/sproutgate-frontend/public/logo.png differ diff --git a/sproutgate-frontend/public/logo192.png b/sproutgate-frontend/public/logo192.png new file mode 100644 index 0000000..ce2968b Binary files /dev/null and b/sproutgate-frontend/public/logo192.png differ diff --git a/sproutgate-frontend/public/manifest.webmanifest b/sproutgate-frontend/public/manifest.webmanifest new file mode 100644 index 0000000..2540f49 --- /dev/null +++ b/sproutgate-frontend/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "萌芽账户认证中心", + "short_name": "SproutGate", + "description": "统一认证与账户管理中心", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f6fb", + "theme_color": "#3b82f6", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/sproutgate-frontend/public/sw.js b/sproutgate-frontend/public/sw.js new file mode 100644 index 0000000..73779e2 --- /dev/null +++ b/sproutgate-frontend/public/sw.js @@ -0,0 +1,64 @@ +const CACHE_NAME = "sproutgate-v1"; +const PRECACHE_URLS = [ + "/", + "/index.html", + "/manifest.webmanifest", + "/favicon.ico", + "/logo.png", + "/logo192.png", + "/icon-192.png", + "/icon-512.png", + "/apple-touch-icon.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => caches.match(request).then((cached) => cached || caches.match("/index.html"))) + ); + return; + } + + event.respondWith( + caches.match(request).then((cached) => + cached || + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => cached) + ) + ); +}); diff --git a/sproutgate-frontend/src/App.jsx b/sproutgate-frontend/src/App.jsx new file mode 100644 index 0000000..0846528 --- /dev/null +++ b/sproutgate-frontend/src/App.jsx @@ -0,0 +1,1233 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { marked } from "marked"; + +const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8080"; +marked.setOptions({ breaks: true }); + +const icons = { + account: ( + + ), + password: ( + + ), + username: ( + + ), + email: ( + + ), + coins: ( + + ), + phone: ( + + ), + avatar: ( + + ), + bio: ( + + ), + token: ( + + ), + level: ( + + ), + secondaryEmail: ( + + ) +}; + +const emptyForm = { + account: "", + password: "", + username: "", + email: "", + level: 0, + sproutCoins: 0, + secondaryEmails: "", + phone: "", + avatarUrl: "", + bio: "" +}; + +function App() { + const isAdmin = window.location.pathname.startsWith("/admin"); + const [booting, setBooting] = useState(true); + + const markReady = () => { + setBooting(false); + }; + + useEffect(() => { + const timer = setTimeout(() => setBooting(false), 4000); + return () => clearTimeout(timer); + }, []); + + return ( +
+ {booting && } +
+
+
+

萌芽账户认证中心

+

统一认证 · 账户管理 · 简洁可用

+
+ +
+ {isAdmin ? : } +
+
+ ); +} + +function SplashScreen() { + return ( +
+ + ); +} + +function IconLabel({ icon, text, hint }) { + return ( + + {icon} + {text} + {hint && {hint}} + + ); +} + +function InfoLabel({ icon, text }) { + return ( + + {icon} + {text} + + ); +} + +function TableCell({ icon, children, onClick }) { + return ( + + {icon} + {children} + + ); +} + +const parseEmailList = (value) => + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + +function UserPortal({ onReady }) { + const [account, setAccount] = useState(""); + const [password, setPassword] = useState(""); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [mode, setMode] = useState("login"); + const [registerForm, setRegisterForm] = useState({ + account: "", + password: "", + username: "", + email: "", + code: "" + }); + const [registerSent, setRegisterSent] = useState(false); + const [registerExpiresAt, setRegisterExpiresAt] = useState(""); + const [registerLoading, setRegisterLoading] = useState(false); + const [registerError, setRegisterError] = useState(""); + const [registerMessage, setRegisterMessage] = useState(""); + const [resetForm, setResetForm] = useState({ + account: "", + email: "", + code: "", + newPassword: "" + }); + const [resetSent, setResetSent] = useState(false); + const [resetExpiresAt, setResetExpiresAt] = useState(""); + const [resetLoading, setResetLoading] = useState(false); + const [resetError, setResetError] = useState(""); + const [resetMessage, setResetMessage] = useState(""); + const [secondaryForm, setSecondaryForm] = useState({ + email: "", + code: "" + }); + const [secondarySent, setSecondarySent] = useState(false); + const [secondaryExpiresAt, setSecondaryExpiresAt] = useState(""); + const [secondaryLoading, setSecondaryLoading] = useState(false); + const [secondaryError, setSecondaryError] = useState(""); + const [secondaryMessage, setSecondaryMessage] = useState(""); + const [profileForm, setProfileForm] = useState({ + username: "", + phone: "", + avatarUrl: "", + bio: "", + password: "" + }); + const [profileLoading, setProfileLoading] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileMessage, setProfileMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + const done = () => { + if (!cancelled && onReady) { + onReady(); + } + }; + const token = localStorage.getItem("sproutgate_token"); + if (token) { + fetch(`${API_BASE}/api/auth/me`, { + headers: { Authorization: `Bearer ${token}` } + }) + .then((res) => res.json()) + .then((data) => { + if (data.user) { + setUser(data.user); + } + }) + .catch(() => {}) + .finally(() => done()); + } else { + done(); + } + return () => { + cancelled = true; + }; + }, [onReady]); + + useEffect(() => { + if (user) { + setProfileForm({ + username: user.username || "", + phone: user.phone || "", + avatarUrl: user.avatarUrl || "", + bio: user.bio || "", + password: "" + }); + } + }, [user]); + + const handleLogin = async (event) => { + event.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ account, password }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "登录失败"); + } + localStorage.setItem("sproutgate_token", data.token); + setUser(data.user); + setAccount(""); + setPassword(""); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem("sproutgate_token"); + setUser(null); + }; + + const handleRegisterChange = (field, value) => { + setRegisterForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSendCode = async (event) => { + event.preventDefault(); + setRegisterLoading(true); + setRegisterError(""); + setRegisterMessage(""); + if (!registerForm.account || !registerForm.password || !registerForm.email) { + setRegisterError("请填写账户、密码和邮箱"); + setRegisterLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account: registerForm.account, + password: registerForm.password, + username: registerForm.username, + email: registerForm.email + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "发送验证码失败"); + } + setRegisterSent(true); + setRegisterExpiresAt(data.expiresAt || ""); + setRegisterMessage("验证码已发送,请检查邮箱"); + } catch (err) { + setRegisterError(err.message); + } finally { + setRegisterLoading(false); + } + }; + + const handleVerifyRegister = async (event) => { + event.preventDefault(); + setRegisterLoading(true); + setRegisterError(""); + setRegisterMessage(""); + if (!registerForm.account || !registerForm.code) { + setRegisterError("请输入账户与验证码"); + setRegisterLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/verify-email`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account: registerForm.account, + code: registerForm.code + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "验证失败"); + } + setRegisterMessage("注册成功,请使用账号登录"); + setRegisterSent(false); + setRegisterForm({ + account: "", + password: "", + username: "", + email: "", + code: "" + }); + setMode("login"); + } catch (err) { + setRegisterError(err.message); + } finally { + setRegisterLoading(false); + } + }; + + const handleResetChange = (field, value) => { + setResetForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSendReset = async (event) => { + event.preventDefault(); + setResetLoading(true); + setResetError(""); + setResetMessage(""); + if (!resetForm.account || !resetForm.email) { + setResetError("请填写账户与邮箱"); + setResetLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account: resetForm.account, + email: resetForm.email + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "发送重置邮件失败"); + } + setResetSent(true); + setResetExpiresAt(data.expiresAt || ""); + setResetMessage("重置验证码已发送,请检查邮箱"); + } catch (err) { + setResetError(err.message); + } finally { + setResetLoading(false); + } + }; + + const handleResetPassword = async (event) => { + event.preventDefault(); + setResetLoading(true); + setResetError(""); + setResetMessage(""); + if (!resetForm.account || !resetForm.code || !resetForm.newPassword) { + setResetError("请填写账户、验证码与新密码"); + setResetLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account: resetForm.account, + code: resetForm.code, + newPassword: resetForm.newPassword + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "重置失败"); + } + setResetMessage("密码已重置,请使用新密码登录"); + setResetSent(false); + setResetForm({ + account: "", + email: "", + code: "", + newPassword: "" + }); + setMode("login"); + } catch (err) { + setResetError(err.message); + } finally { + setResetLoading(false); + } + }; + + const handleSecondaryChange = (field, value) => { + setSecondaryForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSendSecondary = async (event) => { + event.preventDefault(); + setSecondaryLoading(true); + setSecondaryError(""); + setSecondaryMessage(""); + const token = localStorage.getItem("sproutgate_token"); + if (!token) { + setSecondaryError("请先登录"); + setSecondaryLoading(false); + return; + } + if (!secondaryForm.email) { + setSecondaryError("请输入辅助邮箱"); + setSecondaryLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/secondary-email/request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ email: secondaryForm.email }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "发送验证码失败"); + } + setSecondarySent(true); + setSecondaryExpiresAt(data.expiresAt || ""); + setSecondaryMessage("验证码已发送,请检查邮箱"); + } catch (err) { + setSecondaryError(err.message); + } finally { + setSecondaryLoading(false); + } + }; + + const handleVerifySecondary = async (event) => { + event.preventDefault(); + setSecondaryLoading(true); + setSecondaryError(""); + setSecondaryMessage(""); + const token = localStorage.getItem("sproutgate_token"); + if (!token) { + setSecondaryError("请先登录"); + setSecondaryLoading(false); + return; + } + if (!secondaryForm.email || !secondaryForm.code) { + setSecondaryError("请输入邮箱与验证码"); + setSecondaryLoading(false); + return; + } + try { + const res = await fetch(`${API_BASE}/api/auth/secondary-email/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + email: secondaryForm.email, + code: secondaryForm.code + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "验证失败"); + } + if (data.user) { + setUser(data.user); + } + setSecondaryMessage("辅助邮箱验证成功"); + setSecondarySent(false); + setSecondaryForm({ email: "", code: "" }); + } catch (err) { + setSecondaryError(err.message); + } finally { + setSecondaryLoading(false); + } + }; + + const handleProfileChange = (field, value) => { + setProfileForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleProfileSave = async (event) => { + event.preventDefault(); + setProfileLoading(true); + setProfileError(""); + setProfileMessage(""); + const token = localStorage.getItem("sproutgate_token"); + if (!token) { + setProfileError("请先登录"); + setProfileLoading(false); + return; + } + const payload = { + username: profileForm.username, + phone: profileForm.phone, + avatarUrl: profileForm.avatarUrl, + bio: profileForm.bio + }; + if (profileForm.password) { + payload.password = profileForm.password; + } + try { + const res = await fetch(`${API_BASE}/api/auth/profile`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "保存失败"); + } + setUser(data.user); + setProfileMessage("保存成功"); + setProfileForm((prev) => ({ ...prev, password: "" })); + } catch (err) { + setProfileError(err.message); + } finally { + setProfileLoading(false); + } + }; + + return ( +
+
用户中心
+ {!user && mode === "login" && ( +
+

登录

+ + + {error &&
{error}
} + + + +
+ )} + + {!user && mode === "register" && ( +
+

注册账号

+ + + + + {registerSent && ( + + )} + {registerExpiresAt &&
验证码有效期至:{registerExpiresAt}
} + {registerError &&
{registerError}
} + {registerMessage &&
{registerMessage}
} +
+ + +
+
+ )} + + {!user && mode === "reset" && ( +
+

重置密码

+ + + {resetSent && ( + <> + + + + )} + {resetExpiresAt &&
验证码有效期至:{resetExpiresAt}
} + {resetError &&
{resetError}
} + {resetMessage &&
{resetMessage}
} +
+ + +
+
+ )} + + {user && ( + <> +
+
+ avatar +
+

{user.username || user.account}

+

{user.email || "未填写邮箱"}

+ +
+
+
+
+ + {user.account} +
+
+ + 已设置(不展示) +
+
+ + {user.username || "未填写"} +
+
+ + {user.email || "未填写"} +
+
+ + {user.level ?? 0} 级 +
+
+ + {user.sproutCoins} +
+
+ + {user.phone || "未填写"} +
+
+ + {user.avatarUrl || "未填写"} +
+
+
+

个人简介

+
+
+
+ +
+

辅助邮箱

+
+ {(user.secondaryEmails || []).length === 0 && 暂无辅助邮箱} + {(user.secondaryEmails || []).map((email) => ( + + {email} + + ))} +
+ + {secondarySent && ( + + )} + {secondaryExpiresAt &&
验证码有效期至:{secondaryExpiresAt}
} + {secondaryError &&
{secondaryError}
} + {secondaryMessage &&
{secondaryMessage}
} +
+ +
+
+ +
+

修改资料

+ + + + +