diff --git a/.gitignore b/.gitignore index bc44ff8..9ff0214 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,25 @@ -# node -node_modules/ -dist/ -.vite/ - -# go -*.exe -*.test -/mengpost-backend/mengpost-backend* -/mengpost-backend/*.db -/mengpost-backend/*.db-* -mengpost.db -mengpost.db-* - +# node +node_modules/ +dist/ +.vite/ + +# go +mengpost-backend/.env +mengpost-backend/.env.docker + +# 生产 SQLite 数据文件(保留 data/.gitkeep) +/data/*.db +/data/*.db-* +*.exe +*.test +/mengpost-backend/mengpost-backend* +/mengpost-backend/*.db +/mengpost-backend/*.db-* +mengpost.db +mengpost.db-* + # editors .vscode/ .idea/ .DS_Store +.codex diff --git a/README.md b/README.md index d66ce37..d3107a0 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,161 @@ -# 萌邮 MengPost · 多邮箱管理面板 - -基于 **SMTP / IMAP** 协议的轻量多邮箱管理后台。 - -- 前端: React 18 + Vite + React Router -- 后端: Go 1.22 + Gin + SQLite (`modernc.org/sqlite`, 纯 Go, 无需 CGO) -- 邮件: `gopkg.in/gomail.v2` (SMTP)、`github.com/emersion/go-imap` (IMAP) -- UI: 文档风、简洁, 自适应手机 / 电脑 -- 鉴权: 打开站点时若本地未保存密钥,先显示 **访问密钥** 输入框(与后端 `MP_TOKEN` 一致,默认 `shumengya520`);通过后写入浏览器本地存储,后续请求带 `X-Auth-Token` - -## 目录结构 - -``` -mengpost/ - dev.bat / dev.sh # 一键本地开发 (双窗口启动前后端) - build.bat / build.sh # 构建前端 - mengpost-backend/ # Go + Gin 后端 - config/ # 配置(端口/DB/token) - database/ # SQLite 初始化 & 迁移 - models/ # 数据模型 (accounts / sent_logs) - services/ # smtp.go / imap.go - middleware/ # token 鉴权中间件 - handlers/ # HTTP 处理器 - router/ # 路由聚合 - main.go # 入口 - mengpost-frontend/ # React + Vite 前端 - public/ # favicon.ico、logo.png 等静态资源 - src/ - api/ # 统一 fetch 封装, token 存 localStorage - components/ # AccessGate / MainLayout(侧栏) / Topbar - pages/ # Home / Admin / Accounts / Mailbox / Compose - styles/global.css - scripts/ # publish-gitea:用 tea 创建远端仓库的辅助脚本 -``` - -## 本地开发 - -依赖: -- Go ≥ 1.22、Node.js ≥ 18 (SQLite 使用纯 Go 实现, **无需 CGO / gcc**) - -```bash -# Windows -dev.bat - -# macOS / Linux -chmod +x dev.sh && ./dev.sh -``` - -- 前端: http://127.0.0.1:5173 -- 后端: http://127.0.0.1:8787 (Vite 已代理 `/api`) - -## 构建 - -```bash -# Windows -build.bat - -# macOS / Linux -./build.sh -``` - -产物在 `mengpost-frontend/dist`。`npm run preview` 已配置与开发环境相同的 `/api` 代理。 - -若静态文件与后端**不同源**部署,构建前设置 `VITE_API_URL` 指向后端根地址(如 `http://127.0.0.1:8787`),再执行 `build`。 - -## 环境变量 - -| 变量名 | 默认值 | 说明 | -| ---- | ---- | ---- | -| `MP_PORT` | `8787` | 后端 HTTP 端口 | -| `MP_DB` | `mengpost.db` | SQLite 文件路径 | -| `MP_TOKEN` | `shumengya520` | 访问密钥(与前端输入一致) | -| `MP_LOG` | `info` | 日志级别 | - -### 前端 (Vite) - -| 变量名 | 说明 | -| ---- | ---- | -| `VITE_API_URL` | 可选。留空时请求相对路径 `/api`(开发 / preview 走 Vite 代理)。独立部署静态站时设为后端地址。 | - -## 使用 - -1. 用 `dev.bat` / `dev.sh` 启动后,浏览器打开 **http://127.0.0.1:5173**(勿用 `file://` 打开构建产物)。 -2. 首次访问输入 **访问密钥**(与 `MP_TOKEN` 相同);顶部可 **登出** 清除本地密钥。 -3. 在「账户」页新增邮箱 (SMTP / IMAP 与授权码)。 -4. 「收件箱」拉取邮件;「写信」发信。 - -### 常见问题 - -- **未启动后端** 或端口不是 8787:密钥验证会失败或提示无法连接。 -- **未通过 Vite 访问**:直接双击 `dist/index.html` 没有 `/api` 代理,需 `npm run preview` 或配置 `VITE_API_URL` 后重新构建。 -- **使用了 `@vitejs/plugin-pwa` / Workbox**:勿让 Service Worker 缓存 `/api/*`。开发时可在 DevTools → Application → Service Workers 勾选 **Bypass for network** 或先 **Unregister** 再刷新。 - -## 常见邮箱参考 - -| 服务商 | SMTP | IMAP | -| ---- | ---- | ---- | -| QQ | `smtp.qq.com:465` | `imap.qq.com:993` | -| 163 | `smtp.163.com:465` | `imap.163.com:993` | -| Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` | -| Outlook | `smtp.office365.com:587` | `outlook.office365.com:993` | - -> 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。 - -## API 一览 - -所有受保护接口均需要请求头 `X-Auth-Token: `。 - -``` -POST /api/auth/verify # 校验 token -GET /api/accounts # 列出账户 -POST /api/accounts # 新增 -PUT /api/accounts/:id # 更新 (password 为空则保留) -DELETE /api/accounts/:id # 删除 -GET /api/accounts/:id/mailboxes # IMAP 邮件夹 -GET /api/accounts/:id/messages # 列出邮件摘要 ?mailbox=&limit= -GET /api/accounts/:id/messages/:uid # 邮件详情 -POST /api/accounts/:id/send # 发送邮件 -GET /api/accounts/:id/sent # 发送日志 -``` - -## 推送到自建 Gitea(tea CLI) - -前置:安装 [tea](https://gitea.com/gitea/tea/releases),在 Gitea **设置 → 应用 → 生成令牌** 创建访问令牌。 - -```bash -# 1. 添加登录(只需一次;将 URL、令牌换成你的实例) -tea login add --url https://你的Gitea地址 --token <访问令牌> --name mygitea -tea login default mygitea -tea whoami -``` - -在本项目**根目录**执行: - -```bash -# 2. 在 Gitea 上创建空仓库(名称可改;若已存在同名库请先删除或换名) -tea repos create --name mengpost --description "萌邮 MengPost:基于 SMTP/IMAP 的多邮箱管理面板(React + Vite + Go + Gin + SQLite)" - -# 3. 添加远程并推送(将下面 URL 换成 Gitea 仓库页的 HTTPS 克隆地址) -git remote add origin https://你的Gitea/用户名/mengpost.git -git branch -M main -git push -u origin main -``` - -若本地已有 `origin`,可改为:`git remote set-url origin ` 再 `git push -u origin main`。 - -**辅助脚本**(仅执行第 2 步 `tea repos create`,推送仍需手动 `git remote` + `git push`): - -- Windows: `powershell -ExecutionPolicy Bypass -File scripts/publish-gitea.ps1` -- Linux / macOS: `chmod +x scripts/publish-gitea.sh && ./scripts/publish-gitea.sh` - -## 许可 - -内部自用项目, 请按需修改。 +# 萌邮 MengPost · 多邮箱管理面板 + +基于 **SMTP / IMAP** 协议的轻量多邮箱管理后台。 + +- 前端: React 18 + Vite + React Router +- 后端: Go 1.22 + Gin + SQLite (`modernc.org/sqlite`, 纯 Go, 无需 CGO) +- 邮件: `gopkg.in/gomail.v2` (SMTP)、`github.com/emersion/go-imap` (IMAP) +- UI: 文档风、简洁, 自适应手机 / 电脑 +- 鉴权: 打开站点时若本地未保存密钥,先显示 **访问密钥** 输入框(与后端 `MP_TOKEN` 一致,默认 `shumengya520`);通过后写入浏览器本地存储,后续请求带 `X-Auth-Token` + +## 目录结构 + +``` +mengpost/ + dev.bat / dev.sh # 一键本地开发 (双窗口启动前后端) + build.bat / build.sh # 构建前端 + mengpost-backend/ # Go + Gin 后端 + config/ # 配置(端口/DB/token) + database/ # SQLite 初始化 & 迁移 + models/ # 数据模型 (accounts / sent_logs) + services/ # smtp.go / imap.go + middleware/ # token 鉴权中间件 + handlers/ # HTTP 处理器 + router/ # 路由聚合 + main.go # 入口 + mengpost-frontend/ # React + Vite 前端 + public/ # favicon.ico、logo.png 等静态资源 + src/ + api/ # 统一 fetch 封装, token 存 localStorage + components/ # AccessGate / MainLayout(侧栏) / Topbar + pages/ # Home / Admin / Accounts / Mailbox / Compose + styles/global.css + scripts/ # publish-gitea:用 tea 创建远端仓库的辅助脚本 +``` + +## 本地开发 + +依赖: +- Go ≥ 1.22、Node.js ≥ 18 (SQLite 使用纯 Go 实现, **无需 CGO / gcc**) + +```bash +# Windows +dev.bat + +# macOS / Linux +chmod +x dev.sh && ./dev.sh +``` + +- 前端: http://127.0.0.1:5173 +- 后端: http://127.0.0.1:8787 (Vite 已代理 `/api`) + +## 构建 + +```bash +# Windows +build.bat + +# macOS / Linux +./build.sh +``` + +产物在 `mengpost-frontend/dist`。`npm run preview` 已配置与开发环境相同的 `/api` 代理。 + +若静态文件与后端**不同源**部署,构建前设置 `VITE_API_URL` 指向后端根地址(如 `http://127.0.0.1:8787`),再执行 `build`。 + +## 环境变量 + +| 变量名 | 默认值 | 说明 | +| ---- | ---- | ---- | +| `MP_PORT` | `8787` | 后端 HTTP 端口 | +| `MP_DB` | `mengpost.db` | SQLite 文件路径 | +| `MP_TOKEN` | `shumengya520` | 访问密钥(与前端输入一致) | +| `MP_LOG` | `info` | 日志级别 | + +### 前端 (Vite) + +| 变量名 | 说明 | +| ---- | ---- | +| `VITE_API_URL` | 可选。留空时请求相对路径 `/api`(开发 / preview 走 Vite 代理)。独立部署静态站时设为后端地址。 | + +## 使用 + +1. 用 `dev.bat` / `dev.sh` 启动后,浏览器打开 **http://127.0.0.1:5173**(勿用 `file://` 打开构建产物)。 +2. 首次访问输入 **访问密钥**(与 `MP_TOKEN` 相同);顶部可 **登出** 清除本地密钥。 +3. 在「账户」页新增邮箱 (SMTP / IMAP 与授权码)。 +4. 「收件箱」拉取邮件;「写信」发信。 + +### 常见问题 + +- **未启动后端** 或端口不是 8787:密钥验证会失败或提示无法连接。 +- **未通过 Vite 访问**:直接双击 `dist/index.html` 没有 `/api` 代理,需 `npm run preview` 或配置 `VITE_API_URL` 后重新构建。 +- **使用了 `@vitejs/plugin-pwa` / Workbox**:勿让 Service Worker 缓存 `/api/*`。开发时可在 DevTools → Application → Service Workers 勾选 **Bypass for network** 或先 **Unregister** 再刷新。 + +## 常见邮箱参考 + +| 服务商 | SMTP | IMAP | +| ---- | ---- | ---- | +| QQ | `smtp.qq.com:465` | `imap.qq.com:993` | +| 163 | `smtp.163.com:465` | `imap.163.com:993` | +| Gmail | `smtp.gmail.com:465` | `imap.gmail.com:993` | +| Outlook / Hotmail(微软个人邮箱) | `smtp.office365.com:587`(STARTTLS) | `outlook.office365.com:993`(SSL) | + +> 使用 **应用专用密码 / 授权码**, 不要直接使用邮箱登录密码。 + +### 微软 Outlook / Hotmail / Live(2026说明) + +- **IMAP 默认关闭**:登录 [outlook.live.com](https://outlook.live.com) → 设置 → 邮件 → 同步电子邮件,开启 **IMAP**(不同界面可能为「POP和 IMAP」)。 +- **服务器**:收信 `outlook.office365.com:993`(SSL/TLS),发信 `smtp.office365.com:587`(**STARTTLS**,勿填 465 除非你知道在做什么)。萌邮账户弹窗内可点 **「微软 Outlook / Hotmail」** 一键填入。 +- **身份验证**:微软正推动 **新式验证(OAuth 2.0)**,传统 **用户名+密码(基础认证)** 在 **SMTP AUTH** 等场景将逐步停用(约 2026 年)。当前萌邮仍使用「应用密码 / 仍有效的账户密码」走基础认证;若发信被拒,需改用支持微软 OAuth 的客户端,或等待后续版本接入 OAuth。 +- **后端**:对 `@outlook.com`、`@hotmail.com`、`@live.com`、`@msn.com` 会自动使用合适的 TLS `ServerName`,并在 IMAP/SMTP 报错时附加简要排查说明。 + +## API 一览 + +所有受保护接口均需要请求头 `X-Auth-Token: `。 + +``` +POST /api/auth/verify # 校验 token +GET /api/accounts # 列出账户 +POST /api/accounts # 新增 +PUT /api/accounts/:id # 更新 (password 为空则保留) +DELETE /api/accounts/:id # 删除 +GET /api/accounts/:id/mailboxes # IMAP 邮件夹 +GET /api/accounts/:id/messages # 列出邮件摘要 ?mailbox=&limit= +GET /api/accounts/:id/messages/:uid # 邮件详情 +POST /api/accounts/:id/send # 发送邮件 +GET /api/accounts/:id/sent # 发送日志 +``` + +## 推送到自建 Gitea(tea CLI) + +前置:安装 [tea](https://gitea.com/gitea/tea/releases),在 Gitea **设置 → 应用 → 生成令牌** 创建访问令牌。 + +```bash +# 1. 添加登录(只需一次;将 URL、令牌换成你的实例) +tea login add --url https://你的Gitea地址 --token <访问令牌> --name mygitea +tea login default mygitea +tea whoami +``` + +在本项目**根目录**执行: + +```bash +# 2. 在 Gitea 上创建空仓库(名称可改;若已存在同名库请先删除或换名) +tea repos create --name mengpost --description "萌邮 MengPost:基于 SMTP/IMAP 的多邮箱管理面板(React + Vite + Go + Gin + SQLite)" + +# 3. 添加远程并推送(将下面 URL 换成 Gitea 仓库页的 HTTPS 克隆地址) +git remote add origin https://你的Gitea/用户名/mengpost.git +git branch -M main +git push -u origin main +``` + +若本地已有 `origin`,可改为:`git remote set-url origin ` 再 `git push -u origin main`。 + +**辅助脚本**(仅执行第 2 步 `tea repos create`,推送仍需手动 `git remote` + `git push`): + +- Windows: `powershell -ExecutionPolicy Bypass -File scripts/publish-gitea.ps1` +- Linux / macOS: `chmod +x scripts/publish-gitea.sh && ./scripts/publish-gitea.sh` + +## 许可 + +内部自用项目, 请按需修改。 diff --git a/build.bat b/build.bat index 2f72af1..74f54f0 100644 --- a/build.bat +++ b/build.bat @@ -1,16 +1,16 @@ -@echo off -rem 构建前端静态产物到 mengpost-frontend\dist -setlocal -set ROOT=%~dp0 -cd /d %ROOT%mengpost-frontend -if not exist node_modules ( - call npm install || goto :err -) -call npm run build || goto :err -echo. -echo 前端已构建: %ROOT%mengpost-frontend\dist -endlocal & exit /b 0 - -:err -echo 构建失败 -endlocal & exit /b 1 +@echo off +rem 构建前端静态产物到 mengpost-frontend\dist +setlocal +set ROOT=%~dp0 +cd /d %ROOT%mengpost-frontend +if not exist node_modules ( + call npm install || goto :err +) +call npm run build || goto :err +echo. +echo 前端已构建: %ROOT%mengpost-frontend\dist +endlocal & exit /b 0 + +:err +echo 构建失败 +endlocal & exit /b 1 diff --git a/build.sh b/build.sh index f87f1cc..dbd1c81 100644 --- a/build.sh +++ b/build.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash -# 构建前端静态产物到 mengpost-frontend/dist -set -e -ROOT="$(cd "$(dirname "$0")" && pwd)" -cd "$ROOT/mengpost-frontend" -[ -d node_modules ] || npm install -npm run build -echo "" -echo " 前端已构建: $ROOT/mengpost-frontend/dist" +#!/usr/bin/env bash +# 构建前端静态产物到 mengpost-frontend/dist +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT/mengpost-frontend" +[ -d node_modules ] || npm install +npm run build +echo "" +echo " 前端已构建: $ROOT/mengpost-frontend/dist" diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dev.bat b/dev.bat index 88c8d78..fbcc301 100644 --- a/dev.bat +++ b/dev.bat @@ -1,13 +1,13 @@ -@echo off -rem 一键启动本地开发: 后端 Go 和 前端 Vite, 各自独立窗口. -setlocal -set ROOT=%~dp0 - -start "mengpost-backend" cmd /k "cd /d %ROOT%mengpost-backend && go mod tidy && go run ." -start "mengpost-frontend" cmd /k "cd /d %ROOT%mengpost-frontend && (if not exist node_modules npm install) && npm run dev" - -echo. -echo backend : http://127.0.0.1:8787 -echo frontend: http://127.0.0.1:5173 -echo. -endlocal +@echo off +rem 一键启动本地开发: 后端 Go 和 前端 Vite, 各自独立窗口. +setlocal +set ROOT=%~dp0 + +start "mengpost-backend" cmd /k "cd /d %ROOT%mengpost-backend && go mod tidy && go run ." +start "mengpost-frontend" cmd /k "cd /d %ROOT%mengpost-frontend && (if not exist node_modules npm install) && npm run dev" + +echo. +echo backend : http://127.0.0.1:8787 +echo frontend: http://127.0.0.1:5173 +echo. +endlocal diff --git a/dev.sh b/dev.sh index 3f31f67..4e66a06 100644 --- a/dev.sh +++ b/dev.sh @@ -1,16 +1,16 @@ -#!/usr/bin/env bash -# 一键启动本地开发: 后端 Go 和 前端 Vite. Ctrl-C 同时退出. -set -e -ROOT="$(cd "$(dirname "$0")" && pwd)" - -cleanup() { kill 0 2>/dev/null || true; } -trap cleanup EXIT INT TERM - -( cd "$ROOT/mengpost-backend" && go mod tidy && go run . ) & -( cd "$ROOT/mengpost-frontend" && { [ -d node_modules ] || npm install; } && npm run dev ) & - -echo "" -echo " backend : http://127.0.0.1:8787" -echo " frontend: http://127.0.0.1:5173" -echo "" -wait +#!/usr/bin/env bash +# 一键启动本地开发: 后端 Go 和 前端 Vite. Ctrl-C 同时退出. +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" + +cleanup() { kill 0 2>/dev/null || true; } +trap cleanup EXIT INT TERM + +( cd "$ROOT/mengpost-backend" && go mod tidy && go run . ) & +( cd "$ROOT/mengpost-frontend" && { [ -d node_modules ] || npm install; } && npm run dev ) & + +echo "" +echo " backend : http://127.0.0.1:8787" +echo " frontend: http://127.0.0.1:5173" +echo "" +wait diff --git a/mengpost-backend/.dockerignore b/mengpost-backend/.dockerignore new file mode 100644 index 0000000..831e24e --- /dev/null +++ b/mengpost-backend/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.env.* +*.md +*.db +*.db-* +data/ +dist/ +__debug_bin +*.exe +*.test diff --git a/mengpost-backend/Dockerfile b/mengpost-backend/Dockerfile new file mode 100644 index 0000000..9399bf2 --- /dev/null +++ b/mengpost-backend/Dockerfile @@ -0,0 +1,24 @@ +# 多阶段构建,纯 Go(CGO_ENABLED=0)+ modernc.org/sqlite,适合 Alpine运行。 +# 对外映射建议:主机 28088 ->容器 8088(见 docker-compose.yml)。 +FROM golang:alpine AS build +WORKDIR /src +RUN apk add --no-cache git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ENV CGO_ENABLED=0 +RUN go build -trimpath -ldflags="-s -w" -o /out/mengpost-api . + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +ENV TZ=Asia/Shanghai +WORKDIR /app +RUN mkdir -p /app/data +COPY --from=build /out/mengpost-api /app/mengpost-api + +ENV GIN_MODE=release +ENV MP_PORT=8088 +ENV MP_DB=/app/data/mengpost.db + +EXPOSE 8088 +ENTRYPOINT ["/app/mengpost-api"] diff --git a/mengpost-backend/config/config.go b/mengpost-backend/config/config.go index 2372a15..0f1bc0f 100644 --- a/mengpost-backend/config/config.go +++ b/mengpost-backend/config/config.go @@ -1,30 +1,47 @@ -package config - -import ( - "os" -) - -// Config 存放运行期配置, 所有值均可通过环境变量覆盖. -type Config struct { - Port string // HTTP 监听端口 - DBPath string // SQLite 数据库路径 - Token string // 管理员访问 token - LogLevel string -} - -// Load 读取环境变量生成配置, 未设置时使用默认值. -func Load() *Config { - return &Config{ - Port: env("MP_PORT", "8787"), - DBPath: env("MP_DB", "mengpost.db"), - Token: env("MP_TOKEN", "shumengya520"), - LogLevel: env("MP_LOG", "info"), - } -} - -func env(k, def string) string { - if v := os.Getenv(k); v != "" { - return v - } - return def -} +package config + +import ( + "os" +) + +// Config 存放运行期配置, 所有值均可通过环境变量覆盖. +type Config struct { + Port string // HTTP 监听端口 + DBPath string // SQLite 数据库路径 + Token string // 管理员访问 token + LogLevel string + // 微软 OAuth(个人 Outlook/Hotmail);MP_MS_CLIENT_ID 非空则启用 + MSClientID string + MSClientSecret string + MSRedirectURL string // 须与 Azure 应用重定向 URI 完全一致,默认本机回调 + FrontendURL string // OAuth 完成后浏览器跳回的前端根地址 + // CORS:MP_CORS_ORIGINS 为空或 * 时允许任意 Origin;否则为英文逗号分隔的来源列表 + CORSAllowOrigins string +} + +// Load 读取环境变量生成配置, 未设置时使用默认值. +func Load() *Config { + return &Config{ + Port: env("MP_PORT", "8787"), + DBPath: env("MP_DB", "mengpost.db"), + Token: env("MP_TOKEN", "shumengya520"), + LogLevel: env("MP_LOG", "info"), + MSClientID: env("MP_MS_CLIENT_ID", ""), + MSClientSecret: env("MP_MS_CLIENT_SECRET", ""), + MSRedirectURL: env("MP_MS_OAUTH_REDIRECT", "http://127.0.0.1:8787/api/oauth/microsoft/callback"), + FrontendURL: env("MP_FRONTEND_URL", "http://127.0.0.1:5173"), + CORSAllowOrigins: env("MP_CORS_ORIGINS", "*"), + } +} + +// MicrosoftOAuthEnabled 是否配置了微软 OAuth(需同时配置 Client ID). +func (c *Config) MicrosoftOAuthEnabled() bool { + return c.MSClientID != "" +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/mengpost-backend/database/db.go b/mengpost-backend/database/db.go index 71121d3..04959b5 100644 --- a/mengpost-backend/database/db.go +++ b/mengpost-backend/database/db.go @@ -1,59 +1,78 @@ -package database - -import ( - "database/sql" - "log" - - _ "modernc.org/sqlite" -) - -var DB *sql.DB - -// Init 打开 SQLite 并执行必要的表结构初始化. -// 使用纯 Go 的 modernc.org/sqlite, 无需 CGO. -func Init(path string) { - dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" - db, err := sql.Open("sqlite", dsn) - if err != nil { - log.Fatalf("open sqlite: %v", err) - } - DB = db - if err := migrate(); err != nil { - log.Fatalf("migrate: %v", err) - } -} - -// migrate 创建所需表. 统一放在一处便于维护. -func migrate() error { - stmts := []string{ - `CREATE TABLE IF NOT EXISTS accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - smtp_host TEXT NOT NULL, - smtp_port INTEGER NOT NULL, - imap_host TEXT NOT NULL, - imap_port INTEGER NOT NULL, - use_tls INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`, - `CREATE TABLE IF NOT EXISTS sent_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - to_addr TEXT NOT NULL, - subject TEXT, - body TEXT, - status TEXT, - error TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE - );`, - } - for _, s := range stmts { - if _, err := DB.Exec(s); err != nil { - return err - } - } - return nil -} +package database + +import ( + "database/sql" + "log" + "strings" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +// Init 打开 SQLite 并执行必要的表结构初始化. +func Init(path string) { + dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + log.Fatalf("open sqlite: %v", err) + } + DB = db + if err := migrate(); err != nil { + log.Fatalf("migrate: %v", err) + } +} + +func migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL DEFAULT '', + smtp_host TEXT NOT NULL, + smtp_port INTEGER NOT NULL, + imap_host TEXT NOT NULL, + imap_port INTEGER NOT NULL, + use_tls INTEGER DEFAULT 1, + auth_type TEXT DEFAULT 'basic', + oauth_refresh_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE TABLE IF NOT EXISTS sent_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + to_addr TEXT NOT NULL, + subject TEXT, + body TEXT, + status TEXT, + error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE + );`, + `CREATE TABLE IF NOT EXISTS oauth_microsoft_pending ( + state TEXT PRIMARY KEY, + refresh_token TEXT, + email TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`, + } + for _, s := range stmts { + if _, err := DB.Exec(s); err != nil { + return err + } + } + // 旧库升级:逐列添加(已存在则忽略错误) + alters := []string{ + `ALTER TABLE accounts ADD COLUMN auth_type TEXT DEFAULT 'basic'`, + `ALTER TABLE accounts ADD COLUMN oauth_refresh_token TEXT`, + } + for _, s := range alters { + if _, err := DB.Exec(s); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { + log.Printf("migrate alter note: %v", err) + } + } + } + return nil +} diff --git a/mengpost-backend/docker-compose.yml b/mengpost-backend/docker-compose.yml new file mode 100644 index 0000000..ed3f290 --- /dev/null +++ b/mengpost-backend/docker-compose.yml @@ -0,0 +1,24 @@ +# 在 mengpost-backend 目录执行: docker compose up -d +# 数据目录:仓库根目录下的 data/(与 backend 并列),挂载到容器 /app/data +services: + mengpost-api: + build: . + image: mengpost-api:local + restart: unless-stopped + ports: + # 主机 28088 -> 容器 8088(内网穿透可把 post.api.smyhub.com 指到主机 28088) + - "28088:8088" + environment: + GIN_MODE: release + MP_PORT: "8088" + MP_DB: /app/data/mengpost.db + # 生产务必改为强随机字符串,或通过 .env 覆盖 + MP_TOKEN: ${MP_TOKEN:-change-me-in-production} + MP_FRONTEND_URL: ${MP_FRONTEND_URL:-https://post.smyhub.com} + MP_MS_OAUTH_REDIRECT: ${MP_MS_OAUTH_REDIRECT:-https://post.api.smyhub.com/api/oauth/microsoft/callback} + MP_MS_CLIENT_ID: ${MP_MS_CLIENT_ID:-} + MP_MS_CLIENT_SECRET: ${MP_MS_CLIENT_SECRET:-} + # 留空或 * 表示允许任意 Origin;可改为 https://post.smyhub.com + MP_CORS_ORIGINS: ${MP_CORS_ORIGINS:-} + volumes: + - ../data:/app/data diff --git a/mengpost-backend/env.docker.example b/mengpost-backend/env.docker.example new file mode 100644 index 0000000..a2cbdda --- /dev/null +++ b/mengpost-backend/env.docker.example @@ -0,0 +1,6 @@ +# 复制为 .env 后由 docker compose 读取(与 docker-compose.yml 同目录) +MP_TOKEN=请改为强随机字符串 +MP_FRONTEND_URL=https://post.smyhub.com +MP_MS_OAUTH_REDIRECT=https://post.api.smyhub.com/api/oauth/microsoft/callback +MP_MS_CLIENT_ID= +MP_MS_CLIENT_SECRET= diff --git a/mengpost-backend/go.mod b/mengpost-backend/go.mod index 0bb1955..3a8c7ed 100644 --- a/mengpost-backend/go.mod +++ b/mengpost-backend/go.mod @@ -1,13 +1,16 @@ module mengpost-backend -go 1.22 +go 1.25.0 require ( github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde github.com/emersion/go-message v0.18.1 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/emersion/go-smtp v0.24.0 github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/joho/godotenv v1.5.1 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df modernc.org/sqlite v1.33.1 ) @@ -18,7 +21,6 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // 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 diff --git a/mengpost-backend/go.sum b/mengpost-backend/go.sum index b40394b..841545f 100644 --- a/mengpost-backend/go.sum +++ b/mengpost-backend/go.sum @@ -19,8 +19,11 @@ github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde/go.mod h1:sPwp github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= -github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 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= @@ -49,6 +52,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= diff --git a/mengpost-backend/handlers/account.go b/mengpost-backend/handlers/account.go index 357ed72..ee69af4 100644 --- a/mengpost-backend/handlers/account.go +++ b/mengpost-backend/handlers/account.go @@ -1,61 +1,65 @@ -package handlers - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "mengpost-backend/models" -) - -func ListAccounts(c *gin.Context) { - list, err := models.ListAccounts() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, list) -} - -func CreateAccount(c *gin.Context) { - var a models.Account - if err := c.ShouldBindJSON(&a); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if a.Email == "" || a.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "email 和 password 必填"}) - return - } - if err := models.CreateAccount(&a); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - a.Password = "" - c.JSON(http.StatusOK, a) -} - -func UpdateAccount(c *gin.Context) { - id, _ := strconv.ParseInt(c.Param("id"), 10, 64) - var a models.Account - if err := c.ShouldBindJSON(&a); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - a.ID = id - if err := models.UpdateAccount(&a); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func DeleteAccount(c *gin.Context) { - id, _ := strconv.ParseInt(c.Param("id"), 10, 64) - if err := models.DeleteAccount(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "mengpost-backend/models" +) + +func ListAccounts(c *gin.Context) { + list, err := models.ListAccounts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, list) +} + +func CreateAccount(c *gin.Context) { + var a models.Account + if err := c.ShouldBindJSON(&a); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if a.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"}) + return + } + if a.AuthType != models.AuthTypeOAuthMicrosoft && a.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "普通账户需要填写 password"}) + return + } + if err := models.CreateAccount(&a); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + a.Password = "" + c.JSON(http.StatusOK, a) +} + +func UpdateAccount(c *gin.Context) { + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + var a models.Account + if err := c.ShouldBindJSON(&a); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + a.ID = id + if err := models.UpdateAccount(&a); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func DeleteAccount(c *gin.Context) { + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + if err := models.DeleteAccount(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/mengpost-backend/handlers/auth.go b/mengpost-backend/handlers/auth.go index 7c8ce2d..5a2bbe7 100644 --- a/mengpost-backend/handlers/auth.go +++ b/mengpost-backend/handlers/auth.go @@ -1,27 +1,27 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "mengpost-backend/config" -) - -// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确. -func VerifyToken(cfg *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - var body struct { - Token string `json:"token"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) - return - } - if body.Token != cfg.Token { - c.JSON(http.StatusUnauthorized, gin.H{"ok": false}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "mengpost-backend/config" +) + +// VerifyToken 供前端 token 弹窗使用: 仅返回 token 是否正确. +func VerifyToken(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + Token string `json:"token"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + if body.Token != cfg.Token { + c.JSON(http.StatusUnauthorized, gin.H{"ok": false}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} diff --git a/mengpost-backend/handlers/email.go b/mengpost-backend/handlers/email.go index 8af7e30..ae68462 100644 --- a/mengpost-backend/handlers/email.go +++ b/mengpost-backend/handlers/email.go @@ -1,111 +1,112 @@ -package handlers - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "mengpost-backend/models" - "mengpost-backend/services" -) - -func getAccount(c *gin.Context) *models.Account { - id, _ := strconv.ParseInt(c.Param("id"), 10, 64) - acc, err := models.GetAccount(id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) - return nil - } - return acc -} - -// GET /api/accounts/:id/mailboxes -func ListMailboxes(c *gin.Context) { - acc := getAccount(c) - if acc == nil { - return - } - boxes, err := services.ListMailboxes(acc) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, boxes) -} - -// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30 -func ListMessages(c *gin.Context) { - acc := getAccount(c) - if acc == nil { - return - } - mailbox := c.DefaultQuery("mailbox", "INBOX") - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) - list, err := services.FetchMessages(acc, mailbox, limit) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, list) -} - -// GET /api/accounts/:id/messages/:uid?mailbox=INBOX -func GetMessage(c *gin.Context) { - acc := getAccount(c) - if acc == nil { - return - } - uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32) - mailbox := c.DefaultQuery("mailbox", "INBOX") - detail, err := services.FetchMessage(acc, mailbox, uint32(uid64)) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, detail) -} - -// POST /api/accounts/:id/send -func SendMessage(c *gin.Context) { - acc := getAccount(c) - if acc == nil { - return - } - var body struct { - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` - HTML bool `json:"html"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body} - if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil { - log.Status = "failed" - log.Error = err.Error() - _ = models.AddSentLog(log) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - log.Status = "ok" - _ = models.AddSentLog(log) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// GET /api/accounts/:id/sent?limit=50 -func ListSentLogs(c *gin.Context) { - acc := getAccount(c) - if acc == nil { - return - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) - list, err := models.ListSentLogs(acc.ID, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, list) -} +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "mengpost-backend/models" + "mengpost-backend/services" +) + +func getAccount(c *gin.Context) *models.Account { + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + acc, err := models.GetAccount(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + return nil + } + return acc +} + +// GET /api/accounts/:id/mailboxes +func ListMailboxes(c *gin.Context) { + acc := getAccount(c) + if acc == nil { + return + } + boxes, err := services.ListMailboxes(acc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()}) + return + } + c.JSON(http.StatusOK, boxes) +} + +// GET /api/accounts/:id/messages?mailbox=INBOX&limit=30 +func ListMessages(c *gin.Context) { + acc := getAccount(c) + if acc == nil { + return + } + mailbox := c.DefaultQuery("mailbox", "INBOX") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) + list, err := services.FetchMessages(acc, mailbox, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()}) + return + } + c.JSON(http.StatusOK, list) +} + +// GET /api/accounts/:id/messages/:uid?mailbox=INBOX +func GetMessage(c *gin.Context) { + acc := getAccount(c) + if acc == nil { + return + } + uid64, _ := strconv.ParseUint(c.Param("uid"), 10, 32) + mailbox := c.DefaultQuery("mailbox", "INBOX") + detail, err := services.FetchMessage(acc, mailbox, uint32(uid64)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": services.WrapMicrosoftMailErr(acc.Email, err).Error()}) + return + } + c.JSON(http.StatusOK, detail) +} + +// POST /api/accounts/:id/send +func SendMessage(c *gin.Context) { + acc := getAccount(c) + if acc == nil { + return + } + var body struct { + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + HTML bool `json:"html"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log := &models.SentLog{AccountID: acc.ID, To: body.To, Subject: body.Subject, Body: body.Body} + if err := services.SendMail(acc, body.To, body.Subject, body.Body, body.HTML); err != nil { + wrapped := services.WrapMicrosoftMailErr(acc.Email, err) + log.Status = "failed" + log.Error = wrapped.Error() + _ = models.AddSentLog(log) + c.JSON(http.StatusInternalServerError, gin.H{"error": wrapped.Error()}) + return + } + log.Status = "ok" + _ = models.AddSentLog(log) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// GET /api/accounts/:id/sent?limit=50 +func ListSentLogs(c *gin.Context) { + acc := getAccount(c) + if acc == nil { + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + list, err := models.ListSentLogs(acc.ID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, list) +} diff --git a/mengpost-backend/handlers/oauth_microsoft.go b/mengpost-backend/handlers/oauth_microsoft.go new file mode 100644 index 0000000..7cbfc75 --- /dev/null +++ b/mengpost-backend/handlers/oauth_microsoft.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "mengpost-backend/config" + "mengpost-backend/models" + "mengpost-backend/services" +) + +const ( + msSMTPHost = "smtp.office365.com" + msSMTPPort = 587 + msIMAPHost = "outlook.office365.com" + msIMAPPort = 993 + oauthPath = "/admin/accounts" + tokenTimeout = 90 * time.Second +) + +func randomOAuthState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// MicrosoftOAuthEnabled GET /api/oauth/microsoft/enabled +func MicrosoftOAuthEnabled(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"enabled": cfg.MicrosoftOAuthEnabled()}) + } +} + +// MicrosoftOAuthStart GET /api/oauth/microsoft/start +func MicrosoftOAuthStart(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.MicrosoftOAuthEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "未配置微软 OAuth(MP_MS_CLIENT_ID)"}) + return + } + state, err := randomOAuthState() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := models.InsertOAuthPendingState(state); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + authURL := services.MicrosoftAuthCodeURL(cfg, state) + c.JSON(http.StatusOK, gin.H{"url": authURL, "state": state}) + } +} + +// MicrosoftOAuthCallback GET /api/oauth/microsoft/callback(微软公网回调,无 token) +func MicrosoftOAuthCallback(cfg *config.Config) gin.HandlerFunc { + frontendAccounts := strings.TrimSuffix(cfg.FrontendURL, "/") + oauthPath + return func(c *gin.Context) { + q := c.Request.URL.Query() + if errMsg := q.Get("error"); errMsg != "" { + desc := q.Get("error_description") + msg := errMsg + if desc != "" { + msg = errMsg + ": " + desc + } + c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(msg)) + return + } + state := q.Get("state") + code := q.Get("code") + if state == "" || code == "" { + c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape("缺少 code 或 state")) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), tokenTimeout) + defer cancel() + email, refresh, _, _, err := services.ExchangeMicrosoftCode(ctx, cfg, code) + if err != nil { + c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error())) + return + } + if err := models.UpdateOAuthPendingTokens(state, refresh, email); err != nil { + c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error())) + return + } + c.Redirect(http.StatusFound, frontendAccounts+"?oauth_ms="+url.QueryEscape(state)) + } +} + +type microsoftFinishBody struct { + State string `json:"state"` +} + +// MicrosoftOAuthFinish POST /api/oauth/microsoft/finish +func MicrosoftOAuthFinish() gin.HandlerFunc { + return func(c *gin.Context) { + var body microsoftFinishBody + if err := c.ShouldBindJSON(&body); err != nil || body.State == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "需要 JSON: {\"state\":\"...\"}"}) + return + } + refresh, email, err := models.TakeOAuthPending(body.State) + if err != nil { + if errors.Is(err, models.ErrOAuthPendingNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "登录会话无效或已使用,请重新发起 OAuth"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + email = strings.ToLower(strings.TrimSpace(email)) + + acc, err := models.GetAccountByEmail(email) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err == nil { + acc.AuthType = models.AuthTypeOAuthMicrosoft + acc.OAuthRefreshToken = refresh + acc.SMTPHost = msSMTPHost + acc.SMTPPort = msSMTPPort + acc.IMAPHost = msIMAPHost + acc.IMAPPort = msIMAPPort + acc.UseTLS = true + if err := models.UpdateAccount(acc); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + acc.Password = "" + acc.OAuthRefreshToken = "" + c.JSON(http.StatusOK, acc) + return + } + + local := email + if at := strings.IndexByte(email, '@'); at > 0 { + local = email[:at] + } + na := &models.Account{ + Name: local, + Email: email, + Password: "", + SMTPHost: msSMTPHost, + SMTPPort: msSMTPPort, + IMAPHost: msIMAPHost, + IMAPPort: msIMAPPort, + UseTLS: true, + AuthType: models.AuthTypeOAuthMicrosoft, + OAuthRefreshToken: refresh, + } + if err := models.CreateAccount(na); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + na.Password = "" + na.OAuthRefreshToken = "" + c.JSON(http.StatusOK, na) + } +} diff --git a/mengpost-backend/main.go b/mengpost-backend/main.go index de79811..74817bf 100644 --- a/mengpost-backend/main.go +++ b/mengpost-backend/main.go @@ -1,19 +1,26 @@ -package main - -import ( - "log" - - "mengpost-backend/config" - "mengpost-backend/database" - "mengpost-backend/router" -) - -func main() { - cfg := config.Load() - database.Init(cfg.DBPath) - r := router.New(cfg) - log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token) - if err := r.Run(":" + cfg.Port); err != nil { - log.Fatal(err) - } -} +package main + +import ( + "log" + + "github.com/joho/godotenv" + + "mengpost-backend/config" + "mengpost-backend/database" + "mengpost-backend/router" + "mengpost-backend/services" +) + +func main() { + // 从工作目录加载 .env(KEY=value);缺失则忽略,仍可用系统环境变量。 + _ = godotenv.Load() + + cfg := config.Load() + services.SetAppConfig(cfg) + database.Init(cfg.DBPath) + r := router.New(cfg) + log.Printf("萌邮 MengPost backend listening on :%s (token=%s)", cfg.Port, cfg.Token) + if err := r.Run(":" + cfg.Port); err != nil { + log.Fatal(err) + } +} diff --git a/mengpost-backend/middleware/auth.go b/mengpost-backend/middleware/auth.go index d1fb5ad..ecdcbc0 100644 --- a/mengpost-backend/middleware/auth.go +++ b/mengpost-backend/middleware/auth.go @@ -1,24 +1,24 @@ -package middleware - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" -) - -// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer . -func TokenAuth(expected string) gin.HandlerFunc { - return func(c *gin.Context) { - got := c.GetHeader("X-Auth-Token") - if got == "" { - auth := c.GetHeader("Authorization") - got = strings.TrimPrefix(auth, "Bearer ") - } - if got == "" || got != expected { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - c.Next() - } -} +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// TokenAuth 校验请求头中的 X-Auth-Token 或 Authorization: Bearer . +func TokenAuth(expected string) gin.HandlerFunc { + return func(c *gin.Context) { + got := c.GetHeader("X-Auth-Token") + if got == "" { + auth := c.GetHeader("Authorization") + got = strings.TrimPrefix(auth, "Bearer ") + } + if got == "" || got != expected { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Next() + } +} diff --git a/mengpost-backend/models/account.go b/mengpost-backend/models/account.go index c03b570..32bff88 100644 --- a/mengpost-backend/models/account.go +++ b/mengpost-backend/models/account.go @@ -1,85 +1,139 @@ -package models - -import ( - "time" - - "mengpost-backend/database" -) - -// Account 代表一个邮箱账户的完整连接信息. -type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password,omitempty"` - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - IMAPHost string `json:"imap_host"` - IMAPPort int `json:"imap_port"` - UseTLS bool `json:"use_tls"` - CreatedAt time.Time `json:"created_at"` -} - -func ListAccounts() ([]Account, error) { - rows, err := database.DB.Query(`SELECT id,name,email,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts ORDER BY id DESC`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Account - for rows.Next() { - var a Account - var tls int - if err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil { - return nil, err - } - a.UseTLS = tls == 1 - out = append(out, a) - } - return out, nil -} - -func GetAccount(id int64) (*Account, error) { - row := database.DB.QueryRow(`SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,created_at FROM accounts WHERE id=?`, id) - var a Account - var tls int - if err := row.Scan(&a.ID, &a.Name, &a.Email, &a.Password, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &a.CreatedAt); err != nil { - return nil, err - } - a.UseTLS = tls == 1 - return &a, nil -} - -func CreateAccount(a *Account) error { - tls := 0 - if a.UseTLS { - tls = 1 - } - res, err := database.DB.Exec( - `INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls) VALUES(?,?,?,?,?,?,?,?)`, - a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, - ) - if err != nil { - return err - } - id, _ := res.LastInsertId() - a.ID = id - return nil -} - -func UpdateAccount(a *Account) error { - tls := 0 - if a.UseTLS { - tls = 1 - } - _, err := database.DB.Exec( - `UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=? WHERE id=?`, - a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.ID, - ) - return err -} - -func DeleteAccount(id int64) error { - _, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id) - return err -} +package models + +import ( + "database/sql" + "time" + + "mengpost-backend/database" +) + +// Account 邮箱账户(basic 为密码;oauth_microsoft 使用刷新令牌). +type Account struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + UseTLS bool `json:"use_tls"` + AuthType string `json:"auth_type"` + OAuthRefreshToken string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +func readAuthType(ns sql.NullString) string { + if ns.Valid && ns.String != "" { + return ns.String + } + return AuthTypeBasic +} + +func ListAccounts() ([]Account, error) { + rows, err := database.DB.Query(` + SELECT id,name,email,smtp_host,smtp_port,imap_host,imap_port,use_tls, + IFNULL(auth_type,'basic'), created_at + FROM accounts ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Account + for rows.Next() { + var a Account + var tls int + var authType sql.NullString + if err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &authType, &a.CreatedAt); err != nil { + return nil, err + } + a.UseTLS = tls == 1 + a.AuthType = readAuthType(authType) + out = append(out, a) + } + return out, nil +} + +func scanFullAccount(row *sql.Row) (*Account, error) { + var a Account + var tls int + var authType, oauthRefresh sql.NullString + err := row.Scan(&a.ID, &a.Name, &a.Email, &a.Password, &a.SMTPHost, &a.SMTPPort, &a.IMAPHost, &a.IMAPPort, &tls, &authType, &oauthRefresh, &a.CreatedAt) + if err != nil { + return nil, err + } + a.UseTLS = tls == 1 + a.AuthType = readAuthType(authType) + if oauthRefresh.Valid { + a.OAuthRefreshToken = oauthRefresh.String + } + return &a, nil +} + +func GetAccount(id int64) (*Account, error) { + row := database.DB.QueryRow(` + SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls, + IFNULL(auth_type,'basic'), oauth_refresh_token, created_at + FROM accounts WHERE id=?`, id) + return scanFullAccount(row) +} + +func GetAccountByEmail(email string) (*Account, error) { + row := database.DB.QueryRow(` + SELECT id,name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls, + IFNULL(auth_type,'basic'), oauth_refresh_token, created_at + FROM accounts WHERE email=?`, email) + return scanFullAccount(row) +} + +func CreateAccount(a *Account) error { + if a.AuthType == "" { + a.AuthType = AuthTypeBasic + } + tls := 0 + if a.UseTLS { + tls = 1 + } + var oauth any + if a.OAuthRefreshToken != "" { + oauth = a.OAuthRefreshToken + } + res, err := database.DB.Exec( + `INSERT INTO accounts(name,email,password,smtp_host,smtp_port,imap_host,imap_port,use_tls,auth_type,oauth_refresh_token) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType, oauth, + ) + if err != nil { + return err + } + id, _ := res.LastInsertId() + a.ID = id + return nil +} + +func UpdateAccount(a *Account) error { + tls := 0 + if a.UseTLS { + tls = 1 + } + if a.AuthType == "" { + a.AuthType = AuthTypeBasic + } + _, err := database.DB.Exec( + `UPDATE accounts SET name=?,email=?,password=COALESCE(NULLIF(?, ''), password),smtp_host=?,smtp_port=?,imap_host=?,imap_port=?,use_tls=?,auth_type=?, + oauth_refresh_token=COALESCE(NULLIF(?, ''), oauth_refresh_token) WHERE id=?`, + a.Name, a.Email, a.Password, a.SMTPHost, a.SMTPPort, a.IMAPHost, a.IMAPPort, tls, a.AuthType, + a.OAuthRefreshToken, a.ID, + ) + return err +} + +func UpdateAccountOAuthRefresh(id int64, refresh string) error { + _, err := database.DB.Exec(`UPDATE accounts SET oauth_refresh_token=? WHERE id=?`, refresh, id) + return err +} + +func DeleteAccount(id int64) error { + _, err := database.DB.Exec(`DELETE FROM accounts WHERE id=?`, id) + return err +} diff --git a/mengpost-backend/models/auth.go b/mengpost-backend/models/auth.go new file mode 100644 index 0000000..940bd8d --- /dev/null +++ b/mengpost-backend/models/auth.go @@ -0,0 +1,6 @@ +package models + +const ( + AuthTypeBasic = "basic" + AuthTypeOAuthMicrosoft = "oauth_microsoft" +) diff --git a/mengpost-backend/models/oauth_pending.go b/mengpost-backend/models/oauth_pending.go new file mode 100644 index 0000000..de2646a --- /dev/null +++ b/mengpost-backend/models/oauth_pending.go @@ -0,0 +1,47 @@ +package models + +import ( + "database/sql" + "errors" + + "mengpost-backend/database" +) + +// ErrOAuthPendingNotFound state 不存在或尚未完成微软回调. +var ErrOAuthPendingNotFound = errors.New("oauth state 无效或已过期") + +func InsertOAuthPendingState(state string) error { + _, err := database.DB.Exec(`INSERT INTO oauth_microsoft_pending(state) VALUES(?)`, state) + return err +} + +func UpdateOAuthPendingTokens(state, refresh, email string) error { + res, err := database.DB.Exec( + `UPDATE oauth_microsoft_pending SET refresh_token=?, email=? WHERE state=?`, + refresh, email, state, + ) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrOAuthPendingNotFound + } + return nil +} + +// TakeOAuthPending 取出并删除 pending,用于完成账户创建. +func TakeOAuthPending(state string) (refresh, email string, err error) { + row := database.DB.QueryRow( + `SELECT refresh_token, email FROM oauth_microsoft_pending WHERE state=? AND refresh_token IS NOT NULL AND email IS NOT NULL`, + state, + ) + if err := row.Scan(&refresh, &email); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrOAuthPendingNotFound + } + return "", "", err + } + _, _ = database.DB.Exec(`DELETE FROM oauth_microsoft_pending WHERE state=?`, state) + return refresh, email, nil +} diff --git a/mengpost-backend/models/sent_log.go b/mengpost-backend/models/sent_log.go index 0d12150..f329bc3 100644 --- a/mengpost-backend/models/sent_log.go +++ b/mengpost-backend/models/sent_log.go @@ -1,50 +1,50 @@ -package models - -import ( - "time" - - "mengpost-backend/database" -) - -// SentLog 记录一次外发邮件的结果. -type SentLog struct { - ID int64 `json:"id"` - AccountID int64 `json:"account_id"` - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` - Status string `json:"status"` - Error string `json:"error"` - CreatedAt time.Time `json:"created_at"` -} - -func AddSentLog(l *SentLog) error { - _, err := database.DB.Exec( - `INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`, - l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error, - ) - return err -} - -func ListSentLogs(accountID int64, limit int) ([]SentLog, error) { - if limit <= 0 { - limit = 50 - } - rows, err := database.DB.Query( - `SELECT id,account_id,to_addr,subject,body,status,error,created_at FROM sent_logs WHERE account_id=? ORDER BY id DESC LIMIT ?`, - accountID, limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var out []SentLog - for rows.Next() { - var l SentLog - if err := rows.Scan(&l.ID, &l.AccountID, &l.To, &l.Subject, &l.Body, &l.Status, &l.Error, &l.CreatedAt); err != nil { - return nil, err - } - out = append(out, l) - } - return out, nil -} +package models + +import ( + "time" + + "mengpost-backend/database" +) + +// SentLog 记录一次外发邮件的结果. +type SentLog struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt time.Time `json:"created_at"` +} + +func AddSentLog(l *SentLog) error { + _, err := database.DB.Exec( + `INSERT INTO sent_logs(account_id,to_addr,subject,body,status,error) VALUES(?,?,?,?,?,?)`, + l.AccountID, l.To, l.Subject, l.Body, l.Status, l.Error, + ) + return err +} + +func ListSentLogs(accountID int64, limit int) ([]SentLog, error) { + if limit <= 0 { + limit = 50 + } + rows, err := database.DB.Query( + `SELECT id,account_id,to_addr,subject,body,status,error,created_at FROM sent_logs WHERE account_id=? ORDER BY id DESC LIMIT ?`, + accountID, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SentLog + for rows.Next() { + var l SentLog + if err := rows.Scan(&l.ID, &l.AccountID, &l.To, &l.Subject, &l.Body, &l.Status, &l.Error, &l.CreatedAt); err != nil { + return nil, err + } + out = append(out, l) + } + return out, nil +} diff --git a/mengpost-backend/router/router.go b/mengpost-backend/router/router.go index 1cf330e..fd3a625 100644 --- a/mengpost-backend/router/router.go +++ b/mengpost-backend/router/router.go @@ -1,45 +1,76 @@ -package router - -import ( - "time" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - - "mengpost-backend/config" - "mengpost-backend/handlers" - "mengpost-backend/middleware" -) - -// New 构建所有路由. -func New(cfg *config.Config) *gin.Engine { - r := gin.Default() - - r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"}, - ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: false, - MaxAge: 12 * time.Hour, - })) - - r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) - r.POST("/api/auth/verify", handlers.VerifyToken(cfg)) - - auth := r.Group("/api") - auth.Use(middleware.TokenAuth(cfg.Token)) - { - auth.GET("/accounts", handlers.ListAccounts) - auth.POST("/accounts", handlers.CreateAccount) - auth.PUT("/accounts/:id", handlers.UpdateAccount) - auth.DELETE("/accounts/:id", handlers.DeleteAccount) - - auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes) - auth.GET("/accounts/:id/messages", handlers.ListMessages) - auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage) - auth.POST("/accounts/:id/send", handlers.SendMessage) - auth.GET("/accounts/:id/sent", handlers.ListSentLogs) - } - return r -} +package router + +import ( + "strings" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "mengpost-backend/config" + "mengpost-backend/handlers" + "mengpost-backend/middleware" +) + +func corsConfig(cfg *config.Config) cors.Config { + c := cors.Config{ + AllowMethods: []string{ + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", + }, + AllowHeaders: []string{ + "Origin", "Content-Length", "Content-Type", "Accept", + "Authorization", "X-Auth-Token", "X-Requested-With", + }, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + } + raw := strings.TrimSpace(cfg.CORSAllowOrigins) + if raw == "" || raw == "*" { + // 生产/跨域穿透:允许任意 Origin(与 AllowCredentials=false 搭配,适合前后端分域名) + c.AllowOriginFunc = func(origin string) bool { return true } + return c + } + parts := strings.Split(raw, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + c.AllowOrigins = append(c.AllowOrigins, p) + } + } + if len(c.AllowOrigins) == 0 { + c.AllowOriginFunc = func(origin string) bool { return true } + } + return c +} + +// New 构建所有路由. +func New(cfg *config.Config) *gin.Engine { + r := gin.Default() + + r.Use(cors.New(corsConfig(cfg))) + + r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + r.POST("/api/auth/verify", handlers.VerifyToken(cfg)) + r.GET("/api/oauth/microsoft/enabled", handlers.MicrosoftOAuthEnabled(cfg)) + r.GET("/api/oauth/microsoft/callback", handlers.MicrosoftOAuthCallback(cfg)) + + auth := r.Group("/api") + auth.Use(middleware.TokenAuth(cfg.Token)) + { + auth.GET("/oauth/microsoft/start", handlers.MicrosoftOAuthStart(cfg)) + auth.POST("/oauth/microsoft/finish", handlers.MicrosoftOAuthFinish()) + + auth.GET("/accounts", handlers.ListAccounts) + auth.POST("/accounts", handlers.CreateAccount) + auth.PUT("/accounts/:id", handlers.UpdateAccount) + auth.DELETE("/accounts/:id", handlers.DeleteAccount) + + auth.GET("/accounts/:id/mailboxes", handlers.ListMailboxes) + auth.GET("/accounts/:id/messages", handlers.ListMessages) + auth.GET("/accounts/:id/messages/:uid", handlers.GetMessage) + auth.POST("/accounts/:id/send", handlers.SendMessage) + auth.GET("/accounts/:id/sent", handlers.ListSentLogs) + } + return r +} diff --git a/mengpost-backend/services/appcfg.go b/mengpost-backend/services/appcfg.go new file mode 100644 index 0000000..05cc8e3 --- /dev/null +++ b/mengpost-backend/services/appcfg.go @@ -0,0 +1,10 @@ +package services + +import "mengpost-backend/config" + +// AppCfg 供 IMAP/SMTP 刷新微软令牌等使用(main 启动时注入). +var AppCfg *config.Config + +func SetAppConfig(c *config.Config) { + AppCfg = c +} diff --git a/mengpost-backend/services/imap.go b/mengpost-backend/services/imap.go index db128c7..d72d5e5 100644 --- a/mengpost-backend/services/imap.go +++ b/mengpost-backend/services/imap.go @@ -1,225 +1,258 @@ -package services - -import ( - "crypto/tls" - "fmt" - "io" - "strings" - "time" - - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-message/mail" - imapid "github.com/emersion/go-imap-id" - - "mengpost-backend/models" -) - -// MailSummary 是列表页展示的邮件摘要. -type MailSummary struct { - UID uint32 `json:"uid"` - Subject string `json:"subject"` - From string `json:"from"` - Date time.Time `json:"date"` - Seen bool `json:"seen"` - Mailbox string `json:"mailbox"` -} - -// MailDetail 邮件详情, 包含正文. -type MailDetail struct { - MailSummary - To []string `json:"to"` - Text string `json:"text"` - HTML string `json:"html"` -} - -func dial(acc *models.Account) (*client.Client, error) { - addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort) - if acc.UseTLS { - return client.DialTLS(addr, &tls.Config{ServerName: acc.IMAPHost}) - } - return client.Dial(addr) -} - -// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID(网易 163/126/yeah 等要求,否则报 Unsafe Login)。 -func imapSendClientID(c *client.Client) error { - ic := imapid.NewClient(c) - _, err := ic.ID(imapid.ID{ - imapid.FieldName: "MengPost", - imapid.FieldVersion: "1.0", - imapid.FieldVendor: "MengPost", - imapid.FieldOS: "Go", - }) - return err -} - -// ListMailboxes 列出账户下可用的邮件夹名称. -func ListMailboxes(acc *models.Account) ([]string, error) { - c, err := dial(acc) - if err != nil { - return nil, err - } - defer c.Logout() - if err := c.Login(acc.Email, acc.Password); err != nil { - return nil, err - } - if err := imapSendClientID(c); err != nil { - return nil, err - } - ch := make(chan *imap.MailboxInfo, 20) - done := make(chan error, 1) - go func() { done <- c.List("", "*", ch) }() - var out []string - for m := range ch { - out = append(out, m.Name) - } - if err := <-done; err != nil { - return nil, err - } - return out, nil -} - -// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要. -func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) { - if mailbox == "" { - mailbox = "INBOX" - } - if limit <= 0 { - limit = 30 - } - c, err := dial(acc) - if err != nil { - return nil, err - } - defer c.Logout() - if err := c.Login(acc.Email, acc.Password); err != nil { - return nil, err - } - if err := imapSendClientID(c); err != nil { - return nil, err - } - mbox, err := c.Select(mailbox, true) - if err != nil { - return nil, err - } - if mbox.Messages == 0 { - return []MailSummary{}, nil - } - from := uint32(1) - if mbox.Messages > uint32(limit) { - from = mbox.Messages - uint32(limit) + 1 - } - seq := new(imap.SeqSet) - seq.AddRange(from, mbox.Messages) - items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} - ch := make(chan *imap.Message, limit) - done := make(chan error, 1) - go func() { done <- c.Fetch(seq, items, ch) }() - var out []MailSummary - for msg := range ch { - sum := MailSummary{UID: msg.Uid, Mailbox: mailbox} - if msg.Envelope != nil { - sum.Subject = msg.Envelope.Subject - sum.Date = msg.Envelope.Date - if len(msg.Envelope.From) > 0 { - a := msg.Envelope.From[0] - sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) - } - } - for _, f := range msg.Flags { - if f == imap.SeenFlag { - sum.Seen = true - } - } - out = append(out, sum) - } - if err := <-done; err != nil { - return nil, err - } - // 最新的在前 - for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { - out[i], out[j] = out[j], out[i] - } - return out, nil -} - -// FetchMessage 根据 UID 获取单封邮件完整内容. -func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) { - if mailbox == "" { - mailbox = "INBOX" - } - c, err := dial(acc) - if err != nil { - return nil, err - } - defer c.Logout() - if err := c.Login(acc.Email, acc.Password); err != nil { - return nil, err - } - if err := imapSendClientID(c); err != nil { - return nil, err - } - if _, err := c.Select(mailbox, false); err != nil { - return nil, err - } - seq := new(imap.SeqSet) - seq.AddNum(uid) - section := &imap.BodySectionName{} - items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()} - ch := make(chan *imap.Message, 1) - done := make(chan error, 1) - go func() { done <- c.UidFetch(seq, items, ch) }() - msg := <-ch - if err := <-done; err != nil { - return nil, err - } - if msg == nil { - return nil, fmt.Errorf("message not found") - } - d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}} - if msg.Envelope != nil { - d.Subject = msg.Envelope.Subject - d.Date = msg.Envelope.Date - if len(msg.Envelope.From) > 0 { - a := msg.Envelope.From[0] - d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) - } - for _, a := range msg.Envelope.To { - d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName)) - } - } - for _, f := range msg.Flags { - if f == imap.SeenFlag { - d.Seen = true - } - } - r := msg.GetBody(section) - if r == nil { - return d, nil - } - mr, err := mail.CreateReader(r) - if err != nil { - b, _ := io.ReadAll(r) - d.Text = string(b) - return d, nil - } - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - break - } - switch h := p.Header.(type) { - case *mail.InlineHeader: - ct, _, _ := h.ContentType() - b, _ := io.ReadAll(p.Body) - if strings.Contains(ct, "html") { - d.HTML = string(b) - } else { - d.Text = string(b) - } - } - } - return d, nil -} +package services + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-message/mail" + imapid "github.com/emersion/go-imap-id" + + "mengpost-backend/models" +) + +// MailSummary 是列表页展示的邮件摘要. +type MailSummary struct { + UID uint32 `json:"uid"` + Subject string `json:"subject"` + From string `json:"from"` + Date time.Time `json:"date"` + Seen bool `json:"seen"` + Mailbox string `json:"mailbox"` +} + +// MailDetail 邮件详情, 包含正文. +type MailDetail struct { + MailSummary + To []string `json:"to"` + Text string `json:"text"` + HTML string `json:"html"` +} + +func dial(acc *models.Account) (*client.Client, error) { + addr := fmt.Sprintf("%s:%d", acc.IMAPHost, acc.IMAPPort) + if acc.UseTLS { + cfg := IMAPTLSConfig(acc) + if cfg == nil { + cfg = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: acc.IMAPHost} + } + return client.DialTLS(addr, cfg) + } + return client.Dial(addr) +} + +func imapAuthenticate(c *client.Client, acc *models.Account) error { + if acc.AuthType == models.AuthTypeOAuthMicrosoft { + if AppCfg == nil { + return fmt.Errorf("服务未配置微软 OAuth") + } + mt, err := RefreshMicrosoftAccess(context.Background(), AppCfg, acc.OAuthRefreshToken) + if err != nil { + return err + } + if mt.RefreshToken != "" { + _ = models.UpdateAccountOAuthRefresh(acc.ID, mt.RefreshToken) + } + if err := c.Authenticate(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil { + return err + } + } else { + if err := c.Login(acc.Email, acc.Password); err != nil { + return err + } + } + // Outlook/Exchange IMAP 在 AUTH 后对 RFC2971 ID 常返回 “Command Argument Error.12”;网易等仍需 ID。 + if !imapShouldSendClientID(acc) { + return nil + } + return imapSendClientID(c) +} + +func imapShouldSendClientID(acc *models.Account) bool { + if acc.AuthType == models.AuthTypeOAuthMicrosoft { + return false + } + if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") { + return false + } + return true +} + +// imapSendClientID 在 LOGIN 之后、SELECT/LIST 等之前发送 RFC2971 ID(网易 163/126/yeah 等要求,否则报 Unsafe Login)。 +func imapSendClientID(c *client.Client) error { + ic := imapid.NewClient(c) + _, err := ic.ID(imapid.ID{ + imapid.FieldName: "MengPost", + imapid.FieldVersion: "1.0", + imapid.FieldVendor: "MengPost", + imapid.FieldOS: "Go", + }) + return err +} + +// ListMailboxes 列出账户下可用的邮件夹名称. +func ListMailboxes(acc *models.Account) ([]string, error) { + c, err := dial(acc) + if err != nil { + return nil, err + } + defer c.Logout() + if err := imapAuthenticate(c, acc); err != nil { + return nil, err + } + ch := make(chan *imap.MailboxInfo, 20) + done := make(chan error, 1) + go func() { done <- c.List("", "*", ch) }() + var out []string + for m := range ch { + out = append(out, m.Name) + } + if err := <-done; err != nil { + return nil, err + } + return out, nil +} + +// FetchMessages 获取指定邮件夹中最近 n 封邮件摘要. +func FetchMessages(acc *models.Account, mailbox string, limit int) ([]MailSummary, error) { + if mailbox == "" { + mailbox = "INBOX" + } + if limit <= 0 { + limit = 30 + } + c, err := dial(acc) + if err != nil { + return nil, err + } + defer c.Logout() + if err := imapAuthenticate(c, acc); err != nil { + return nil, err + } + mbox, err := c.Select(mailbox, true) + if err != nil { + return nil, err + } + if mbox.Messages == 0 { + return []MailSummary{}, nil + } + from := uint32(1) + if mbox.Messages > uint32(limit) { + from = mbox.Messages - uint32(limit) + 1 + } + seq := new(imap.SeqSet) + seq.AddRange(from, mbox.Messages) + items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} + ch := make(chan *imap.Message, limit) + done := make(chan error, 1) + go func() { done <- c.Fetch(seq, items, ch) }() + var out []MailSummary + for msg := range ch { + sum := MailSummary{UID: msg.Uid, Mailbox: mailbox} + if msg.Envelope != nil { + sum.Subject = msg.Envelope.Subject + sum.Date = msg.Envelope.Date + if len(msg.Envelope.From) > 0 { + a := msg.Envelope.From[0] + sum.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) + } + } + for _, f := range msg.Flags { + if f == imap.SeenFlag { + sum.Seen = true + } + } + out = append(out, sum) + } + if err := <-done; err != nil { + return nil, err + } + // 最新的在前 + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out, nil +} + +// FetchMessage 根据 UID 获取单封邮件完整内容. +func FetchMessage(acc *models.Account, mailbox string, uid uint32) (*MailDetail, error) { + if mailbox == "" { + mailbox = "INBOX" + } + c, err := dial(acc) + if err != nil { + return nil, err + } + defer c.Logout() + if err := imapAuthenticate(c, acc); err != nil { + return nil, err + } + if _, err := c.Select(mailbox, false); err != nil { + return nil, err + } + seq := new(imap.SeqSet) + seq.AddNum(uid) + section := &imap.BodySectionName{} + items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()} + ch := make(chan *imap.Message, 1) + done := make(chan error, 1) + go func() { done <- c.UidFetch(seq, items, ch) }() + msg := <-ch + if err := <-done; err != nil { + return nil, err + } + if msg == nil { + return nil, fmt.Errorf("message not found") + } + d := &MailDetail{MailSummary: MailSummary{UID: msg.Uid, Mailbox: mailbox}} + if msg.Envelope != nil { + d.Subject = msg.Envelope.Subject + d.Date = msg.Envelope.Date + if len(msg.Envelope.From) > 0 { + a := msg.Envelope.From[0] + d.From = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) + } + for _, a := range msg.Envelope.To { + d.To = append(d.To, fmt.Sprintf("%s@%s", a.MailboxName, a.HostName)) + } + } + for _, f := range msg.Flags { + if f == imap.SeenFlag { + d.Seen = true + } + } + r := msg.GetBody(section) + if r == nil { + return d, nil + } + mr, err := mail.CreateReader(r) + if err != nil { + b, _ := io.ReadAll(r) + d.Text = string(b) + return d, nil + } + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + break + } + switch h := p.Header.(type) { + case *mail.InlineHeader: + ct, _, _ := h.ContentType() + b, _ := io.ReadAll(p.Body) + if strings.Contains(ct, "html") { + d.HTML = string(b) + } else { + d.Text = string(b) + } + } + } + return d, nil +} diff --git a/mengpost-backend/services/microsoft.go b/mengpost-backend/services/microsoft.go new file mode 100644 index 0000000..0b3e6e4 --- /dev/null +++ b/mengpost-backend/services/microsoft.go @@ -0,0 +1,66 @@ +package services + +import ( + "crypto/tls" + "fmt" + "strings" + + "mengpost-backend/models" +) + +// 微软个人邮箱常见后缀(Outlook / Hotmail / Live 等). +var microsoftConsumerSuffixes = []string{ + "@outlook.com", + "@hotmail.com", + "@live.com", + "@msn.com", +} + +// IsMicrosoftConsumerEmail 判断是否为消费者微软邮箱(非 Exchange 自建域). +func IsMicrosoftConsumerEmail(email string) bool { + e := strings.ToLower(strings.TrimSpace(email)) + for _, suf := range microsoftConsumerSuffixes { + if strings.HasSuffix(e, suf) { + return true + } + } + return false +} + +// IMAPTLSConfig 为 IMAP TLS 握手提供 ServerName(微软等场景与证书一致). +func IMAPTLSConfig(acc *models.Account) *tls.Config { + if acc == nil || !acc.UseTLS { + return nil + } + sn := acc.IMAPHost + if IsMicrosoftConsumerEmail(acc.Email) && strings.EqualFold(acc.IMAPHost, "outlook.office365.com") { + sn = "outlook.office365.com" + } + return &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: sn, + } +} + +// SMTPTLSConfig 发信使用 587 STARTTLS 时校验证书(微软 smtp.office365.com). +func SMTPTLSConfig(acc *models.Account) *tls.Config { + if acc == nil || !IsMicrosoftConsumerEmail(acc.Email) { + return nil + } + if strings.EqualFold(acc.SMTPHost, "smtp.office365.com") { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: "smtp.office365.com", + } + } + return nil +} + +// WrapMicrosoftMailErr 为微软邮箱附加常见配置说明(IMAP 开关、基础认证与 OAuth). +func WrapMicrosoftMailErr(email string, err error) error { + if err == nil || !IsMicrosoftConsumerEmail(email) { + return err + } + hint := "(微软邮箱:网页端需开启 IMAP;OAuth 账户无需应用密码。若仍失败请检查令牌是否过期、Azure 权限是否含 IMAP。)" + return fmt.Errorf("%w %s", err, hint) +} diff --git a/mengpost-backend/services/microsoft_oauth.go b/mengpost-backend/services/microsoft_oauth.go new file mode 100644 index 0000000..6a8a1d7 --- /dev/null +++ b/mengpost-backend/services/microsoft_oauth.go @@ -0,0 +1,165 @@ +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "mengpost-backend/config" +) + +var msTokenURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" +var msAuthURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" + +// MicrosoftOAuthScopes IMAP + SMTP + 刷新令牌. +var MicrosoftOAuthScopes = []string{ + "openid", + "email", + "offline_access", + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", +} + +// MicrosoftAuthCodeURL 生成跳转微软登录的 URL. +func MicrosoftAuthCodeURL(cfg *config.Config, state string) string { + q := url.Values{} + q.Set("client_id", cfg.MSClientID) + q.Set("response_type", "code") + q.Set("redirect_uri", cfg.MSRedirectURL) + q.Set("response_mode", "query") + q.Set("scope", strings.Join(MicrosoftOAuthScopes, " ")) + q.Set("state", state) + return msAuthURL + "?" + q.Encode() +} + +type msTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + Description string `json:"error_description"` +} + +// ExchangeMicrosoftCode 用授权码换令牌并解析邮箱(id_token). +func ExchangeMicrosoftCode(ctx context.Context, cfg *config.Config, code string) (email, refresh, access string, accessExpiry time.Time, err error) { + form := url.Values{} + form.Set("client_id", cfg.MSClientID) + if cfg.MSClientSecret != "" { + form.Set("client_secret", cfg.MSClientSecret) + } + form.Set("code", code) + form.Set("redirect_uri", cfg.MSRedirectURL) + form.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", "", "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", time.Time{}, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var tr msTokenResp + if err := json.Unmarshal(body, &tr); err != nil { + return "", "", "", time.Time{}, fmt.Errorf("token 响应解析失败: %w", err) + } + if tr.Error != "" { + return "", "", "", time.Time{}, fmt.Errorf("微软 token错误: %s %s", tr.Error, tr.Description) + } + if tr.RefreshToken == "" { + return "", "", "", time.Time{}, fmt.Errorf("未返回 refresh_token,请确认 scope 含 offline_access") + } + email, err = emailFromIDToken(tr.IDToken) + if err != nil || email == "" { + return "", "", "", time.Time{}, fmt.Errorf("无法从 id_token 解析邮箱: %w", err) + } + exp := time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + return strings.ToLower(strings.TrimSpace(email)), tr.RefreshToken, tr.AccessToken, exp, nil +} + +func emailFromIDToken(idt string) (string, error) { + if idt == "" { + return "", fmt.Errorf("无 id_token") + } + parts := strings.Split(idt, ".") + if len(parts) < 2 { + return "", fmt.Errorf("id_token 格式异常") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + var claims struct { + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + UPN string `json:"upn"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", err + } + if claims.Email != "" { + return claims.Email, nil + } + if claims.PreferredUsername != "" { + return claims.PreferredUsername, nil + } + if claims.UPN != "" { + return claims.UPN, nil + } + return "", fmt.Errorf("claims 中无邮箱字段") +} + +// MicrosoftAccessToken 用 refresh_token 换新 access_token(及可能的新 refresh). +type MicrosoftAccessToken struct { + AccessToken string + RefreshToken string // 若微软返回新的则替换存库 + Expiry time.Time +} + +func RefreshMicrosoftAccess(ctx context.Context, cfg *config.Config, refresh string) (*MicrosoftAccessToken, error) { + form := url.Values{} + form.Set("client_id", cfg.MSClientID) + if cfg.MSClientSecret != "" { + form.Set("client_secret", cfg.MSClientSecret) + } + form.Set("refresh_token", refresh) + form.Set("grant_type", "refresh_token") + form.Set("scope", strings.Join(MicrosoftOAuthScopes, " ")) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var tr msTokenResp + if err := json.Unmarshal(body, &tr); err != nil { + return nil, err + } + if tr.Error != "" { + return nil, fmt.Errorf("%s: %s", tr.Error, tr.Description) + } + out := &MicrosoftAccessToken{ + AccessToken: tr.AccessToken, + Expiry: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second), + } + if tr.RefreshToken != "" { + out.RefreshToken = tr.RefreshToken + } + return out, nil +} diff --git a/mengpost-backend/services/smtp.go b/mengpost-backend/services/smtp.go index 02f6312..1887703 100644 --- a/mengpost-backend/services/smtp.go +++ b/mengpost-backend/services/smtp.go @@ -1,23 +1,29 @@ -package services - -import ( - "gopkg.in/gomail.v2" - - "mengpost-backend/models" -) - -// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML. -func SendMail(acc *models.Account, to, subject, body string, html bool) error { - m := gomail.NewMessage() - m.SetHeader("From", acc.Email) - m.SetHeader("To", to) - m.SetHeader("Subject", subject) - if html { - m.SetBody("text/html", body) - } else { - m.SetBody("text/plain", body) - } - d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password) - d.SSL = acc.UseTLS && acc.SMTPPort == 465 - return d.DialAndSend(m) -} +package services + +import ( + "gopkg.in/gomail.v2" + + "mengpost-backend/models" +) + +// SendMail 通过指定账户发送邮件. body 支持纯文本或 HTML. +func SendMail(acc *models.Account, to, subject, body string, html bool) error { + if acc.AuthType == models.AuthTypeOAuthMicrosoft { + return sendMailMicrosoftOAuth(acc, to, subject, body, html) + } + m := gomail.NewMessage() + m.SetHeader("From", acc.Email) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + if html { + m.SetBody("text/html", body) + } else { + m.SetBody("text/plain", body) + } + d := gomail.NewDialer(acc.SMTPHost, acc.SMTPPort, acc.Email, acc.Password) + d.SSL = acc.UseTLS && acc.SMTPPort == 465 + if tc := SMTPTLSConfig(acc); tc != nil { + d.TLSConfig = tc + } + return d.DialAndSend(m) +} diff --git a/mengpost-backend/services/smtp_oauth.go b/mengpost-backend/services/smtp_oauth.go new file mode 100644 index 0000000..cb3c6dc --- /dev/null +++ b/mengpost-backend/services/smtp_oauth.go @@ -0,0 +1,73 @@ +package services + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + + smtpp "github.com/emersion/go-smtp" + "gopkg.in/gomail.v2" + + "mengpost-backend/models" +) + +// sendMailMicrosoftOAuth 使用 XOAUTH2 + STARTTLS 连接 smtp.office365.com:587. +func sendMailMicrosoftOAuth(acc *models.Account, to, subject, body string, html bool) error { + if AppCfg == nil { + return fmt.Errorf("服务未配置 OAuth") + } + mt, err := RefreshMicrosoftAccess(context.Background(), AppCfg, acc.OAuthRefreshToken) + if err != nil { + return err + } + if mt.RefreshToken != "" { + _ = models.UpdateAccountOAuthRefresh(acc.ID, mt.RefreshToken) + } + + m := gomail.NewMessage() + m.SetHeader("From", acc.Email) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + if html { + m.SetBody("text/html", body) + } else { + m.SetBody("text/plain", body) + } + var buf bytes.Buffer + if _, err := m.WriteTo(&buf); err != nil { + return err + } + + addr := fmt.Sprintf("%s:%d", acc.SMTPHost, acc.SMTPPort) + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: acc.SMTPHost, + } + c, err := smtpp.DialStartTLS(addr, tlsCfg) + if err != nil { + return err + } + defer c.Close() + + if err := c.Auth(NewXOAUTH2Client(acc.Email, mt.AccessToken)); err != nil { + return err + } + if err := c.Mail(acc.Email, nil); err != nil { + return err + } + if err := c.Rcpt(to, nil); err != nil { + return err + } + w, err := c.Data() + if err != nil { + return err + } + if _, err := w.Write(buf.Bytes()); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return c.Quit() +} diff --git a/mengpost-backend/services/xoauth2.go b/mengpost-backend/services/xoauth2.go new file mode 100644 index 0000000..ed0d587 --- /dev/null +++ b/mengpost-backend/services/xoauth2.go @@ -0,0 +1,35 @@ +package services + +import ( + "fmt" + "strings" + + "github.com/emersion/go-sasl" +) + +// xoauth2SASL 实现 Microsoft / Google 邮件使用的 XOAUTH2(与 RFC7628 OAUTHBEARER 不同)。 +type xoauth2SASL struct { + username string + token string +} + +// NewXOAUTH2Client 供 IMAP AUTHENTICATE / SMTP AUTH 使用,username 一般为完整邮箱。 +func NewXOAUTH2Client(username, accessToken string) sasl.Client { + return &xoauth2SASL{ + username: strings.TrimSpace(username), + token: strings.TrimSpace(accessToken), + } +} + +func (x *xoauth2SASL) Start() (mech string, ir []byte, err error) { + // https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + s := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.username, x.token) + return "XOAUTH2", []byte(s), nil +} + +func (x *xoauth2SASL) Next(challenge []byte) ([]byte, error) { + if len(challenge) == 0 { + return nil, nil + } + return nil, fmt.Errorf("xoauth2: %s", string(challenge)) +} diff --git a/mengpost-frontend/.env.production b/mengpost-frontend/.env.production new file mode 100644 index 0000000..9169e7e --- /dev/null +++ b/mengpost-frontend/.env.production @@ -0,0 +1,2 @@ +# 生产构建:npm run build 时注入。静态站点域名 post.smyhub.com,API 走 post.api.smyhub.com +VITE_API_URL=https://post.api.smyhub.com diff --git a/mengpost-frontend/index.html b/mengpost-frontend/index.html index bbfcafb..720571e 100644 --- a/mengpost-frontend/index.html +++ b/mengpost-frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - 萌邮 MengPost · 多邮箱管理 - - -
- - - + + + + + + + 萌邮 MengPost · 多邮箱管理 + + +
+ + + diff --git a/mengpost-frontend/package-lock.json b/mengpost-frontend/package-lock.json index 0145a57..0cdde8e 100644 --- a/mengpost-frontend/package-lock.json +++ b/mengpost-frontend/package-lock.json @@ -1,1758 +1,1758 @@ -{ - "name": "mengpost-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mengpost-frontend", - "version": "0.1.0", - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.6" - } - }, - "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.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "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/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "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.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "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.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "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.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", - "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.340", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", - "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", - "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/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.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "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.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "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/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "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.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "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" - } - } -} +{ + "name": "mengpost-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mengpost-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.6" + } + }, + "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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "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/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "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.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "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.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "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/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.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "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.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "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.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "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/mengpost-frontend/package.json b/mengpost-frontend/package.json index 1e0c632..f941882 100644 --- a/mengpost-frontend/package.json +++ b/mengpost-frontend/package.json @@ -1,20 +1,20 @@ -{ - "name": "mengpost-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview --port 5173" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.6" - } -} +{ + "name": "mengpost-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 5173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.6" + } +} diff --git a/mengpost-frontend/src/App.jsx b/mengpost-frontend/src/App.jsx index 100bd7a..b8977d2 100644 --- a/mengpost-frontend/src/App.jsx +++ b/mengpost-frontend/src/App.jsx @@ -1,35 +1,35 @@ -import { useSyncExternalStore } from 'react' -import { Route, Routes } from 'react-router-dom' -import { tokenStore } from './api/client.js' -import AccessGate from './components/AccessGate.jsx' -import MainLayout from './components/MainLayout.jsx' -import Topbar from './components/Topbar.jsx' -import Home from './pages/Home.jsx' -import Admin from './pages/Admin.jsx' -import Accounts from './pages/Accounts.jsx' -import Mailbox from './pages/Mailbox.jsx' -import Compose from './pages/Compose.jsx' - -function useToken() { - return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '') -} - -export default function App() { - const token = useToken() - if (!token) return - - return ( - <> - - - }> - } /> - } /> - } /> - } /> - } /> - - - - ) -} +import { useSyncExternalStore } from 'react' +import { Route, Routes } from 'react-router-dom' +import { tokenStore } from './api/client.js' +import AccessGate from './components/AccessGate.jsx' +import MainLayout from './components/MainLayout.jsx' +import Topbar from './components/Topbar.jsx' +import Home from './pages/Home.jsx' +import Admin from './pages/Admin.jsx' +import Accounts from './pages/Accounts.jsx' +import Mailbox from './pages/Mailbox.jsx' +import Compose from './pages/Compose.jsx' + +function useToken() { + return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '') +} + +export default function App() { + const token = useToken() + if (!token) return + + return ( + <> + + + }> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/mengpost-frontend/src/api/client.js b/mengpost-frontend/src/api/client.js index cecf287..abd108d 100644 --- a/mengpost-frontend/src/api/client.js +++ b/mengpost-frontend/src/api/client.js @@ -1,109 +1,111 @@ -const TOKEN_KEY = 'mengpost_token' -const EVT = 'mengpost-token' - -/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */ -const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') - -export function apiUrl(path) { - if (!path.startsWith('/')) path = '/' + path - return base ? base + path : path -} - -export const tokenStore = { - get: () => localStorage.getItem(TOKEN_KEY) || '', - set: (t) => { - localStorage.setItem(TOKEN_KEY, t) - window.dispatchEvent(new Event(EVT)) - }, - clear: () => { - localStorage.removeItem(TOKEN_KEY) - window.dispatchEvent(new Event(EVT)) - }, - subscribe: (fn) => { - window.addEventListener(EVT, fn) - return () => window.removeEventListener(EVT, fn) - }, -} - -function parseJsonSafe(txt) { - if (!txt || !txt.trim()) return null - try { - return JSON.parse(txt) - } catch { - return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt } - } -} - -async function request(method, path, body) { - const headers = { 'Content-Type': 'application/json' } - const t = tokenStore.get() - if (t) headers['X-Auth-Token'] = t - let res - try { - res = await fetch(apiUrl(path), { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - } catch (e) { - throw new Error( - e.message?.includes('Failed to fetch') - ? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL' - : e.message, - ) - } - const txt = await res.text() - const data = parseJsonSafe(txt) - if (!res.ok) { - const msg = (data && data.error) || res.statusText || '请求失败' - throw new Error(msg) - } - return data -} - -/** 列表接口在空 body / SW 缓存异常时可能得到 null,统一成数组 */ -function asArray(v) { - return Array.isArray(v) ? v : [] -} - -/** 校验 token 时不带 X-Auth-Token,避免旧 token 干扰 */ -export async function verifyToken(token) { - let res - try { - res = await fetch(apiUrl('/api/auth/verify'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }) - } catch (e) { - throw new Error( - e.message?.includes('Failed to fetch') - ? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL' - : e.message, - ) - } - const txt = await res.text() - const data = parseJsonSafe(txt) - if (!res.ok) { - if (data && data.ok === false) return { ok: false } - throw new Error((data && data.error) || res.statusText || '验证失败') - } - return data -} - -export const api = { - listAccounts: () => request('GET', '/api/accounts').then(asArray), - createAccount: (a) => request('POST', '/api/accounts', a), - updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a), - deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`), - listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray), - listMessages: (id, mailbox = 'INBOX', limit = 30) => - request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then( - asArray, - ), - getMessage: (id, uid, mailbox = 'INBOX') => - request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`), - send: (id, body) => request('POST', `/api/accounts/${id}/send`, body), - listSent: (id, limit = 50) => - request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray), -} +const TOKEN_KEY = 'mengpost_token' +const EVT = 'mengpost-token' + +/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */ +const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') + +export function apiUrl(path) { + if (!path.startsWith('/')) path = '/' + path + return base ? base + path : path +} + +export const tokenStore = { + get: () => localStorage.getItem(TOKEN_KEY) || '', + set: (t) => { + localStorage.setItem(TOKEN_KEY, t) + window.dispatchEvent(new Event(EVT)) + }, + clear: () => { + localStorage.removeItem(TOKEN_KEY) + window.dispatchEvent(new Event(EVT)) + }, + subscribe: (fn) => { + window.addEventListener(EVT, fn) + return () => window.removeEventListener(EVT, fn) + }, +} + +function parseJsonSafe(txt) { + if (!txt || !txt.trim()) return null + try { + return JSON.parse(txt) + } catch { + return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt } + } +} + +async function request(method, path, body) { + const headers = { 'Content-Type': 'application/json' } + const t = tokenStore.get() + if (t) headers['X-Auth-Token'] = t + let res + try { + res = await fetch(apiUrl(path), { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + } catch (e) { + throw new Error( + e.message?.includes('Failed to fetch') + ? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL' + : e.message, + ) + } + const txt = await res.text() + const data = parseJsonSafe(txt) + if (!res.ok) { + const msg = (data && data.error) || res.statusText || '请求失败' + throw new Error(msg) + } + return data +} + +/** 列表接口在空 body / SW 缓存异常时可能得到 null,统一成数组 */ +function asArray(v) { + return Array.isArray(v) ? v : [] +} + +/** 校验 token 时不带 X-Auth-Token,避免旧 token 干扰 */ +export async function verifyToken(token) { + let res + try { + res = await fetch(apiUrl('/api/auth/verify'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + } catch (e) { + throw new Error( + e.message?.includes('Failed to fetch') + ? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL' + : e.message, + ) + } + const txt = await res.text() + const data = parseJsonSafe(txt) + if (!res.ok) { + if (data && data.ok === false) return { ok: false } + throw new Error((data && data.error) || res.statusText || '验证失败') + } + return data +} + +export const api = { + listAccounts: () => request('GET', '/api/accounts').then(asArray), + createAccount: (a) => request('POST', '/api/accounts', a), + updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a), + deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`), + listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray), + listMessages: (id, mailbox = 'INBOX', limit = 30) => + request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then( + asArray, + ), + getMessage: (id, uid, mailbox = 'INBOX') => + request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`), + send: (id, body) => request('POST', `/api/accounts/${id}/send`, body), + listSent: (id, limit = 50) => + request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray), + microsoftOAuthStart: () => request('GET', '/api/oauth/microsoft/start'), + microsoftOAuthFinish: (state) => request('POST', '/api/oauth/microsoft/finish', { state }), +} diff --git a/mengpost-frontend/src/components/AccessGate.jsx b/mengpost-frontend/src/components/AccessGate.jsx index a3bb80d..becf865 100644 --- a/mengpost-frontend/src/components/AccessGate.jsx +++ b/mengpost-frontend/src/components/AccessGate.jsx @@ -1,64 +1,64 @@ -import { useEffect, useRef, useState } from 'react' -import { verifyToken, tokenStore } from '../api/client.js' - -/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */ -export default function AccessGate() { - const [key, setKey] = useState('') - const [err, setErr] = useState('') - const [loading, setLoading] = useState(false) - const inputRef = useRef(null) - - useEffect(() => { - inputRef.current?.focus() - }, []) - - const submit = async () => { - const v = key.trim() - if (!v) return - setLoading(true) - setErr('') - try { - const r = await verifyToken(v) - if (r && r.ok) { - tokenStore.set(v) - } else { - setErr('密钥不正确') - } - } catch (e) { - setErr(e.message || '验证失败') - } finally { - setLoading(false) - } - } - - return ( -
-
-
- -

萌邮 MengPost

-
-

- 请输入访问密钥(与后端 MP_TOKEN 一致) -

- setKey(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && submit()} - /> - {err && ( -
{err}
- )} -
- -
-
-
- ) -} +import { useEffect, useRef, useState } from 'react' +import { verifyToken, tokenStore } from '../api/client.js' + +/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */ +export default function AccessGate() { + const [key, setKey] = useState('') + const [err, setErr] = useState('') + const [loading, setLoading] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const submit = async () => { + const v = key.trim() + if (!v) return + setLoading(true) + setErr('') + try { + const r = await verifyToken(v) + if (r && r.ok) { + tokenStore.set(v) + } else { + setErr('密钥不正确') + } + } catch (e) { + setErr(e.message || '验证失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ +

萌邮 MengPost

+
+

+ 请输入访问密钥(与后端 MP_TOKEN 一致) +

+ setKey(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {err && ( +
{err}
+ )} +
+ +
+
+
+ ) +} diff --git a/mengpost-frontend/src/components/MainLayout.jsx b/mengpost-frontend/src/components/MainLayout.jsx index 3b480b2..c53f87d 100644 --- a/mengpost-frontend/src/components/MainLayout.jsx +++ b/mengpost-frontend/src/components/MainLayout.jsx @@ -1,99 +1,184 @@ -import { useEffect, useMemo, useState } from 'react' -import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' -import { api } from '../api/client.js' - -function useMailboxAccountId() { - const loc = useLocation() - return useMemo(() => { - const m = loc.pathname.match(/\/admin\/mailbox\/(\d+)/) - return m ? Number(m[1]) : null - }, [loc.pathname]) -} - -function accountActive(location, id) { - return ( - location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}` - ) -} - -function SidebarMailboxLink({ accountId, name }) { - const location = useLocation() - const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX' - const active = - location.pathname === `/admin/mailbox/${accountId}` && current === name - return ( - - {name} - - ) -} - -export default function MainLayout() { - const location = useLocation() - const [accounts, setAccounts] = useState([]) - const [boxes, setBoxes] = useState([]) - const accountId = useMailboxAccountId() - - useEffect(() => { - api - .listAccounts() - .then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) - .catch(() => setAccounts([])) - }, [location.pathname]) - - useEffect(() => { - if (!accountId) { - setBoxes([]) - return - } - api - .listMailboxes(accountId) - .then((b) => setBoxes(Array.isArray(b) ? b : [])) - .catch(() => setBoxes([])) - }, [accountId]) - - return ( -
- -
- -
-
- ) -} +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' +import { api } from '../api/client.js' + +function emailDomain(email) { + const s = (email || '').trim().toLowerCase() + const at = s.lastIndexOf('@') + if (at < 0 || at === s.length - 1) return '其他' + return s.slice(at + 1) +} + +function groupAccountsByDomain(accounts) { + const map = new Map() + for (const a of accounts) { + const d = emailDomain(a.email) + if (!map.has(d)) map.set(d, []) + map.get(d).push(a) + } + const groups = Array.from(map.entries()).map(([domain, items]) => ({ + domain, + items: items.slice().sort((x, y) => (x.email || '').localeCompare(y.email || '', 'zh-CN')), + })) + groups.sort((a, b) => a.domain.localeCompare(b.domain, 'zh-CN')) + return groups +} + +function useMailboxAccountId() { + const loc = useLocation() + return useMemo(() => { + const m = loc.pathname.match(/\/admin\/(?:mailbox|compose)\/(\d+)/) + return m ? Number(m[1]) : null + }, [loc.pathname]) +} + +function accountActive(location, id) { + return ( + location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}` + ) +} + +function SidebarMailboxLink({ accountId, name }) { + const location = useLocation() + const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX' + const active = + location.pathname === `/admin/mailbox/${accountId}` && current === name + return ( + + {name} + + ) +} + +export default function MainLayout() { + const location = useLocation() + const [accounts, setAccounts] = useState([]) + const [boxes, setBoxes] = useState([]) + const [domainCollapsed, setDomainCollapsed] = useState({}) + const accountId = useMailboxAccountId() + + const groupedAccounts = useMemo(() => groupAccountsByDomain(accounts), [accounts]) + + const expandAllDomains = useCallback(() => setDomainCollapsed({}), []) + const collapseAllDomains = useCallback(() => { + const next = {} + for (const g of groupedAccounts) next[g.domain] = true + setDomainCollapsed(next) + }, [groupedAccounts]) + + const toggleDomain = useCallback((domain) => { + setDomainCollapsed((c) => ({ ...c, [domain]: !c[domain] })) + }, []) + + useEffect(() => { + api + .listAccounts() + .then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) + .catch(() => setAccounts([])) + }, [location.pathname]) + + // 当前选中的账户所在分组自动展开 + useEffect(() => { + const active = accounts.find((a) => accountActive(location, a.id)) + if (!active) return + const d = emailDomain(active.email) + setDomainCollapsed((c) => { + if (!c[d]) return c + const next = { ...c } + delete next[d] + return next + }) + }, [location.pathname, accounts]) + + useEffect(() => { + if (!accountId) { + setBoxes([]) + return + } + api + .listMailboxes(accountId) + .then((b) => setBoxes(Array.isArray(b) ? b : [])) + .catch(() => setBoxes([])) + }, [accountId]) + + return ( +
+ +
+ +
+ {accountId != null && ( + + )} +
+ ) +} diff --git a/mengpost-frontend/src/components/Topbar.jsx b/mengpost-frontend/src/components/Topbar.jsx index cd3e1fc..6c9c47c 100644 --- a/mengpost-frontend/src/components/Topbar.jsx +++ b/mengpost-frontend/src/components/Topbar.jsx @@ -1,29 +1,29 @@ -import { NavLink, useNavigate } from 'react-router-dom' -import { tokenStore } from '../api/client.js' - -export default function Topbar() { - const nav = useNavigate() - - const logout = () => { - tokenStore.clear() - nav('/', { replace: true }) - } - - return ( -
- - - 萌邮 MengPost - - -
- ) -} +import { NavLink, useNavigate } from 'react-router-dom' +import { tokenStore } from '../api/client.js' + +export default function Topbar() { + const nav = useNavigate() + + const logout = () => { + tokenStore.clear() + nav('/', { replace: true }) + } + + return ( +
+ + + 萌邮 MengPost + + +
+ ) +} diff --git a/mengpost-frontend/src/main.jsx b/mengpost-frontend/src/main.jsx index 6b81342..861af5c 100644 --- a/mengpost-frontend/src/main.jsx +++ b/mengpost-frontend/src/main.jsx @@ -1,13 +1,13 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import App from './App.jsx' -import './styles/global.css' - -ReactDOM.createRoot(document.getElementById('root')).render( - - - - - , -) +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App.jsx' +import './styles/global.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/mengpost-frontend/src/pages/Accounts.jsx b/mengpost-frontend/src/pages/Accounts.jsx index f026d66..5e61c16 100644 --- a/mengpost-frontend/src/pages/Accounts.jsx +++ b/mengpost-frontend/src/pages/Accounts.jsx @@ -1,225 +1,311 @@ -import { useEffect, useState, useCallback } from 'react' -import { api } from '../api/client.js' - -const empty = { - name: '', - email: '', - password: '', - smtp_host: '', - smtp_port: 465, - imap_host: '', - imap_port: 993, - use_tls: true, -} - -export default function Accounts() { - const [list, setList] = useState([]) - const [form, setForm] = useState(empty) - const [editing, setEditing] = useState(null) - const [modalOpen, setModalOpen] = useState(false) - const [err, setErr] = useState('') - const [msg, setMsg] = useState('') - - const reload = () => - api - .listAccounts() - .then((rows) => setList(Array.isArray(rows) ? rows : [])) - .catch((e) => setErr(e.message)) - - useEffect(() => { - reload() - }, []) - - const openNew = () => { - setEditing(null) - setForm(empty) - setErr('') - setMsg('') - setModalOpen(true) - } - - const openEdit = (a) => { - setEditing(a.id) - setForm({ ...a, password: '' }) - setErr('') - setMsg('') - setModalOpen(true) - } - - const closeModal = useCallback(() => { - setModalOpen(false) - setEditing(null) - setForm(empty) - setErr('') - }, []) - - useEffect(() => { - if (!modalOpen) return - const onKey = (e) => { - if (e.key === 'Escape') closeModal() - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [modalOpen, closeModal]) - - const save = async (e) => { - e.preventDefault() - setErr('') - try { - if (editing) { - await api.updateAccount(editing, form) - setMsg('已更新') - } else { - await api.createAccount(form) - setMsg('已创建') - } - closeModal() - reload() - } catch (e) { - setErr(e.message) - } - } - - const del = async (id) => { - if (!confirm('确定删除该账户?')) return - try { - await api.deleteAccount(id) - setMsg('已删除') - reload() - } catch (e) { - setErr(e.message) - } - } - - const set = (k) => (e) => { - const v = - e.target.type === 'checkbox' - ? e.target.checked - : e.target.type === 'number' - ? Number(e.target.value) - : e.target.value - setForm({ ...form, [k]: v }) - } - - return ( -
-
-

邮箱账户

- -
- - {msg &&

{msg}

} - {err && !modalOpen && ( -

{err}

- )} - -
-

已有账户

- {list.length === 0 ? ( -

暂无账户。

- ) : ( - - - - - - - - - - - - {list.map((a) => ( - - - - - - - - ))} - -
名称邮箱SMTPIMAP
{a.name}{a.email} - {a.smtp_host}:{a.smtp_port} - - {a.imap_host}:{a.imap_port} - - {' '} - -
- )} -
- - {modalOpen && ( -
e.target === e.currentTarget && closeModal()} - > -
e.stopPropagation()}> -

{editing ? `编辑账户 #${editing}` : '新增账户'}

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- {err && ( -
{err}
- )} -
- - -
-
-
-
- )} -
- ) -} +import { useEffect, useState, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api } from '../api/client.js' + +const empty = { + name: '', + email: '', + password: '', + smtp_host: '', + smtp_port: 465, + imap_host: '', + imap_port: 993, + use_tls: true, +} + +function authLabel(t) { + return t === 'oauth_microsoft' ? 'OAuth(微软)' : '密码' +} + +export default function Accounts() { + const [searchParams, setSearchParams] = useSearchParams() + const [list, setList] = useState([]) + const [form, setForm] = useState(empty) + const [editing, setEditing] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [err, setErr] = useState('') + const [msg, setMsg] = useState('') + + const reload = () => + api + .listAccounts() + .then((rows) => setList(Array.isArray(rows) ? rows : [])) + .catch((e) => setErr(e.message)) + + useEffect(() => { + reload() + }, []) + + useEffect(() => { + const ms = searchParams.get('oauth_ms') + const oe = searchParams.get('oauth_err') + if (!ms && !oe) return + + const next = new URLSearchParams(searchParams) + if (oe) { + setErr(decodeURIComponent(oe.replace(/\+/g, ' '))) + next.delete('oauth_err') + } + if (ms) { + next.delete('oauth_ms') + } + setSearchParams(next, { replace: true }) + + if (ms) { + const gate = `mp_ms_oauth_${ms}` + let skip = false + try { + if (sessionStorage.getItem(gate)) skip = true + else sessionStorage.setItem(gate, '1') + } catch { + /* ignore */ + } + if (skip) return + api + .microsoftOAuthFinish(ms) + .then(() => { + setMsg('微软账户已通过 OAuth 连接') + reload() + }) + .catch((e) => { + try { + sessionStorage.removeItem(gate) + } catch { + /* ignore */ + } + setErr(e.message) + }) + } + }, [searchParams, setSearchParams]) + + const startMicrosoftOAuth = async () => { + setErr('') + setMsg('') + try { + const { url } = await api.microsoftOAuthStart() + if (url) window.location.href = url + } catch (e) { + setErr(e.message) + } + } + + const openNew = () => { + setEditing(null) + setForm(empty) + setErr('') + setMsg('') + setModalOpen(true) + } + + const openEdit = (a) => { + setEditing(a.id) + setForm({ ...a, password: '' }) + setErr('') + setMsg('') + setModalOpen(true) + } + + const closeModal = useCallback(() => { + setModalOpen(false) + setEditing(null) + setForm(empty) + setErr('') + }, []) + + useEffect(() => { + if (!modalOpen) return + const onKey = (e) => { + if (e.key === 'Escape') closeModal() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [modalOpen, closeModal]) + + const save = async (e) => { + e.preventDefault() + setErr('') + try { + if (editing) { + await api.updateAccount(editing, form) + setMsg('已更新') + } else { + await api.createAccount(form) + setMsg('已创建') + } + closeModal() + reload() + } catch (e) { + setErr(e.message) + } + } + + const del = async (id) => { + if (!confirm('确定删除该账户?')) return + try { + await api.deleteAccount(id) + setMsg('已删除') + reload() + } catch (e) { + setErr(e.message) + } + } + + const set = (k) => (e) => { + const v = + e.target.type === 'checkbox' + ? e.target.checked + : e.target.type === 'number' + ? Number(e.target.value) + : e.target.value + setForm({ ...form, [k]: v }) + } + + return ( +
+
+

邮箱账户

+
+ + +
+
+ + {msg &&

{msg}

} + {err && !modalOpen && ( +

{err}

+ )} + +
+

已有账户

+ {list.length === 0 ? ( +

暂无账户。

+ ) : ( + + + + + + + + + + + + + {list.map((a) => ( + + + + + + + + + ))} + +
名称邮箱认证SMTPIMAP
{a.name}{a.email}{authLabel(a.auth_type)} + {a.smtp_host}:{a.smtp_port} + + {a.imap_host}:{a.imap_port} + + {' '} + +
+ )} +
+ + {modalOpen && ( +
e.target === e.currentTarget && closeModal()} + > +
e.stopPropagation()}> +

{editing ? `编辑账户 #${editing}` : '新增账户'}

+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {err && ( +
{err}
+ )} +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/mengpost-frontend/src/pages/Admin.jsx b/mengpost-frontend/src/pages/Admin.jsx index 7620b89..afa7d62 100644 --- a/mengpost-frontend/src/pages/Admin.jsx +++ b/mengpost-frontend/src/pages/Admin.jsx @@ -1,57 +1,57 @@ -import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { api } from '../api/client.js' - -export default function Admin() { - const [accounts, setAccounts] = useState([]) - const [err, setErr] = useState('') - - useEffect(() => { - api - .listAccounts() - .then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) - .catch((e) => setErr(e.message)) - }, []) - - return ( -
-

概览

-

共 {accounts.length} 个邮箱账户

- - {err && ( -
- {err} -
- )} - -
- {accounts.length === 0 ? ( -

- 暂无账户,前往 管理账户 添加。 -

- ) : ( - - - - - - - - - - {accounts.map((a) => ( - - - - - - ))} - -
名称邮箱
{a.name}{a.email} - 打开 -
- )} -
-
- ) -} +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api/client.js' + +export default function Admin() { + const [accounts, setAccounts] = useState([]) + const [err, setErr] = useState('') + + useEffect(() => { + api + .listAccounts() + .then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) + .catch((e) => setErr(e.message)) + }, []) + + return ( +
+

概览

+

共 {accounts.length} 个邮箱账户

+ + {err && ( +
+ {err} +
+ )} + +
+ {accounts.length === 0 ? ( +

+ 暂无账户,前往 管理账户 添加。 +

+ ) : ( + + + + + + + + + + {accounts.map((a) => ( + + + + + + ))} + +
名称邮箱
{a.name}{a.email} + 打开 +
+ )} +
+
+ ) +} diff --git a/mengpost-frontend/src/pages/Compose.jsx b/mengpost-frontend/src/pages/Compose.jsx index d856847..b3c1fe0 100644 --- a/mengpost-frontend/src/pages/Compose.jsx +++ b/mengpost-frontend/src/pages/Compose.jsx @@ -1,78 +1,78 @@ -import { useEffect, useState } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import { api } from '../api/client.js' - -export default function Compose() { - const { id } = useParams() - const accountId = Number(id) - const nav = useNavigate() - const [accounts, setAccounts] = useState([]) - const [form, setForm] = useState({ to: '', subject: '', body: '', html: false }) - const [sending, setSending] = useState(false) - const [err, setErr] = useState('') - const [msg, setMsg] = useState('') - - useEffect(() => { - api - .listAccounts() - .then((rows) => setAccounts(Array.isArray(rows) ? rows : [])) - .catch(() => {}) - }, []) - - const set = (k) => (e) => { - const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value - setForm({ ...form, [k]: v }) - } - - const send = async (e) => { - e.preventDefault() - setErr(''); setMsg(''); setSending(true) - try { - await api.send(accountId, form) - setMsg('发送成功') - setForm({ to: '', subject: '', body: '', html: false }) - } catch (e) { setErr(e.message) } - finally { setSending(false) } - } - - const acc = accounts.find((a) => a.id === accountId) - - return ( -
-

撰写

-

{acc ? acc.email : `#${accountId}`}

- -
-
-
- -
-
-
-
- - -
-
- -