@@ -0,0 +1,157 @@
|
||||
# YMhut Box 反馈工单服务
|
||||
|
||||
这是 `server/feedback-mailer` 下的 Go + Gin + SQLite 反馈服务。旧 PHP 入口已移除,线上入口由 Go 服务接管;`config.txt`、`storage/`、`storage/feedback.sqlite` 和历史 `.ymfb` / `.zip` 文件继续保留并兼容读取。
|
||||
|
||||
## 运行
|
||||
|
||||
在 `server/feedback-mailer` 目录直接运行:
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
监听地址以核心配置文件 `config.txt` 的 `listen` 为准:
|
||||
|
||||
```php
|
||||
'listen' => ':8080',
|
||||
```
|
||||
|
||||
服务器部署时也可以显式设置服务根目录:
|
||||
|
||||
```bash
|
||||
export YMHUT_FEEDBACK_HOME=/opt/feedback-mailer
|
||||
./feedback-mailer
|
||||
```
|
||||
|
||||
后台入口:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/admin/
|
||||
```
|
||||
|
||||
生产构建与检查:
|
||||
|
||||
```bash
|
||||
npm --prefix admin-web install
|
||||
npm --prefix admin-web run build
|
||||
go test ./...
|
||||
go build .
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
优先读取当前目录的 `config.txt`,这是线上实际配置文件,部署时不要覆盖。没有 `config.txt` 时才回退读取 `config.json`。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `listen`:服务监听地址,例如 `:8080`、`:9090`、`127.0.0.1:8080`。
|
||||
- `admin_password_hash` / `admin_password`:后台登录密码,推荐使用 bcrypt hash。
|
||||
- `client_signature_key`:旧 WinUI 客户端 HMAC 签名密钥,必须保持一致。
|
||||
- `package_encryption_key`:`.ymfb` 反馈包解密密钥,必须保持一致。
|
||||
- `storage_dir`:默认 `storage`,保留历史反馈包。
|
||||
- `database_path`:默认 `storage/feedback.sqlite`,保留旧 SQLite 数据。
|
||||
- `rate_limit`:提交、状态查询、验证码、登录和后台 API 的令牌桶限流。
|
||||
- `upload_guard`:zip 文件数量、解压后大小、单文件大小、压缩比、路径穿越校验。
|
||||
- `backup.dir` / `backup_dir`:数据库只读备份输出目录,默认 `storage/backups`。
|
||||
- `webhooks`:通用 Webhook 通知配置,secret 只用于签名,不进入 URL 或配置健康 API。
|
||||
|
||||
## 兼容接口
|
||||
|
||||
旧客户端提交接口保持不变:
|
||||
|
||||
```text
|
||||
POST /
|
||||
```
|
||||
|
||||
继续接收 `payload`、`timestamp`、`nonce`、`packageSha256`、`signature`、`package`,并保留旧错误码。
|
||||
|
||||
旧状态查询保持不变:
|
||||
|
||||
```text
|
||||
GET /?api=status&code=FB-YYYYMMDD-XXXXXX
|
||||
```
|
||||
|
||||
响应保留旧字段,并兼容增加 `statusDetail`、`category`、`priority`。
|
||||
|
||||
## 后台能力
|
||||
|
||||
- 公共首页 `/`:反馈中心正式页和无需登录的状态查询。
|
||||
- 后台 `/admin/`:工作台、工单流、状态看板、通知记录、Webhook 集成、运维、配置健康。
|
||||
- 10 秒轮询刷新:统计卡片、SVG 图表、工单列表、详情、通知和配置摘要都会重绘。
|
||||
- 工单增强:状态、分类、优先级、SLA、指派人、截止时间、标签、评论、解决说明、内部备注、公开回复。
|
||||
- 看板操作:按 `new / triaged / investigating / resolved / archived` 分栏,拖动工单即可改状态。
|
||||
- 批量处理:多选后批量归类、处理中、解决、归档。
|
||||
- 导出:`GET /api/admin/feedbacks/export?format=csv` 导出当前筛选结果,不包含包文件和密钥。
|
||||
- 审计日志:登录、工单修改、批量操作、导出、备份、Webhook 测试等都会记录。
|
||||
- 数据库备份:后台创建和下载 SQLite 备份,不提供在线删除、VACUUM 或破坏性维护。
|
||||
- Webhook:支持 `feedback.created`、`feedback.updated`、`feedback.status_changed`、`feedback.comment_created`、`mail.failed`。
|
||||
|
||||
Webhook 请求头:
|
||||
|
||||
```text
|
||||
X-YMhut-Event
|
||||
X-YMhut-Delivery
|
||||
X-YMhut-Signature
|
||||
```
|
||||
|
||||
签名格式为 `sha256=<hex hmac>`。
|
||||
|
||||
## 数据升级
|
||||
|
||||
服务启动时会对旧 `feedbacks` 表做非破坏性补列,并创建新表:
|
||||
|
||||
- `feedback_comments`
|
||||
- `feedback_tags`
|
||||
- `audit_logs`
|
||||
- `webhook_deliveries`
|
||||
- `feedback_events`
|
||||
|
||||
SQLite 默认启用 WAL,并设置 `busy_timeout`。不会删除 `storage/feedback.sqlite`,不会修改历史 `.ymfb`、`.zip` 文件。
|
||||
|
||||
## 后台 API
|
||||
|
||||
需要登录 session,写接口需要 CSRF:
|
||||
|
||||
- `GET /api/admin/overview`
|
||||
- `GET /api/admin/config`
|
||||
- `GET /api/admin/feedbacks?page=&perPage=&status=&category=&priority=&mail=&q=&assignee=&tag=&sla=&overdue=&sort=`
|
||||
- `GET /api/admin/feedbacks/export?format=csv`
|
||||
- `GET /api/admin/feedbacks/summary`
|
||||
- `GET /api/admin/feedbacks/:code`
|
||||
- `PATCH /api/admin/feedbacks/:code`
|
||||
- `PATCH /api/admin/feedbacks/bulk`
|
||||
- `POST /api/admin/feedbacks/:code/comments`
|
||||
- `GET /api/admin/mails?page=&perPage=&status=`
|
||||
- `POST /api/admin/mails/test`
|
||||
- `GET /api/admin/audit-logs?page=&perPage=&actor=&type=`
|
||||
- `GET /api/admin/webhooks/deliveries?page=&perPage=&status=`
|
||||
- `POST /api/admin/webhooks/test`
|
||||
- `POST /api/admin/backups/database`
|
||||
- `GET /api/admin/backups`
|
||||
- `GET /api/admin/backups/:name`
|
||||
|
||||
## 增强依据
|
||||
|
||||
- OWASP File Upload Cheat Sheet:上传限额、存储隔离、路径和内容校验、防 zip bomb。
|
||||
- OWASP REST Security Cheat Sheet:限流、审计、避免敏感信息出现在 URL、统一错误语义。
|
||||
- SQLite WAL 与备份文档:读写并发和在线备份思路。
|
||||
- Go `golang.org/x/time/rate`:内存令牌桶限流。
|
||||
- Gin graceful shutdown 示例:使用 `http.Server.Shutdown()` 优雅停机。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go build .
|
||||
npm --prefix admin-web run build
|
||||
go run main.go
|
||||
```
|
||||
|
||||
启动后建议检查:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/
|
||||
http://127.0.0.1:8080/admin/
|
||||
http://127.0.0.1:8080/?api=status&code=BAD
|
||||
http://127.0.0.1:8080/api/auth/captcha
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YMhut Box Feedback Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1730
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ymhut-feedback-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
:root {
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #17202a;
|
||||
background: #edf2f6;
|
||||
--panel: rgba(255, 255, 255, 0.94);
|
||||
--solid: #ffffff;
|
||||
--line: rgba(126, 146, 166, 0.28);
|
||||
--muted: #667486;
|
||||
--primary: #0f6d7a;
|
||||
--primary-2: #163d4c;
|
||||
--green: #16865a;
|
||||
--amber: #a46805;
|
||||
--red: #c24132;
|
||||
--blue: #2563a8;
|
||||
--shadow: 0 18px 42px rgba(26, 41, 58, 0.1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: linear-gradient(135deg, #f7fafc, #e5ebf1); }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 800; }
|
||||
input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: #17202a; padding: 10px 12px; outline: none; }
|
||||
textarea { resize: vertical; }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(15, 109, 122, .13); }
|
||||
pre { margin: 0; max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid var(--line); border-radius: 8px; background: #f9fbfc; padding: 11px; color: #263241; }
|
||||
|
||||
.brand-mark { display: flex; align-items: center; gap: 10px; color: var(--primary); font-weight: 900; }
|
||||
.ghost-button, .icon-button, .primary-button, .pager button, .bulk-bar button, .notice, .rail button { border: 1px solid var(--line); border-radius: 8px; min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: #fff; color: #17202a; font-weight: 850; padding: 8px 12px; transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease; }
|
||||
.ghost-button:hover, .icon-button:hover, .primary-button:hover, .pager button:hover, .bulk-bar button:hover, .rail button:hover { transform: translateY(-1px); border-color: rgba(15,109,122,.35); box-shadow: 0 10px 26px rgba(26,41,58,.08); }
|
||||
.primary-button { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.compact { min-height: 34px; padding: 7px 10px; }
|
||||
.icon-button { width: 40px; padding: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.error-text { color: var(--red); font-weight: 850; }
|
||||
.spin { animation: spin .8s linear infinite; }
|
||||
.reveal { animation: rise .28s ease both; }
|
||||
.divider { height: 1px; background: var(--line); margin: 12px 0; }
|
||||
|
||||
.public-shell { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; }
|
||||
.public-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px clamp(18px, 5vw, 64px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.78); backdrop-filter: blur(16px); }
|
||||
.public-hero { width: min(1180px, calc(100% - 32px)); margin: 28px auto; display: grid; grid-template-columns: minmax(0, 1.06fr) minmax(320px, .94fr); gap: 18px; align-content: start; }
|
||||
.public-copy, .status-lookup, .public-status { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); padding: clamp(18px, 3vw, 30px); }
|
||||
.public-copy { min-height: 320px; display: grid; align-content: center; gap: 18px; }
|
||||
.public-copy h1 { margin: 0; font-size: clamp(34px, 5vw, 58px); line-height: 1.04; letter-spacing: 0; color: #142535; }
|
||||
.public-copy p { margin: 0; max-width: 620px; color: var(--muted); font-size: 17px; line-height: 1.7; }
|
||||
.public-signals { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.public-signals span { display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; padding: 8px 11px; background: #fff; color: var(--primary-2); font-weight: 850; }
|
||||
.status-lookup { display: grid; align-content: start; gap: 14px; }
|
||||
.public-status { grid-column: 1 / -1; min-height: 220px; display: grid; gap: 14px; }
|
||||
.public-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
|
||||
.status-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.status-head strong { font-size: 20px; }
|
||||
.error-state { place-items: center; text-align: center; color: var(--red); }
|
||||
|
||||
.login-screen { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.login-panel { width: min(460px, 100%); padding: 30px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
|
||||
.login-panel h1 { margin: 18px 0 6px; font-size: 32px; letter-spacing: 0; }
|
||||
.login-panel p { margin: 0; color: var(--muted); }
|
||||
.login-form { display: grid; gap: 15px; margin-top: 24px; }
|
||||
.captcha-row { display: grid; grid-template-columns: 1fr 152px; gap: 10px; }
|
||||
.captcha-button { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 0; overflow: hidden; min-height: 48px; }
|
||||
.captcha-button img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.admin-shell { min-height: 100vh; display: grid; grid-template-columns: 240px minmax(0, 1fr); }
|
||||
.rail { position: sticky; top: 0; height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: 24px; padding: 22px 16px; border-right: 1px solid var(--line); background: rgba(251, 253, 255, .82); backdrop-filter: blur(18px); }
|
||||
.rail nav { display: grid; gap: 8px; align-content: start; }
|
||||
.rail nav button { justify-content: flex-start; background: transparent; }
|
||||
.rail nav button.active { border-color: rgba(15,109,122,.25); background: rgba(15,109,122,.1); color: var(--primary-2); }
|
||||
.workbench { min-width: 0; display: grid; gap: 18px; align-content: start; padding: 22px; }
|
||||
.hero-bar { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,.94); box-shadow: var(--shadow); }
|
||||
.hero-bar h1 { margin: 6px 0; font-size: 30px; letter-spacing: 0; }
|
||||
.hero-bar p { margin: 0; color: var(--muted); }
|
||||
.eyebrow, .refresh-stamp { display: inline-flex; align-items: center; gap: 6px; color: var(--primary); font-weight: 850; font-size: 13px; }
|
||||
.refresh-stamp.live { color: var(--amber); }
|
||||
.hero-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.notice { justify-content: flex-start; background: rgba(15,109,122,.09); color: var(--primary-2); }
|
||||
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); gap: 12px; }
|
||||
.metric, .chart-panel, .activity-card, .filter-pane, .queue-pane, .detail-pane, .mail-workspace, .config-section, .health-strip, .board-column, .integration-card, .delivery-list, .ops-panel { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 34px rgba(26,41,58,.07); backdrop-filter: blur(16px); }
|
||||
.metric { min-height: 92px; padding: 15px; display: flex; align-items: center; gap: 13px; }
|
||||
.metric > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 8px; background: rgba(15,109,122,.1); color: var(--primary); }
|
||||
.metric.green > span { background: rgba(22,134,90,.12); color: var(--green); }
|
||||
.metric.amber > span { background: rgba(164,104,5,.14); color: var(--amber); }
|
||||
.metric.red > span { background: rgba(194,65,50,.12); color: var(--red); }
|
||||
.metric.blue > span { background: rgba(37,99,168,.12); color: var(--blue); }
|
||||
.metric small { color: var(--muted); }
|
||||
.metric strong { display: block; margin-top: 3px; font-size: 26px; }
|
||||
.chart-panel, .activity-card, .health-strip { padding: 15px; min-height: 180px; }
|
||||
.status-panel { grid-column: span 2; }
|
||||
.activity-card { grid-column: span 2; display: grid; gap: 12px; align-content: start; }
|
||||
.card-title, .pane-title { display: flex; align-items: center; justify-content: space-between; gap: 9px; font-weight: 900; margin-bottom: 12px; }
|
||||
.card-title { justify-content: flex-start; }
|
||||
.span-all { grid-column: 1 / -1; margin-bottom: 0; }
|
||||
|
||||
.donut-wrap { display: grid; grid-template-columns: 140px minmax(0,1fr); gap: 12px; align-items: center; }
|
||||
.donut-wrap svg { width: 140px; height: 140px; transform: rotate(-90deg); }
|
||||
.donut-bg, .donut-segment { fill: none; stroke-width: 13; }
|
||||
.donut-bg { stroke: #e4ebf1; }
|
||||
.donut-segment { transition: stroke-dasharray .55s ease, stroke-dashoffset .55s ease; }
|
||||
.donut-wrap strong { display: block; font-size: 32px; }
|
||||
.donut-wrap span { color: var(--muted); }
|
||||
.donut-legend { grid-column: 1 / -1; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.donut-legend span { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 6px 9px; background: #fff; font-size: 12px; font-weight: 850; color: #39485a; }
|
||||
.donut-legend i { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.status-bars { display: grid; gap: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 72px 1fr 34px; gap: 10px; align-items: center; font-size: 13px; color: var(--muted); }
|
||||
.bar-row div { height: 10px; border-radius: 999px; overflow: hidden; background: #e4ebf1; }
|
||||
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--primary), #36a0ae); transition: width .45s ease; }
|
||||
.mail-signal { display: grid; grid-template-columns: auto 1fr; gap: 4px 9px; align-items: center; }
|
||||
.mail-signal small { grid-column: 2; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.config-line { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.health-strip { grid-column: 1 / -1; min-height: auto; display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
|
||||
.health-chip { min-width: 0; display: grid; grid-template-columns: auto minmax(0,1fr); gap: 3px 8px; align-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 10px; }
|
||||
.health-chip small { grid-column: 2; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 5px rgba(194,65,50,.11); }
|
||||
.health-dot.ok { background: var(--green); box-shadow: 0 0 0 5px rgba(22,134,90,.11); }
|
||||
.health-dot.warning { background: var(--amber); box-shadow: 0 0 0 5px rgba(164,104,5,.12); }
|
||||
|
||||
.ticket-grid { display: grid; grid-template-columns: 270px minmax(340px, .82fr) minmax(460px, 1.18fr); gap: 14px; align-items: start; }
|
||||
.filter-pane, .queue-pane, .detail-pane, .mail-workspace, .config-section, .delivery-list, .ops-panel { padding: 14px; }
|
||||
.filter-pane { position: sticky; top: 18px; display: grid; gap: 14px; }
|
||||
.input-icon { position: relative; }
|
||||
.input-icon svg { position: absolute; top: 12px; left: 11px; color: var(--muted); }
|
||||
.input-icon input { padding-left: 35px; }
|
||||
.two-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.checkline { display: flex; align-items: center; grid-template-columns: none; gap: 8px; color: #39485a; }
|
||||
.checkline input { width: auto; }
|
||||
.segment-group { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.segment-group > span { flex-basis: 100%; color: var(--muted); font-size: 12px; font-weight: 900; }
|
||||
.segment-group button, .quick-status button { border: 1px solid var(--line); border-radius: 999px; padding: 6px 9px; background: #fff; color: var(--muted); font-weight: 850; }
|
||||
.segment-group button.active, .quick-status button.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.bulk-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 9px; margin-bottom: 10px; border: 1px solid rgba(15,109,122,.22); border-radius: 8px; background: rgba(15,109,122,.08); }
|
||||
.bulk-bar span { font-weight: 900; color: var(--primary-2); }
|
||||
.bulk-bar button { min-height: 30px; padding: 5px 8px; }
|
||||
.ticket-list { display: grid; gap: 9px; }
|
||||
.ticket-row { display: grid; grid-template-columns: 30px minmax(0,1fr) 18px; gap: 10px; align-items: center; min-height: 122px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; animation: rise .22s ease both; }
|
||||
.ticket-row:hover, .ticket-row.active { transform: translateY(-2px); border-color: rgba(15,109,122,.42); box-shadow: 0 14px 34px rgba(26,41,58,.09); }
|
||||
.check-button { border: 0; background: transparent; color: var(--primary); padding: 0; }
|
||||
.ticket-main { min-width: 0; display: grid; gap: 6px; }
|
||||
.ticket-main strong, .ticket-main p, .ticket-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ticket-main p, .ticket-main small { margin: 0; color: var(--muted); }
|
||||
.ticket-top { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.status-pill, .priority-pill, .sla-pill, .category-tag { width: fit-content; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 900; background: #e7edf3; color: #516174; white-space: nowrap; }
|
||||
.status-pill.investigating, .priority-pill.major, .sla-pill.elevated, .status-pill.pending { background: #fff0ce; color: var(--amber); }
|
||||
.status-pill.resolved, .status-pill.sent { background: #dff5e8; color: var(--green); }
|
||||
.status-pill.failed, .priority-pill.blocking, .sla-pill.urgent { background: #ffe4df; color: var(--red); }
|
||||
.status-pill.triaged { background: #dbeafe; color: var(--blue); }
|
||||
.priority-pill.normal, .sla-pill.standard { background: #e9f7f4; color: var(--primary); }
|
||||
.category-tag { background: #eef2f6; color: var(--muted); }
|
||||
|
||||
.detail-pane { display: grid; gap: 13px; max-height: calc(100vh - 42px); overflow: auto; }
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
|
||||
.detail-head h2 { margin: 8px 0 4px; font-size: 24px; letter-spacing: 0; overflow-wrap: anywhere; }
|
||||
.detail-head p { margin: 0; color: var(--muted); }
|
||||
.quick-status { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 9px; }
|
||||
.info-cell { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fff; display: grid; gap: 5px; }
|
||||
.info-cell span { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; font-weight: 900; }
|
||||
.info-cell strong { overflow-wrap: anywhere; }
|
||||
.report-block h3, .timeline h3 { margin: 0 0 8px; font-size: 15px; }
|
||||
.workflow-form { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
|
||||
.workflow-form .full { grid-column: 1 / -1; }
|
||||
.comment-box, .package-box { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fff; }
|
||||
.comment-input { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: start; }
|
||||
.comment-list { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.comment-list article { border: 1px solid rgba(126,146,166,.18); border-radius: 8px; padding: 9px; background: #f9fbfc; }
|
||||
.comment-list small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.comment-list p { margin: 7px 0 0; overflow-wrap: anywhere; }
|
||||
.package-box code { display: block; margin-top: 8px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.mini-timeline, .timeline { display: grid; gap: 10px; }
|
||||
.event-line { position: relative; display: grid; grid-template-columns: 16px minmax(0,1fr); gap: 9px; }
|
||||
.event-line .dot { width: 10px; height: 10px; margin-top: 5px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 0 5px rgba(15,109,122,.1); }
|
||||
.event-line strong { display: block; }
|
||||
.event-line small, .event-line em { display: block; color: var(--muted); font-size: 12px; font-style: normal; margin-top: 2px; }
|
||||
.event-line.compact strong { font-size: 13px; }
|
||||
|
||||
.board-grid { display: grid; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap: 12px; align-items: start; overflow-x: auto; padding-bottom: 6px; }
|
||||
.board-column { min-height: 460px; padding: 12px; display: grid; align-content: start; gap: 10px; }
|
||||
.board-title { display: flex; align-items: center; justify-content: space-between; }
|
||||
.board-title small { color: var(--muted); font-weight: 900; }
|
||||
.board-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; display: grid; gap: 8px; transition: transform .18s ease, box-shadow .18s ease; }
|
||||
.board-card:hover { transform: translateY(-2px); box-shadow: 0 14px 28px rgba(26,41,58,.08); }
|
||||
.board-card p { margin: 0; color: var(--muted); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.board-card div { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.board-card small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
.empty-mini { min-height: 86px; display: grid; place-items: center; border: 1px dashed var(--line); border-radius: 8px; color: var(--muted); }
|
||||
|
||||
.mail-workspace { display: grid; gap: 12px; }
|
||||
.mail-grid { display: grid; gap: 10px; }
|
||||
.mail-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
|
||||
.mail-card summary { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 6px 10px; align-items: center; cursor: pointer; }
|
||||
.mail-card summary small { grid-column: 2; color: var(--muted); }
|
||||
.mail-card code { display: block; margin-top: 8px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
|
||||
.integration-grid, .ops-grid { display: grid; grid-template-columns: minmax(320px, .8fr) minmax(360px, 1.2fr); gap: 14px; align-items: start; }
|
||||
.integration-list, .delivery-list, .ops-panel { display: grid; gap: 10px; }
|
||||
.integration-card, .delivery-row, .backup-row, .audit-row { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
|
||||
.integration-card { display: grid; gap: 8px; }
|
||||
.integration-card > div:first-child, .backup-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.integration-card small, .delivery-row small, .backup-row small, .audit-row small { color: var(--muted); display: block; margin-top: 3px; overflow-wrap: anywhere; }
|
||||
.integration-card p, .delivery-row p, .audit-row p { margin: 7px 0 0; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.delivery-row { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 6px 10px; align-items: center; }
|
||||
.delivery-row p { grid-column: 2; }
|
||||
|
||||
.config-page { display: grid; gap: 14px; }
|
||||
.config-checks { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
|
||||
.config-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
|
||||
.config-section dl { margin: 0; display: grid; gap: 9px; }
|
||||
.config-section dl div { display: grid; grid-template-columns: 128px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid rgba(126,146,166,.18); }
|
||||
.config-section dl div:last-child { border-bottom: 0; }
|
||||
.config-section dt { color: var(--muted); font-weight: 850; }
|
||||
.config-section dd { margin: 0; overflow-wrap: anywhere; font-weight: 760; }
|
||||
.config-footer, .inline-actions { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-weight: 850; flex-wrap: wrap; }
|
||||
.workflow-form.single { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.db-runtime { margin-top: 12px; border: 1px solid rgba(22,134,90,.22); border-radius: 8px; background: rgba(22,134,90,.08); padding: 11px; display: grid; gap: 4px; }
|
||||
.db-runtime.warning { border-color: rgba(164,104,5,.28); background: rgba(164,104,5,.1); }
|
||||
.db-runtime strong { color: var(--primary-2); }
|
||||
.db-runtime span, .db-runtime small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
.webhook-editor { display: grid; gap: 10px; }
|
||||
.webhook-editor .integration-card { background: #fbfdff; }
|
||||
.webhook-editor .workflow-form { grid-template-columns: repeat(5, minmax(0,1fr)); }
|
||||
|
||||
.pager { margin-top: 12px; display: flex; justify-content: flex-end; align-items: center; gap: 8px; color: var(--muted); font-weight: 850; }
|
||||
.pager button { min-height: 32px; padding: 5px 10px; }
|
||||
.empty-state { min-height: 160px; display: grid; place-items: center; border: 1px dashed var(--line); border-radius: 8px; color: var(--muted); text-align: center; }
|
||||
.skeleton { min-height: 92px; border-radius: 8px; background: linear-gradient(90deg, #edf2f6, #f8fafc, #edf2f6); background-size: 240% 100%; animation: shimmer 1.1s linear infinite; }
|
||||
.skeleton.row { min-height: 118px; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes shimmer { to { background-position: -240% 0; } }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (max-width: 1420px) {
|
||||
.dashboard-grid, .config-checks { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.ticket-grid { grid-template-columns: 250px minmax(330px, 1fr); }
|
||||
.detail-pane { grid-column: 1 / -1; max-height: none; }
|
||||
.activity-card, .status-panel { grid-column: span 2; }
|
||||
.health-strip { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell, .public-hero, .config-grid, .public-status-grid, .integration-grid, .ops-grid { grid-template-columns: 1fr; }
|
||||
.public-status { grid-column: auto; }
|
||||
.rail { position: sticky; z-index: 5; height: auto; grid-template-columns: 1fr auto; grid-template-rows: auto auto; }
|
||||
.rail nav { grid-column: 1 / -1; grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||
.rail nav button { justify-content: center; }
|
||||
.workbench { padding: 14px; }
|
||||
.hero-bar, .ticket-grid, .dashboard-grid, .info-grid, .workflow-form, .workflow-form.single, .webhook-editor .workflow-form, .captcha-row, .config-checks, .health-strip { grid-template-columns: 1fr; }
|
||||
.filter-pane { position: static; }
|
||||
.activity-card, .status-panel { grid-column: auto; }
|
||||
.board-grid { grid-template-columns: repeat(5, minmax(220px, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.public-top, .hero-bar, .detail-head, .pane-title, .integration-card div, .backup-row { align-items: flex-start; flex-direction: column; }
|
||||
.rail nav { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.public-copy h1 { font-size: 34px; }
|
||||
.donut-wrap { grid-template-columns: 1fr; justify-items: center; }
|
||||
.config-section dl div, .two-inputs, .comment-input { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/admin/",
|
||||
build: {
|
||||
outDir: "../web/dist",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8080"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/auth"
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
"ymhut-box/server/feedback-mailer/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
baseDir := findBaseDir()
|
||||
cfg, err := config.Load(baseDir)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
authService := auth.NewService(cfg)
|
||||
router := web.NewRouter(cfg, store, authService)
|
||||
|
||||
addr := cfg.Listen
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
log.Printf("YMhut Box feedback service listening on %s", addr)
|
||||
server := &http.Server{Addr: addr, Handler: router}
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
log.Println("YMhut Box feedback service stopped")
|
||||
}
|
||||
|
||||
func findBaseDir() string {
|
||||
if value := os.Getenv("YMHUT_FEEDBACK_HOME"); value != "" {
|
||||
if abs, err := filepath.Abs(value); err == nil {
|
||||
return abs
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil && looksLikeServiceRoot(wd) {
|
||||
return wd
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err == nil {
|
||||
return wd
|
||||
}
|
||||
|
||||
return "."
|
||||
}
|
||||
|
||||
func looksLikeServiceRoot(path string) bool {
|
||||
for _, name := range []string{"config.txt", "config.json", "admin-web", "web"} {
|
||||
if _, err := os.Stat(filepath.Join(path, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"listen": ":8080",
|
||||
"admin_password_hash": "",
|
||||
"admin_password": "CHANGE_ME_ADMIN_PASSWORD",
|
||||
"client_signature_key": "ymhut-box-feedback-client-v1",
|
||||
"package_encryption_key": "ymhut-box-feedback-package-v1",
|
||||
"timestamp_window_seconds": 600,
|
||||
"max_request_bytes": 12582912,
|
||||
"max_package_bytes": 10485760,
|
||||
"storage_dir": "./storage",
|
||||
"database_path": "./storage/feedback.sqlite",
|
||||
"mail": {
|
||||
"host": "mail.example.com",
|
||||
"port": 465,
|
||||
"secure": "ssl",
|
||||
"username": "sender@example.com",
|
||||
"password": "CHANGE_ME_MAIL_PASSWORD",
|
||||
"from_address": "sender@example.com",
|
||||
"from_name": "YMhut Box Feedback",
|
||||
"developer_address": "developer@example.com",
|
||||
"timeout_seconds": 20
|
||||
},
|
||||
"rate_limit": {
|
||||
"submission_per_minute": 20,
|
||||
"submission_burst": 5,
|
||||
"status_per_minute": 120,
|
||||
"status_burst": 30,
|
||||
"captcha_per_minute": 60,
|
||||
"captcha_burst": 10,
|
||||
"login_per_minute": 12,
|
||||
"login_burst": 3,
|
||||
"admin_read_per_minute": 300,
|
||||
"admin_read_burst": 60,
|
||||
"admin_write_per_minute": 90,
|
||||
"admin_write_burst": 20
|
||||
},
|
||||
"upload_guard": {
|
||||
"max_zip_files": 80,
|
||||
"max_decompressed_bytes": 31457280,
|
||||
"max_single_file_bytes": 8388608,
|
||||
"max_compression_ratio": 120,
|
||||
"max_readable_text_bytes": 262144,
|
||||
"allow_unexpected_zip_files": true
|
||||
},
|
||||
"backup": {
|
||||
"dir": "./storage/backups"
|
||||
},
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "ops",
|
||||
"url": "https://example.com/webhook",
|
||||
"secret": "CHANGE_ME_WEBHOOK_SECRET",
|
||||
"enabled": false,
|
||||
"events": [
|
||||
"feedback.created",
|
||||
"feedback.updated",
|
||||
"feedback.status_changed",
|
||||
"feedback.comment_created",
|
||||
"mail.failed"
|
||||
],
|
||||
"timeout_seconds": 5,
|
||||
"max_retries": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
return [
|
||||
'listen' => ':8099',
|
||||
'admin_password_hash' => '',
|
||||
'admin_password' => 'aoc13110314620',
|
||||
'client_signature_key' => 'ymhut-box-feedback-client-v1',
|
||||
'package_encryption_key' => 'ymhut-box-feedback-package-v1',
|
||||
'timestamp_window_seconds' => 600,
|
||||
'max_request_bytes' => 12582912,
|
||||
'max_package_bytes' => 10485760,
|
||||
'storage_dir' => 'storage',
|
||||
'database_path' => 'storage/feedback.sqlite',
|
||||
'database_provider' => 'sqlite',
|
||||
'database_sqlite_path' => 'storage/feedback.sqlite',
|
||||
'database_host' => '',
|
||||
'database_port' => 0,
|
||||
'database_name' => '',
|
||||
'database_user' => '',
|
||||
'database_password' => '',
|
||||
'database_dsn' => '',
|
||||
'database_ssl_mode' => 'disable',
|
||||
'database_max_open_conns' => 10,
|
||||
'database_max_idle_conns' => 4,
|
||||
'database_conn_max_lifetime_seconds' => 300,
|
||||
'database_failover_enabled' => true,
|
||||
'database_health_interval_seconds' => 30,
|
||||
'database_sync_enabled' => true,
|
||||
'database_sync_interval_seconds' => 86400,
|
||||
'database_sync_batch_size' => 500,
|
||||
'host' => 'smtp.qiye.aliyun.com',
|
||||
'port' => 465,
|
||||
'secure' => 'ssl',
|
||||
'username' => 'support@ymhut.cn',
|
||||
'password' => 'XOzIRqQxbUUZJ4Py',
|
||||
'from_address' => 'support@ymhut.cn',
|
||||
'from_name' => 'YMhut Box Feedback',
|
||||
'developer_address' => '2451997170@qq.com',
|
||||
'timeout_seconds' => 20,
|
||||
'backup_dir' => 'storage/backups',
|
||||
'submission_per_minute' => 20,
|
||||
'submission_burst' => 5,
|
||||
'status_per_minute' => 120,
|
||||
'status_burst' => 30,
|
||||
'captcha_per_minute' => 60,
|
||||
'captcha_burst' => 10,
|
||||
'login_per_minute' => 12,
|
||||
'login_burst' => 3,
|
||||
'admin_read_per_minute' => 300,
|
||||
'admin_read_burst' => 60,
|
||||
'admin_write_per_minute' => 90,
|
||||
'admin_write_burst' => 20,
|
||||
'max_zip_files' => 80,
|
||||
'max_decompressed_bytes' => 31457280,
|
||||
'max_single_file_bytes' => 8388608,
|
||||
'max_compression_ratio' => 120,
|
||||
'max_readable_text_bytes' => 262144,
|
||||
'allow_unexpected_zip_files' => true,
|
||||
'webhooks' => [
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
module ymhut-box/server/feedback-mailer
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,293 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "ymhut_feedback_session"
|
||||
captchaTTL = 5 * time.Minute
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
mu sync.Mutex
|
||||
captchas map[string]captchaEntry
|
||||
sessions map[string]sessionEntry
|
||||
}
|
||||
|
||||
type captchaEntry struct {
|
||||
Answer string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
ExpiresAt time.Time
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
ID string
|
||||
ImagePNG []byte
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
captchas: map[string]captchaEntry{},
|
||||
sessions: map[string]sessionEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) NewCaptcha() (Captcha, error) {
|
||||
answer := randomDigits(5)
|
||||
id := randomToken(16)
|
||||
imageBytes, err := renderCaptcha(answer)
|
||||
if err != nil {
|
||||
return Captcha{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked()
|
||||
s.captchas[id] = captchaEntry{Answer: answer, ExpiresAt: time.Now().Add(captchaTTL)}
|
||||
s.mu.Unlock()
|
||||
return Captcha{ID: id, ImagePNG: imageBytes}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(password, captchaID, captchaAnswer string) (string, string, bool) {
|
||||
if !s.consumeCaptcha(captchaID, captchaAnswer) {
|
||||
return "", "", false
|
||||
}
|
||||
if !s.VerifyPassword(password) {
|
||||
return "", "", false
|
||||
}
|
||||
sessionID := randomToken(32)
|
||||
csrf := randomToken(32)
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked()
|
||||
s.sessions[sessionID] = sessionEntry{ExpiresAt: time.Now().Add(sessionTTL), CSRFToken: csrf}
|
||||
s.mu.Unlock()
|
||||
return sessionID, csrf, true
|
||||
}
|
||||
|
||||
func (s *Service) Logout(c *gin.Context) {
|
||||
sessionID, _ := c.Cookie(sessionCookie)
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
clearCookie(c)
|
||||
}
|
||||
|
||||
func (s *Service) RequireAuth(c *gin.Context) {
|
||||
sessionID, err := c.Cookie(sessionCookie)
|
||||
if err != nil || sessionID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
csrf, ok := s.SessionCSRF(sessionID)
|
||||
if !ok {
|
||||
clearCookie(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("csrf", csrf)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Service) RequireCSRF(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
expected, _ := c.Get("csrf")
|
||||
actual := c.GetHeader("X-CSRF-Token")
|
||||
if expected == nil || actual == "" || subtle.ConstantTimeCompare([]byte(expected.(string)), []byte(actual)) != 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"ok": false, "error": "CSRF_INVALID", "message": "Invalid CSRF token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Service) SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(sessionCookie, sessionID, int(sessionTTL.Seconds()), "/", "", false, true)
|
||||
}
|
||||
|
||||
func (s *Service) SessionCSRF(sessionID string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return session.CSRFToken, true
|
||||
}
|
||||
|
||||
func (s *Service) VerifyPassword(password string) bool {
|
||||
password = strings.TrimSpace(password)
|
||||
if password == "" {
|
||||
return false
|
||||
}
|
||||
hash := strings.TrimSpace(s.cfg.AdminPasswordHash)
|
||||
if hash != "" && verifyBcrypt(hash, password) {
|
||||
return true
|
||||
}
|
||||
plain := strings.TrimSpace(s.cfg.AdminPassword)
|
||||
return plain != "" && subtle.ConstantTimeCompare([]byte(plain), []byte(password)) == 1
|
||||
}
|
||||
|
||||
func (s *Service) consumeCaptcha(id, answer string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
answer = strings.TrimSpace(answer)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked()
|
||||
entry, ok := s.captchas[id]
|
||||
if ok {
|
||||
delete(s.captchas, id)
|
||||
}
|
||||
if !ok || time.Now().After(entry.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(strings.ToLower(entry.Answer)), []byte(strings.ToLower(answer))) == 1
|
||||
}
|
||||
|
||||
func (s *Service) cleanupLocked() {
|
||||
now := time.Now()
|
||||
for id, entry := range s.captchas {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.captchas, id)
|
||||
}
|
||||
}
|
||||
for id, entry := range s.sessions {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifyBcrypt(hash, password string) bool {
|
||||
candidates := []string{hash}
|
||||
if strings.HasPrefix(hash, "$2y$") {
|
||||
candidates = append(candidates, "$2a$"+strings.TrimPrefix(hash, "$2y$"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if bcrypt.CompareHashAndPassword([]byte(candidate), []byte(password)) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clearCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(sessionCookie, "", -1, "/", "", false, true)
|
||||
}
|
||||
|
||||
func randomDigits(count int) string {
|
||||
data := make([]byte, count)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "12345"
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, value := range data {
|
||||
builder.WriteByte('0' + value%10)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomToken(bytesLen int) string {
|
||||
data := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(data)
|
||||
}
|
||||
|
||||
func renderCaptcha(answer string) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 180, 64))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{245, 248, 252, 255}}, image.Point{}, draw.Src)
|
||||
|
||||
for i := 0; i < 24; i++ {
|
||||
x := (i*37 + 13) % 180
|
||||
y := (i*19 + 7) % 64
|
||||
img.Set(x, y, color.RGBA{102, 120, 145, 255})
|
||||
}
|
||||
|
||||
for index, digit := range answer {
|
||||
drawDigit(img, int(digit-'0'), 18+index*32, 13, color.RGBA{28, 72, 130, 255})
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
var segments = [10][7]bool{
|
||||
{true, true, true, true, true, true, false},
|
||||
{false, true, true, false, false, false, false},
|
||||
{true, true, false, true, true, false, true},
|
||||
{true, true, true, true, false, false, true},
|
||||
{false, true, true, false, false, true, true},
|
||||
{true, false, true, true, false, true, true},
|
||||
{true, false, true, true, true, true, true},
|
||||
{true, true, true, false, false, false, false},
|
||||
{true, true, true, true, true, true, true},
|
||||
{true, true, true, true, false, true, true},
|
||||
}
|
||||
|
||||
func drawDigit(img *image.RGBA, digit, x, y int, col color.Color) {
|
||||
if digit < 0 || digit > 9 {
|
||||
return
|
||||
}
|
||||
thick := 4
|
||||
width := 22
|
||||
height := 36
|
||||
drawSegment := func(rect image.Rectangle) {
|
||||
draw.Draw(img, rect, &image.Uniform{col}, image.Point{}, draw.Src)
|
||||
}
|
||||
if segments[digit][0] {
|
||||
drawSegment(image.Rect(x+thick, y, x+width-thick, y+thick))
|
||||
}
|
||||
if segments[digit][1] {
|
||||
drawSegment(image.Rect(x+width-thick, y+thick, x+width, y+height/2))
|
||||
}
|
||||
if segments[digit][2] {
|
||||
drawSegment(image.Rect(x+width-thick, y+height/2, x+width, y+height-thick))
|
||||
}
|
||||
if segments[digit][3] {
|
||||
drawSegment(image.Rect(x+thick, y+height-thick, x+width-thick, y+height))
|
||||
}
|
||||
if segments[digit][4] {
|
||||
drawSegment(image.Rect(x, y+height/2, x+thick, y+height-thick))
|
||||
}
|
||||
if segments[digit][5] {
|
||||
drawSegment(image.Rect(x, y+thick, x+thick, y+height/2))
|
||||
}
|
||||
if segments[digit][6] {
|
||||
drawSegment(image.Rect(x+thick, y+height/2-thick/2, x+width-thick, y+height/2+thick/2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientSignatureKey = "ymhut-box-feedback-client-v1"
|
||||
defaultPackageEncryptionKey = "ymhut-box-feedback-package-v1"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseDir string `json:"-"`
|
||||
Listen string `json:"listen"`
|
||||
AdminPasswordHash string `json:"admin_password_hash"`
|
||||
AdminPassword string `json:"admin_password"`
|
||||
ClientSignatureKey string `json:"client_signature_key"`
|
||||
PackageEncryptionKey string `json:"package_encryption_key"`
|
||||
TimestampWindowSeconds int64 `json:"timestamp_window_seconds"`
|
||||
MaxRequestBytes int64 `json:"max_request_bytes"`
|
||||
MaxPackageBytes int64 `json:"max_package_bytes"`
|
||||
StorageDir string `json:"storage_dir"`
|
||||
DatabasePath string `json:"database_path"`
|
||||
Mail MailConfig `json:"mail"`
|
||||
RateLimit RateLimitConfig `json:"rate_limit"`
|
||||
UploadGuard UploadGuardConfig `json:"upload_guard"`
|
||||
Backup BackupConfig `json:"backup"`
|
||||
Database DatabaseConfig `json:"database"`
|
||||
Webhooks []WebhookConfig `json:"webhooks"`
|
||||
}
|
||||
|
||||
type MailConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Secure string `json:"secure"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromAddress string `json:"from_address"`
|
||||
FromName string `json:"from_name"`
|
||||
DeveloperAddress string `json:"developer_address"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
SubmissionPerMinute int `json:"submission_per_minute"`
|
||||
SubmissionBurst int `json:"submission_burst"`
|
||||
StatusPerMinute int `json:"status_per_minute"`
|
||||
StatusBurst int `json:"status_burst"`
|
||||
CaptchaPerMinute int `json:"captcha_per_minute"`
|
||||
CaptchaBurst int `json:"captcha_burst"`
|
||||
LoginPerMinute int `json:"login_per_minute"`
|
||||
LoginBurst int `json:"login_burst"`
|
||||
AdminReadPerMinute int `json:"admin_read_per_minute"`
|
||||
AdminReadBurst int `json:"admin_read_burst"`
|
||||
AdminWritePerMinute int `json:"admin_write_per_minute"`
|
||||
AdminWriteBurst int `json:"admin_write_burst"`
|
||||
}
|
||||
|
||||
type UploadGuardConfig struct {
|
||||
MaxZipFiles int `json:"max_zip_files"`
|
||||
MaxDecompressedBytes int64 `json:"max_decompressed_bytes"`
|
||||
MaxSingleFileBytes int64 `json:"max_single_file_bytes"`
|
||||
MaxCompressionRatio float64 `json:"max_compression_ratio"`
|
||||
MaxReadableTextBytes int64 `json:"max_readable_text_bytes"`
|
||||
AllowUnexpectedZipFiles bool `json:"allow_unexpected_zip_files"`
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
SQLitePath string `json:"sqlite_path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DSN string `json:"dsn"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
MaxIdleConns int `json:"max_idle_conns"`
|
||||
ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"`
|
||||
FailoverEnabled bool `json:"failover_enabled"`
|
||||
HealthIntervalSeconds int `json:"health_interval_seconds"`
|
||||
Sync DatabaseSyncConfig `json:"sync"`
|
||||
}
|
||||
|
||||
type DatabaseSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Events []string `json:"events"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
}
|
||||
|
||||
func Load(baseDir string) (*Config, error) {
|
||||
absBase, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Default(absBase)
|
||||
for _, name := range []string{"config.txt", "config.json"} {
|
||||
path := filepath.Join(absBase, name)
|
||||
if isFile(path) {
|
||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
||||
if err := loadJSON(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := loadLegacy(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
normalize(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
normalize(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default(baseDir string) *Config {
|
||||
return &Config{
|
||||
BaseDir: baseDir,
|
||||
Listen: ":8080",
|
||||
AdminPasswordHash: "",
|
||||
AdminPassword: "CHANGE_ME_ADMIN_PASSWORD",
|
||||
ClientSignatureKey: defaultClientSignatureKey,
|
||||
PackageEncryptionKey: defaultPackageEncryptionKey,
|
||||
TimestampWindowSeconds: 600,
|
||||
MaxRequestBytes: 12 * 1024 * 1024,
|
||||
MaxPackageBytes: 10 * 1024 * 1024,
|
||||
StorageDir: filepath.Join(baseDir, "storage"),
|
||||
DatabasePath: filepath.Join(baseDir, "storage", "feedback.sqlite"),
|
||||
Mail: MailConfig{
|
||||
Host: "mail.example.com",
|
||||
Port: 465,
|
||||
Secure: "ssl",
|
||||
Username: "sender@example.com",
|
||||
Password: "CHANGE_ME_MAIL_PASSWORD",
|
||||
FromAddress: "sender@example.com",
|
||||
FromName: "YMhut Box Feedback",
|
||||
DeveloperAddress: "developer@example.com",
|
||||
TimeoutSeconds: 20,
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
SubmissionPerMinute: 20,
|
||||
SubmissionBurst: 5,
|
||||
StatusPerMinute: 120,
|
||||
StatusBurst: 30,
|
||||
CaptchaPerMinute: 60,
|
||||
CaptchaBurst: 10,
|
||||
LoginPerMinute: 12,
|
||||
LoginBurst: 3,
|
||||
AdminReadPerMinute: 300,
|
||||
AdminReadBurst: 60,
|
||||
AdminWritePerMinute: 90,
|
||||
AdminWriteBurst: 20,
|
||||
},
|
||||
UploadGuard: UploadGuardConfig{
|
||||
MaxZipFiles: 80,
|
||||
MaxDecompressedBytes: 30 * 1024 * 1024,
|
||||
MaxSingleFileBytes: 8 * 1024 * 1024,
|
||||
MaxCompressionRatio: 120,
|
||||
MaxReadableTextBytes: 256 * 1024,
|
||||
AllowUnexpectedZipFiles: true,
|
||||
},
|
||||
Backup: BackupConfig{
|
||||
Dir: filepath.Join(baseDir, "storage", "backups"),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(baseDir, "storage", "feedback.sqlite"),
|
||||
Port: 0,
|
||||
SSLMode: "disable",
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 4,
|
||||
ConnMaxLifetimeSeconds: 300,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSeconds: 30,
|
||||
Sync: DatabaseSyncConfig{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 24 * 60 * 60,
|
||||
BatchSize: 500,
|
||||
},
|
||||
},
|
||||
Webhooks: []WebhookConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
func loadJSON(path string, cfg *Config) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLegacy(path string, cfg *Config) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
text := string(data)
|
||||
vars := parseLegacyVars(text)
|
||||
entries := parseLegacyEntries(text, vars, filepath.Dir(path))
|
||||
if len(entries) == 0 {
|
||||
return errors.New("legacy config did not contain any parseable entries")
|
||||
}
|
||||
|
||||
assignString(entries, "admin_password_hash", &cfg.AdminPasswordHash)
|
||||
assignString(entries, "admin_password", &cfg.AdminPassword)
|
||||
assignString(entries, "listen", &cfg.Listen)
|
||||
assignString(entries, "client_signature_key", &cfg.ClientSignatureKey)
|
||||
assignString(entries, "package_encryption_key", &cfg.PackageEncryptionKey)
|
||||
assignInt64(entries, "timestamp_window_seconds", &cfg.TimestampWindowSeconds)
|
||||
assignInt64(entries, "max_request_bytes", &cfg.MaxRequestBytes)
|
||||
assignInt64(entries, "max_package_bytes", &cfg.MaxPackageBytes)
|
||||
assignString(entries, "storage_dir", &cfg.StorageDir)
|
||||
assignString(entries, "database_path", &cfg.DatabasePath)
|
||||
assignString(entries, "database_provider", &cfg.Database.Provider)
|
||||
assignString(entries, "database_sqlite_path", &cfg.Database.SQLitePath)
|
||||
assignString(entries, "database_host", &cfg.Database.Host)
|
||||
assignInt(entries, "database_port", &cfg.Database.Port)
|
||||
assignString(entries, "database_name", &cfg.Database.Name)
|
||||
assignString(entries, "database_user", &cfg.Database.User)
|
||||
assignString(entries, "database_password", &cfg.Database.Password)
|
||||
assignString(entries, "database_dsn", &cfg.Database.DSN)
|
||||
assignString(entries, "database_ssl_mode", &cfg.Database.SSLMode)
|
||||
assignInt(entries, "database_max_open_conns", &cfg.Database.MaxOpenConns)
|
||||
assignInt(entries, "database_max_idle_conns", &cfg.Database.MaxIdleConns)
|
||||
assignInt(entries, "database_conn_max_lifetime_seconds", &cfg.Database.ConnMaxLifetimeSeconds)
|
||||
assignBool(entries, "database_failover_enabled", &cfg.Database.FailoverEnabled)
|
||||
assignInt(entries, "database_health_interval_seconds", &cfg.Database.HealthIntervalSeconds)
|
||||
assignBool(entries, "database_sync_enabled", &cfg.Database.Sync.Enabled)
|
||||
assignInt(entries, "database_sync_interval_seconds", &cfg.Database.Sync.IntervalSeconds)
|
||||
assignInt(entries, "database_sync_batch_size", &cfg.Database.Sync.BatchSize)
|
||||
|
||||
assignString(entries, "host", &cfg.Mail.Host)
|
||||
assignInt(entries, "port", &cfg.Mail.Port)
|
||||
assignString(entries, "secure", &cfg.Mail.Secure)
|
||||
assignString(entries, "username", &cfg.Mail.Username)
|
||||
assignString(entries, "password", &cfg.Mail.Password)
|
||||
assignString(entries, "from_address", &cfg.Mail.FromAddress)
|
||||
assignString(entries, "from_name", &cfg.Mail.FromName)
|
||||
assignString(entries, "developer_address", &cfg.Mail.DeveloperAddress)
|
||||
assignInt(entries, "timeout_seconds", &cfg.Mail.TimeoutSeconds)
|
||||
|
||||
assignIntAliases(entries, &cfg.RateLimit.SubmissionPerMinute, "submission_per_minute", "rate_limit_submission_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.SubmissionBurst, "submission_burst", "rate_limit_submission_burst")
|
||||
assignIntAliases(entries, &cfg.RateLimit.StatusPerMinute, "status_per_minute", "rate_limit_status_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.StatusBurst, "status_burst", "rate_limit_status_burst")
|
||||
assignIntAliases(entries, &cfg.RateLimit.CaptchaPerMinute, "captcha_per_minute", "rate_limit_captcha_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.CaptchaBurst, "captcha_burst", "rate_limit_captcha_burst")
|
||||
assignIntAliases(entries, &cfg.RateLimit.LoginPerMinute, "login_per_minute", "rate_limit_login_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.LoginBurst, "login_burst", "rate_limit_login_burst")
|
||||
assignIntAliases(entries, &cfg.RateLimit.AdminReadPerMinute, "admin_read_per_minute", "rate_limit_admin_read_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.AdminReadBurst, "admin_read_burst", "rate_limit_admin_read_burst")
|
||||
assignIntAliases(entries, &cfg.RateLimit.AdminWritePerMinute, "admin_write_per_minute", "rate_limit_admin_write_per_minute")
|
||||
assignIntAliases(entries, &cfg.RateLimit.AdminWriteBurst, "admin_write_burst", "rate_limit_admin_write_burst")
|
||||
|
||||
assignInt(entries, "max_zip_files", &cfg.UploadGuard.MaxZipFiles)
|
||||
assignInt64(entries, "max_decompressed_bytes", &cfg.UploadGuard.MaxDecompressedBytes)
|
||||
assignInt64(entries, "max_single_file_bytes", &cfg.UploadGuard.MaxSingleFileBytes)
|
||||
assignFloat(entries, "max_compression_ratio", &cfg.UploadGuard.MaxCompressionRatio)
|
||||
assignInt64(entries, "max_readable_text_bytes", &cfg.UploadGuard.MaxReadableTextBytes)
|
||||
assignBool(entries, "allow_unexpected_zip_files", &cfg.UploadGuard.AllowUnexpectedZipFiles)
|
||||
assignString(entries, "backup_dir", &cfg.Backup.Dir)
|
||||
|
||||
if hooks := parseLegacyWebhooks(text, vars, filepath.Dir(path)); len(hooks) > 0 {
|
||||
cfg.Webhooks = hooks
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Save(cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
normalize(cfg)
|
||||
path := filepath.Join(cfg.BaseDir, "config.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if isFile(path) {
|
||||
backup := filepath.Join(cfg.BaseDir, "config-"+time.Now().UTC().Format("20060102-150405")+".bak")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(backup, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, []byte(RenderLegacy(cfg)), 0o600)
|
||||
}
|
||||
|
||||
func RenderLegacy(cfg *Config) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<?php\nreturn [\n")
|
||||
writeKV := func(key, value string) {
|
||||
b.WriteString(" '")
|
||||
b.WriteString(key)
|
||||
b.WriteString("' => ")
|
||||
b.WriteString(quotePHP(value))
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
writeInt := func(key string, value int64) {
|
||||
b.WriteString(" '")
|
||||
b.WriteString(key)
|
||||
b.WriteString("' => ")
|
||||
b.WriteString(strconv.FormatInt(value, 10))
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
writeBool := func(key string, value bool) {
|
||||
b.WriteString(" '")
|
||||
b.WriteString(key)
|
||||
b.WriteString("' => ")
|
||||
if value {
|
||||
b.WriteString("true")
|
||||
} else {
|
||||
b.WriteString("false")
|
||||
}
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
|
||||
writeKV("listen", cfg.Listen)
|
||||
writeKV("admin_password_hash", cfg.AdminPasswordHash)
|
||||
writeKV("admin_password", cfg.AdminPassword)
|
||||
writeKV("client_signature_key", cfg.ClientSignatureKey)
|
||||
writeKV("package_encryption_key", cfg.PackageEncryptionKey)
|
||||
writeInt("timestamp_window_seconds", cfg.TimestampWindowSeconds)
|
||||
writeInt("max_request_bytes", cfg.MaxRequestBytes)
|
||||
writeInt("max_package_bytes", cfg.MaxPackageBytes)
|
||||
writeKV("storage_dir", portablePath(cfg, cfg.StorageDir))
|
||||
writeKV("database_path", portablePath(cfg, cfg.Database.SQLitePath))
|
||||
writeKV("database_provider", cfg.Database.Provider)
|
||||
writeKV("database_sqlite_path", portablePath(cfg, cfg.Database.SQLitePath))
|
||||
writeKV("database_host", cfg.Database.Host)
|
||||
writeInt("database_port", int64(cfg.Database.Port))
|
||||
writeKV("database_name", cfg.Database.Name)
|
||||
writeKV("database_user", cfg.Database.User)
|
||||
writeKV("database_password", cfg.Database.Password)
|
||||
writeKV("database_dsn", cfg.Database.DSN)
|
||||
writeKV("database_ssl_mode", cfg.Database.SSLMode)
|
||||
writeInt("database_max_open_conns", int64(cfg.Database.MaxOpenConns))
|
||||
writeInt("database_max_idle_conns", int64(cfg.Database.MaxIdleConns))
|
||||
writeInt("database_conn_max_lifetime_seconds", int64(cfg.Database.ConnMaxLifetimeSeconds))
|
||||
writeBool("database_failover_enabled", cfg.Database.FailoverEnabled)
|
||||
writeInt("database_health_interval_seconds", int64(cfg.Database.HealthIntervalSeconds))
|
||||
writeBool("database_sync_enabled", cfg.Database.Sync.Enabled)
|
||||
writeInt("database_sync_interval_seconds", int64(cfg.Database.Sync.IntervalSeconds))
|
||||
writeInt("database_sync_batch_size", int64(cfg.Database.Sync.BatchSize))
|
||||
|
||||
writeKV("host", cfg.Mail.Host)
|
||||
writeInt("port", int64(cfg.Mail.Port))
|
||||
writeKV("secure", cfg.Mail.Secure)
|
||||
writeKV("username", cfg.Mail.Username)
|
||||
writeKV("password", cfg.Mail.Password)
|
||||
writeKV("from_address", cfg.Mail.FromAddress)
|
||||
writeKV("from_name", cfg.Mail.FromName)
|
||||
writeKV("developer_address", cfg.Mail.DeveloperAddress)
|
||||
writeInt("timeout_seconds", int64(cfg.Mail.TimeoutSeconds))
|
||||
writeKV("backup_dir", portablePath(cfg, cfg.Backup.Dir))
|
||||
|
||||
writeInt("submission_per_minute", int64(cfg.RateLimit.SubmissionPerMinute))
|
||||
writeInt("submission_burst", int64(cfg.RateLimit.SubmissionBurst))
|
||||
writeInt("status_per_minute", int64(cfg.RateLimit.StatusPerMinute))
|
||||
writeInt("status_burst", int64(cfg.RateLimit.StatusBurst))
|
||||
writeInt("captcha_per_minute", int64(cfg.RateLimit.CaptchaPerMinute))
|
||||
writeInt("captcha_burst", int64(cfg.RateLimit.CaptchaBurst))
|
||||
writeInt("login_per_minute", int64(cfg.RateLimit.LoginPerMinute))
|
||||
writeInt("login_burst", int64(cfg.RateLimit.LoginBurst))
|
||||
writeInt("admin_read_per_minute", int64(cfg.RateLimit.AdminReadPerMinute))
|
||||
writeInt("admin_read_burst", int64(cfg.RateLimit.AdminReadBurst))
|
||||
writeInt("admin_write_per_minute", int64(cfg.RateLimit.AdminWritePerMinute))
|
||||
writeInt("admin_write_burst", int64(cfg.RateLimit.AdminWriteBurst))
|
||||
|
||||
writeInt("max_zip_files", int64(cfg.UploadGuard.MaxZipFiles))
|
||||
writeInt("max_decompressed_bytes", cfg.UploadGuard.MaxDecompressedBytes)
|
||||
writeInt("max_single_file_bytes", cfg.UploadGuard.MaxSingleFileBytes)
|
||||
b.WriteString(" 'max_compression_ratio' => ")
|
||||
b.WriteString(strconv.FormatFloat(cfg.UploadGuard.MaxCompressionRatio, 'f', -1, 64))
|
||||
b.WriteString(",\n")
|
||||
writeInt("max_readable_text_bytes", cfg.UploadGuard.MaxReadableTextBytes)
|
||||
writeBool("allow_unexpected_zip_files", cfg.UploadGuard.AllowUnexpectedZipFiles)
|
||||
|
||||
b.WriteString(" 'webhooks' => [\n")
|
||||
for _, hook := range cfg.Webhooks {
|
||||
b.WriteString(" [\n")
|
||||
b.WriteString(" 'name' => " + quotePHP(hook.Name) + ",\n")
|
||||
b.WriteString(" 'url' => " + quotePHP(hook.URL) + ",\n")
|
||||
b.WriteString(" 'secret' => " + quotePHP(hook.Secret) + ",\n")
|
||||
if hook.Enabled {
|
||||
b.WriteString(" 'enabled' => true,\n")
|
||||
} else {
|
||||
b.WriteString(" 'enabled' => false,\n")
|
||||
}
|
||||
b.WriteString(" 'events' => [")
|
||||
for index, event := range hook.Events {
|
||||
if index > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(quotePHP(event))
|
||||
}
|
||||
b.WriteString("],\n")
|
||||
b.WriteString(" 'timeout_seconds' => " + strconv.Itoa(hook.TimeoutSeconds) + ",\n")
|
||||
b.WriteString(" 'max_retries' => " + strconv.Itoa(hook.MaxRetries) + ",\n")
|
||||
b.WriteString(" ],\n")
|
||||
}
|
||||
b.WriteString(" ],\n")
|
||||
b.WriteString("];\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func quotePHP(value string) string {
|
||||
replacer := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\r", `\r`, "\n", `\n`)
|
||||
return "'" + replacer.Replace(value) + "'"
|
||||
}
|
||||
|
||||
func portablePath(cfg *Config, value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || cfg == nil || strings.TrimSpace(cfg.BaseDir) == "" {
|
||||
return value
|
||||
}
|
||||
absValue, err := filepath.Abs(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
absBase, err := filepath.Abs(cfg.BaseDir)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
rel, err := filepath.Rel(absBase, absValue)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
||||
return value
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
var (
|
||||
legacyVarPattern = regexp.MustCompile(`(?s)\$([A-Za-z_][A-Za-z0-9_]*)\s*=\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")\s*;`)
|
||||
legacyEntryPattern = regexp.MustCompile(`(?s)['"]([A-Za-z0-9_]+)['"]\s*=>\s*([^,\]\r\n]+(?:\s*\*\s*\d+)*)`)
|
||||
)
|
||||
|
||||
func parseLegacyVars(text string) map[string]string {
|
||||
vars := map[string]string{}
|
||||
for _, match := range legacyVarPattern.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) == 3 {
|
||||
vars[match[1]] = stripQuoted(match[2])
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
func parseLegacyEntries(text string, vars map[string]string, dir string) map[string]string {
|
||||
entries := map[string]string{}
|
||||
for _, match := range legacyEntryPattern.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) != 3 {
|
||||
continue
|
||||
}
|
||||
key := match[1]
|
||||
value := strings.TrimSpace(match[2])
|
||||
entries[key] = parseLegacyValue(value, vars, dir)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func parseLegacyValue(value string, vars map[string]string, dir string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "__DIR__") {
|
||||
re := regexp.MustCompile(`__DIR__\s*\.\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")`)
|
||||
if match := re.FindStringSubmatch(value); len(match) == 2 {
|
||||
return filepath.Clean(dir + filepath.FromSlash(stripQuoted(match[1])))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "$") {
|
||||
name := strings.TrimPrefix(value, "$")
|
||||
if parsed, ok := vars[name]; ok {
|
||||
return parsed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "'") || strings.HasPrefix(value, `"`) {
|
||||
return stripQuoted(value)
|
||||
}
|
||||
|
||||
if result, ok := evalIntExpression(value); ok {
|
||||
return strconv.FormatInt(result, 10)
|
||||
}
|
||||
|
||||
return strings.Trim(value, " \t;")
|
||||
}
|
||||
|
||||
func parseLegacyWebhooks(text string, vars map[string]string, dir string) []WebhookConfig {
|
||||
block, ok := findLegacyArrayBlock(text, "webhooks")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := splitNestedArrayItems(block)
|
||||
hooks := make([]WebhookConfig, 0, len(items))
|
||||
for _, item := range items {
|
||||
entries := parseLegacyEntries(item, vars, dir)
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
hook := WebhookConfig{Enabled: true, TimeoutSeconds: 5, MaxRetries: 2}
|
||||
assignString(entries, "name", &hook.Name)
|
||||
assignString(entries, "url", &hook.URL)
|
||||
assignString(entries, "secret", &hook.Secret)
|
||||
assignBool(entries, "enabled", &hook.Enabled)
|
||||
assignInt(entries, "timeout_seconds", &hook.TimeoutSeconds)
|
||||
assignInt(entries, "max_retries", &hook.MaxRetries)
|
||||
hook.Events = parseStringArrayValue(item, "events")
|
||||
if hook.Name == "" && hook.URL != "" {
|
||||
hook.Name = "webhook"
|
||||
}
|
||||
if hook.URL != "" {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
}
|
||||
return hooks
|
||||
}
|
||||
|
||||
func findLegacyArrayBlock(text, key string) (string, bool) {
|
||||
re := regexp.MustCompile(`['"]` + regexp.QuoteMeta(key) + `['"]\s*=>\s*\[`)
|
||||
loc := re.FindStringIndex(text)
|
||||
if loc == nil {
|
||||
return "", false
|
||||
}
|
||||
open := loc[1] - 1
|
||||
depth := 0
|
||||
for index := open; index < len(text); index++ {
|
||||
switch text[index] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return text[open+1 : index], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitNestedArrayItems(block string) []string {
|
||||
items := []string{}
|
||||
depth := 0
|
||||
start := -1
|
||||
for index := 0; index < len(block); index++ {
|
||||
switch block[index] {
|
||||
case '[':
|
||||
if depth == 0 {
|
||||
start = index + 1
|
||||
}
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 && start >= 0 {
|
||||
items = append(items, block[start:index])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func parseStringArrayValue(block, key string) []string {
|
||||
sub, ok := findLegacyArrayBlock(block, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
re := regexp.MustCompile(`'(?:\\.|[^'])*'|"(?:\\.|[^"])*"`)
|
||||
values := []string{}
|
||||
for _, raw := range re.FindAllString(sub, -1) {
|
||||
if value := strings.TrimSpace(stripQuoted(raw)); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func stripQuoted(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 2 {
|
||||
return value
|
||||
}
|
||||
|
||||
quote := value[0]
|
||||
if (quote != '\'' && quote != '"') || value[len(value)-1] != quote {
|
||||
return value
|
||||
}
|
||||
|
||||
body := value[1 : len(value)-1]
|
||||
body = strings.ReplaceAll(body, `\\`, `\`)
|
||||
if quote == '\'' {
|
||||
body = strings.ReplaceAll(body, `\'`, `'`)
|
||||
} else {
|
||||
body = strings.ReplaceAll(body, `\"`, `"`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func evalIntExpression(value string) (int64, bool) {
|
||||
parts := strings.Split(value, "*")
|
||||
total := int64(1)
|
||||
seen := false
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(strings.TrimSuffix(part, ";"))
|
||||
if part == "" {
|
||||
return 0, false
|
||||
}
|
||||
number, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
total *= number
|
||||
seen = true
|
||||
}
|
||||
return total, seen
|
||||
}
|
||||
|
||||
func normalize(cfg *Config) {
|
||||
if cfg.BaseDir == "" {
|
||||
cfg.BaseDir, _ = os.Getwd()
|
||||
}
|
||||
if cfg.ClientSignatureKey == "" {
|
||||
cfg.ClientSignatureKey = defaultClientSignatureKey
|
||||
}
|
||||
if cfg.PackageEncryptionKey == "" {
|
||||
cfg.PackageEncryptionKey = defaultPackageEncryptionKey
|
||||
}
|
||||
if cfg.TimestampWindowSeconds <= 0 {
|
||||
cfg.TimestampWindowSeconds = 600
|
||||
}
|
||||
if cfg.MaxRequestBytes <= 0 {
|
||||
cfg.MaxRequestBytes = 12 * 1024 * 1024
|
||||
}
|
||||
if cfg.MaxPackageBytes <= 0 {
|
||||
cfg.MaxPackageBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if cfg.StorageDir == "" {
|
||||
cfg.StorageDir = filepath.Join(cfg.BaseDir, "storage")
|
||||
}
|
||||
if cfg.DatabasePath == "" {
|
||||
cfg.DatabasePath = filepath.Join(cfg.StorageDir, "feedback.sqlite")
|
||||
}
|
||||
if cfg.Database.Provider == "" {
|
||||
cfg.Database.Provider = "sqlite"
|
||||
}
|
||||
cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider))
|
||||
if cfg.Database.Provider != "mysql" && cfg.Database.Provider != "postgres" && cfg.Database.Provider != "pgsql" {
|
||||
cfg.Database.Provider = "sqlite"
|
||||
}
|
||||
if cfg.Database.Provider == "pgsql" {
|
||||
cfg.Database.Provider = "postgres"
|
||||
}
|
||||
if cfg.Database.SQLitePath == "" {
|
||||
cfg.Database.SQLitePath = cfg.DatabasePath
|
||||
}
|
||||
if cfg.Backup.Dir == "" {
|
||||
cfg.Backup.Dir = filepath.Join(cfg.StorageDir, "backups")
|
||||
}
|
||||
|
||||
cfg.StorageDir = normalizePath(cfg.BaseDir, cfg.StorageDir)
|
||||
cfg.DatabasePath = normalizePath(cfg.BaseDir, cfg.DatabasePath)
|
||||
cfg.Database.SQLitePath = normalizePath(cfg.BaseDir, cfg.Database.SQLitePath)
|
||||
cfg.DatabasePath = cfg.Database.SQLitePath
|
||||
cfg.Backup.Dir = normalizePath(cfg.BaseDir, cfg.Backup.Dir)
|
||||
if cfg.Database.Port <= 0 {
|
||||
switch cfg.Database.Provider {
|
||||
case "mysql":
|
||||
cfg.Database.Port = 3306
|
||||
case "postgres":
|
||||
cfg.Database.Port = 5432
|
||||
}
|
||||
}
|
||||
if cfg.Database.SSLMode == "" {
|
||||
cfg.Database.SSLMode = "disable"
|
||||
}
|
||||
if cfg.Database.MaxOpenConns <= 0 {
|
||||
cfg.Database.MaxOpenConns = 10
|
||||
}
|
||||
if cfg.Database.MaxIdleConns < 0 {
|
||||
cfg.Database.MaxIdleConns = 0
|
||||
}
|
||||
if cfg.Database.MaxIdleConns == 0 {
|
||||
cfg.Database.MaxIdleConns = 4
|
||||
}
|
||||
if cfg.Database.ConnMaxLifetimeSeconds <= 0 {
|
||||
cfg.Database.ConnMaxLifetimeSeconds = 300
|
||||
}
|
||||
if cfg.Database.HealthIntervalSeconds <= 0 {
|
||||
cfg.Database.HealthIntervalSeconds = 30
|
||||
}
|
||||
if cfg.Database.Sync.IntervalSeconds <= 0 {
|
||||
cfg.Database.Sync.IntervalSeconds = 24 * 60 * 60
|
||||
}
|
||||
if cfg.Database.Sync.BatchSize <= 0 {
|
||||
cfg.Database.Sync.BatchSize = 500
|
||||
}
|
||||
|
||||
if cfg.Mail.Port <= 0 {
|
||||
cfg.Mail.Port = 465
|
||||
}
|
||||
if cfg.Mail.Secure == "" {
|
||||
cfg.Mail.Secure = "ssl"
|
||||
}
|
||||
cfg.Mail.Secure = strings.ToLower(cfg.Mail.Secure)
|
||||
if cfg.Mail.FromAddress == "" {
|
||||
cfg.Mail.FromAddress = cfg.Mail.Username
|
||||
}
|
||||
if cfg.Mail.FromName == "" {
|
||||
cfg.Mail.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if cfg.Mail.TimeoutSeconds <= 0 {
|
||||
cfg.Mail.TimeoutSeconds = 20
|
||||
}
|
||||
|
||||
if cfg.RateLimit.SubmissionPerMinute <= 0 {
|
||||
cfg.RateLimit.SubmissionPerMinute = 20
|
||||
}
|
||||
if cfg.RateLimit.SubmissionBurst <= 0 {
|
||||
cfg.RateLimit.SubmissionBurst = 5
|
||||
}
|
||||
if cfg.RateLimit.StatusPerMinute <= 0 {
|
||||
cfg.RateLimit.StatusPerMinute = 120
|
||||
}
|
||||
if cfg.RateLimit.StatusBurst <= 0 {
|
||||
cfg.RateLimit.StatusBurst = 30
|
||||
}
|
||||
if cfg.RateLimit.CaptchaPerMinute <= 0 {
|
||||
cfg.RateLimit.CaptchaPerMinute = 60
|
||||
}
|
||||
if cfg.RateLimit.CaptchaBurst <= 0 {
|
||||
cfg.RateLimit.CaptchaBurst = 10
|
||||
}
|
||||
if cfg.RateLimit.LoginPerMinute <= 0 {
|
||||
cfg.RateLimit.LoginPerMinute = 12
|
||||
}
|
||||
if cfg.RateLimit.LoginBurst <= 0 {
|
||||
cfg.RateLimit.LoginBurst = 3
|
||||
}
|
||||
if cfg.RateLimit.AdminReadPerMinute <= 0 {
|
||||
cfg.RateLimit.AdminReadPerMinute = 300
|
||||
}
|
||||
if cfg.RateLimit.AdminReadBurst <= 0 {
|
||||
cfg.RateLimit.AdminReadBurst = 60
|
||||
}
|
||||
if cfg.RateLimit.AdminWritePerMinute <= 0 {
|
||||
cfg.RateLimit.AdminWritePerMinute = 90
|
||||
}
|
||||
if cfg.RateLimit.AdminWriteBurst <= 0 {
|
||||
cfg.RateLimit.AdminWriteBurst = 20
|
||||
}
|
||||
|
||||
if cfg.UploadGuard.MaxZipFiles <= 0 {
|
||||
cfg.UploadGuard.MaxZipFiles = 80
|
||||
}
|
||||
if cfg.UploadGuard.MaxDecompressedBytes <= 0 {
|
||||
cfg.UploadGuard.MaxDecompressedBytes = 30 * 1024 * 1024
|
||||
}
|
||||
if cfg.UploadGuard.MaxSingleFileBytes <= 0 {
|
||||
cfg.UploadGuard.MaxSingleFileBytes = 8 * 1024 * 1024
|
||||
}
|
||||
if cfg.UploadGuard.MaxCompressionRatio <= 0 {
|
||||
cfg.UploadGuard.MaxCompressionRatio = 120
|
||||
}
|
||||
if cfg.UploadGuard.MaxReadableTextBytes <= 0 {
|
||||
cfg.UploadGuard.MaxReadableTextBytes = 256 * 1024
|
||||
}
|
||||
|
||||
for index := range cfg.Webhooks {
|
||||
hook := &cfg.Webhooks[index]
|
||||
hook.Name = strings.TrimSpace(hook.Name)
|
||||
hook.URL = strings.TrimSpace(hook.URL)
|
||||
if hook.TimeoutSeconds <= 0 {
|
||||
hook.TimeoutSeconds = 5
|
||||
}
|
||||
if hook.MaxRetries < 0 {
|
||||
hook.MaxRetries = 0
|
||||
}
|
||||
if len(hook.Events) == 0 {
|
||||
hook.Events = []string{"feedback.created", "feedback.updated", "feedback.status_changed", "feedback.comment_created", "mail.failed"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePath(baseDir, value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
if filepath.IsAbs(value) {
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
return filepath.Clean(filepath.Join(baseDir, value))
|
||||
}
|
||||
|
||||
func assignString(entries map[string]string, key string, target *string) {
|
||||
if value, ok := entries[key]; ok {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
func assignInt(entries map[string]string, key string, target *int) {
|
||||
if value, ok := entries[key]; ok {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignIntAliases(entries map[string]string, target *int, keys ...string) {
|
||||
for _, key := range keys {
|
||||
if _, ok := entries[key]; ok {
|
||||
assignInt(entries, key, target)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignInt64(entries map[string]string, key string, target *int64) {
|
||||
if value, ok := entries[key]; ok {
|
||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignFloat(entries map[string]string, key string, target *float64) {
|
||||
if value, ok := entries[key]; ok {
|
||||
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignBool(entries map[string]string, key string, target *bool) {
|
||||
if value, ok := entries[key]; ok {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "1", "yes", "on":
|
||||
*target = true
|
||||
case "false", "0", "no", "off":
|
||||
*target = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadJSONConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `{
|
||||
"listen": ":9090",
|
||||
"admin_password": "secret",
|
||||
"client_signature_key": "client-key",
|
||||
"package_encryption_key": "package-key",
|
||||
"timestamp_window_seconds": 90,
|
||||
"max_request_bytes": 123,
|
||||
"max_package_bytes": 456,
|
||||
"storage_dir": "./data",
|
||||
"database_path": "./data/feedback.sqlite",
|
||||
"mail": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"secure": "starttls",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"from_address": "from@example.com",
|
||||
"from_name": "Feedback",
|
||||
"developer_address": "dev@example.com",
|
||||
"timeout_seconds": 7
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Listen != ":9090" || cfg.AdminPassword != "secret" || cfg.ClientSignatureKey != "client-key" {
|
||||
t.Fatalf("unexpected top-level config: %+v", cfg)
|
||||
}
|
||||
if cfg.StorageDir != filepath.Join(dir, "data") {
|
||||
t.Fatalf("storage dir was not normalized: %q", cfg.StorageDir)
|
||||
}
|
||||
if cfg.Mail.Host != "smtp.example.com" || cfg.Mail.Port != 587 || cfg.Mail.Secure != "starttls" {
|
||||
t.Fatalf("unexpected mail config: %+v", cfg.Mail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTxtTakesPriorityOverJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"admin_password":"json-secret"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := `<?php
|
||||
$adminPassword = 'txt-secret';
|
||||
return [
|
||||
'listen' => ':9191',
|
||||
'admin_password' => $adminPassword,
|
||||
'storage_dir' => __DIR__ . '/storage',
|
||||
'database_path' => __DIR__ . '/storage/feedback.sqlite',
|
||||
];`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AdminPassword != "txt-secret" || cfg.Listen != ":9191" {
|
||||
t.Fatalf("config.txt should win over config.json, got password=%q listen=%q", cfg.AdminPassword, cfg.Listen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTxtLegacyArrayConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `<?php
|
||||
$adminPassword = 'legacy-secret';
|
||||
return [
|
||||
'listen' => ':7070',
|
||||
'admin_password_hash' => '',
|
||||
'admin_password' => $adminPassword,
|
||||
'client_signature_key' => 'legacy-client',
|
||||
'package_encryption_key' => 'legacy-package',
|
||||
'timestamp_window_seconds' => 120,
|
||||
'max_request_bytes' => 12 * 1024 * 1024,
|
||||
'max_package_bytes' => 10 * 1024 * 1024,
|
||||
'storage_dir' => __DIR__ . '/storage',
|
||||
'database_path' => __DIR__ . '/storage/feedback.sqlite',
|
||||
'mail' => [
|
||||
'host' => 'mail.example.com',
|
||||
'port' => 465,
|
||||
'secure' => 'ssl',
|
||||
'username' => 'sender@example.com',
|
||||
'password' => 'mail-secret',
|
||||
'from_address' => 'sender@example.com',
|
||||
'from_name' => 'YMhut Box Feedback',
|
||||
'developer_address' => 'developer@example.com',
|
||||
'timeout_seconds' => 20,
|
||||
],
|
||||
];`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Listen != ":7070" || cfg.AdminPassword != "legacy-secret" || cfg.ClientSignatureKey != "legacy-client" || cfg.PackageEncryptionKey != "legacy-package" {
|
||||
t.Fatalf("unexpected legacy config: %+v", cfg)
|
||||
}
|
||||
if cfg.MaxRequestBytes != 12*1024*1024 || cfg.MaxPackageBytes != 10*1024*1024 {
|
||||
t.Fatalf("legacy integer expressions were not evaluated: %+v", cfg)
|
||||
}
|
||||
if cfg.StorageDir != filepath.Join(dir, "storage") || cfg.DatabasePath != filepath.Join(dir, "storage", "feedback.sqlite") {
|
||||
t.Fatalf("legacy __DIR__ paths were not normalized: %q %q", cfg.StorageDir, cfg.DatabasePath)
|
||||
}
|
||||
if cfg.Mail.Password != "mail-secret" || cfg.Mail.DeveloperAddress != "developer@example.com" {
|
||||
t.Fatalf("unexpected legacy mail config: %+v", cfg.Mail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTxtEnhancedSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `<?php
|
||||
return [
|
||||
'listen' => ':7071',
|
||||
'admin_password' => 'secret',
|
||||
'storage_dir' => __DIR__ . '/storage',
|
||||
'database_path' => __DIR__ . '/storage/feedback.sqlite',
|
||||
'submission_per_minute' => 9,
|
||||
'max_zip_files' => 12,
|
||||
'max_decompressed_bytes' => 1024 * 1024,
|
||||
'backup_dir' => __DIR__ . '/storage/backups',
|
||||
'webhooks' => [
|
||||
[
|
||||
'name' => 'ops',
|
||||
'url' => 'https://example.com/hook',
|
||||
'secret' => 'hook-secret',
|
||||
'enabled' => true,
|
||||
'events' => ['feedback.created', 'mail.failed'],
|
||||
'timeout_seconds' => 3,
|
||||
'max_retries' => 1,
|
||||
],
|
||||
],
|
||||
];`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.RateLimit.SubmissionPerMinute != 9 || cfg.UploadGuard.MaxZipFiles != 12 || cfg.UploadGuard.MaxDecompressedBytes != 1024*1024 {
|
||||
t.Fatalf("enhanced settings were not parsed: %+v", cfg)
|
||||
}
|
||||
if cfg.Backup.Dir != filepath.Join(dir, "storage", "backups") {
|
||||
t.Fatalf("backup dir was not normalized: %q", cfg.Backup.Dir)
|
||||
}
|
||||
if len(cfg.Webhooks) != 1 || cfg.Webhooks[0].Name != "ops" || cfg.Webhooks[0].Events[1] != "mail.failed" || cfg.Webhooks[0].MaxRetries != 1 {
|
||||
t.Fatalf("webhooks were not parsed: %+v", cfg.Webhooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTxtDatabaseSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `<?php
|
||||
return [
|
||||
'storage_dir' => __DIR__ . '/storage',
|
||||
'database_path' => __DIR__ . '/storage/feedback.sqlite',
|
||||
'database_provider' => 'postgres',
|
||||
'database_host' => 'db.example.com',
|
||||
'database_port' => 5433,
|
||||
'database_name' => 'feedback',
|
||||
'database_user' => 'feedback_user',
|
||||
'database_password' => 'db-secret',
|
||||
'database_ssl_mode' => 'require',
|
||||
'database_failover_enabled' => true,
|
||||
'database_sync_enabled' => true,
|
||||
'database_sync_interval_seconds' => 60,
|
||||
'database_sync_batch_size' => 25,
|
||||
];`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.Provider != "postgres" || cfg.Database.Host != "db.example.com" || cfg.Database.Port != 5433 || cfg.Database.Password != "db-secret" {
|
||||
t.Fatalf("database config was not parsed: %+v", cfg.Database)
|
||||
}
|
||||
if cfg.Database.SQLitePath != filepath.Join(dir, "storage", "feedback.sqlite") || cfg.DatabasePath != cfg.Database.SQLitePath {
|
||||
t.Fatalf("sqlite path compatibility failed: %q %q", cfg.Database.SQLitePath, cfg.DatabasePath)
|
||||
}
|
||||
if !cfg.Database.FailoverEnabled || !cfg.Database.Sync.Enabled || cfg.Database.Sync.IntervalSeconds != 60 || cfg.Database.Sync.BatchSize != 25 {
|
||||
t.Fatalf("database sync settings were not parsed: %+v", cfg.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigCreatesReloadableConfigTxt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Default(dir)
|
||||
cfg.Listen = ":9099"
|
||||
cfg.Database.Provider = "mysql"
|
||||
cfg.Database.Host = "mysql.example.com"
|
||||
cfg.Database.Name = "feedback"
|
||||
cfg.Database.User = "feedback_user"
|
||||
cfg.Database.Password = "mysql-secret"
|
||||
cfg.Webhooks = []WebhookConfig{{Name: "ops", URL: "https://example.com/hook", Secret: "hook-secret", Enabled: true, Events: []string{"feedback.created"}, TimeoutSeconds: 4, MaxRetries: 2}}
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := os.ReadFile(filepath.Join(dir, "config.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(saved)
|
||||
if strings.Contains(text, dir) {
|
||||
t.Fatalf("saved config should not contain deploy-root absolute paths: %s", text)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"'storage_dir' => 'storage'",
|
||||
"'database_path' => 'storage/feedback.sqlite'",
|
||||
"'database_sqlite_path' => 'storage/feedback.sqlite'",
|
||||
"'backup_dir' => 'storage/backups'",
|
||||
} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Fatalf("saved config is missing portable path %q: %s", expected, text)
|
||||
}
|
||||
}
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Listen != ":9099" || loaded.Database.Provider != "mysql" || loaded.Database.Password != "mysql-secret" {
|
||||
t.Fatalf("saved config did not reload: %+v", loaded.Database)
|
||||
}
|
||||
if len(loaded.Webhooks) != 1 || loaded.Webhooks[0].Secret != "hook-secret" {
|
||||
t.Fatalf("saved webhooks did not reload: %+v", loaded.Webhooks)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
)
|
||||
|
||||
func TestOpenMigratesLegacyFeedbackColumns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storage := filepath.Join(dir, "storage")
|
||||
if err := os.MkdirAll(storage, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dbPath := filepath.Join(storage, "feedback.sqlite")
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = conn.Exec(`CREATE TABLE feedbacks (
|
||||
code TEXT PRIMARY KEY,
|
||||
received_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
contact TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
package_path TEXT NOT NULL,
|
||||
package_sha256 TEXT NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
summary_text TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := config.Default(dir)
|
||||
cfg.StorageDir = storage
|
||||
cfg.DatabasePath = dbPath
|
||||
store, err := Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
for _, column := range []string{"public_reply", "included_files", "mail_sent", "encrypted_package_path", "plain_package_sha256", "assignee", "due_at", "sla_level", "source_channel", "risk_score", "resolution"} {
|
||||
if !hasColumn(t, store.db, "feedbacks", column) {
|
||||
t.Fatalf("expected migrated column %q", column)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"feedback_comments", "feedback_tags", "audit_logs", "webhook_deliveries"} {
|
||||
if !hasTable(t, store.db, table) {
|
||||
t.Fatalf("expected migrated table %q", table)
|
||||
}
|
||||
}
|
||||
if mode := store.WALMode(); mode != "wal" {
|
||||
t.Fatalf("expected wal mode, got %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketExtensionsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := config.Default(dir)
|
||||
store, err := Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
record := FeedbackRecord{
|
||||
Code: "FB-20260606-ABC123",
|
||||
ReceivedAt: Now(),
|
||||
Title: "Crash",
|
||||
Type: "issue",
|
||||
Severity: "major",
|
||||
Contact: "dev@example.com",
|
||||
Body: "Steps",
|
||||
Status: "new",
|
||||
StatusDetail: "received",
|
||||
PackagePath: "a.zip",
|
||||
EncryptedPackagePath: "a.ymfb",
|
||||
PackageSha256: strings.Repeat("a", 64),
|
||||
PlainPackageSha256: strings.Repeat("b", 64),
|
||||
RemoteAddr: "127.0.0.1",
|
||||
SummaryText: "summary",
|
||||
IncludedFiles: "feedback.json",
|
||||
UpdatedAt: Now(),
|
||||
LastActivityAt: Now(),
|
||||
Tags: []string{"crash", "UI"},
|
||||
}
|
||||
if err := store.InsertFeedback(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateFeedback(record.Code, FeedbackUpdate{
|
||||
Status: "investigating",
|
||||
Category: "issue",
|
||||
Priority: "major",
|
||||
StatusDetail: "checking",
|
||||
Assignee: "alice",
|
||||
SLALevel: "elevated",
|
||||
Note: "internal",
|
||||
PublicReply: "reply",
|
||||
Actor: "alice",
|
||||
Tags: []string{"crash", "priority"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.InsertComment(FeedbackComment{FeedbackCode: record.Code, Author: "alice", Body: "comment", Internal: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InsertAudit(AuditLog{Actor: "alice", Type: "feedback.updated", Target: record.Code, Message: "updated"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.InsertWebhookDelivery(WebhookDelivery{WebhookName: "ops", Event: "feedback.updated", Status: "pending"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
detail, err := store.GetFeedbackDetail(record.Code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Assignee != "alice" || detail.SLALevel != "elevated" || len(detail.Comments) != 1 || len(detail.Tags) != 2 {
|
||||
t.Fatalf("unexpected detail: %+v", detail)
|
||||
}
|
||||
page, err := store.ListFeedbacks(1, 20, FeedbackFilters{Assignee: "alice", Tag: "priority", SLA: "elevated"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 {
|
||||
t.Fatalf("expected filtered ticket, got %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDatabaseConfigSwitchesSQLitePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := config.Default(dir)
|
||||
store, err := Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
next := cfg.Database
|
||||
next.Provider = "sqlite"
|
||||
next.SQLitePath = filepath.Join(dir, "storage", "next.sqlite")
|
||||
if err := store.ApplyDatabaseConfig(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.Status().ActiveProvider != "sqlite" {
|
||||
t.Fatalf("expected sqlite active provider, got %+v", store.Status())
|
||||
}
|
||||
if _, err := os.Stat(next.SQLitePath); err != nil {
|
||||
t.Fatalf("expected new sqlite database at %s: %v", next.SQLitePath, err)
|
||||
}
|
||||
if !hasTable(t, store.DB(), "feedbacks") {
|
||||
t.Fatal("expected migrated feedbacks table on switched sqlite database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseDSNEncodesRemoteCredentials(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqliteDSN, err := databaseDSN(config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: "storage/feedback.sqlite",
|
||||
}, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqliteDSN != filepath.Join(dir, "storage", "feedback.sqlite") {
|
||||
t.Fatalf("relative sqlite path should resolve from service root, got %q", sqliteDSN)
|
||||
}
|
||||
|
||||
mysqlDSN, err := databaseDSN(config.DatabaseConfig{
|
||||
Provider: "mysql",
|
||||
Host: "db.example.com",
|
||||
Port: 3307,
|
||||
Name: "feedback_db",
|
||||
User: "feedback_user",
|
||||
Password: "p@ss/word",
|
||||
}, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedMySQL, err := mysql.ParseDSN(mysqlDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsedMySQL.User != "feedback_user" || parsedMySQL.Passwd != "p@ss/word" || parsedMySQL.DBName != "feedback_db" || parsedMySQL.Addr != "db.example.com:3307" {
|
||||
t.Fatalf("mysql DSN did not preserve settings: %+v", parsedMySQL)
|
||||
}
|
||||
|
||||
postgresDSN, err := databaseDSN(config.DatabaseConfig{
|
||||
Provider: "postgres",
|
||||
Host: "pg.example.com",
|
||||
Port: 5433,
|
||||
Name: "feedback/db",
|
||||
User: "feedback:user",
|
||||
Password: "p@ss/word",
|
||||
SSLMode: "require",
|
||||
}, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(postgresDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Scheme != "postgres" || parsed.Host != "pg.example.com:5433" || parsed.Query().Get("sslmode") != "require" {
|
||||
t.Fatalf("postgres DSN was not safely formatted: %s", postgresDSN)
|
||||
}
|
||||
if password, ok := parsed.User.Password(); !ok || password != "p@ss/word" || parsed.User.Username() != "feedback:user" {
|
||||
t.Fatalf("postgres credentials were not preserved: %s", postgresDSN)
|
||||
}
|
||||
}
|
||||
|
||||
func hasColumn(t *testing.T, conn *sql.DB, table, column string) bool {
|
||||
t.Helper()
|
||||
rows, err := conn.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasTable(t *testing.T, conn *sql.DB, table string) bool {
|
||||
t.Helper()
|
||||
row := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table)
|
||||
var name string
|
||||
if err := row.Scan(&name); err != nil {
|
||||
return false
|
||||
}
|
||||
return name == table
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
)
|
||||
|
||||
type dialect struct {
|
||||
name string
|
||||
driverName string
|
||||
}
|
||||
|
||||
func dialectFor(provider string) dialect {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "mysql":
|
||||
return dialect{name: "mysql", driverName: "mysql"}
|
||||
case "postgres", "pgsql":
|
||||
return dialect{name: "postgres", driverName: "pgx"}
|
||||
default:
|
||||
return dialect{name: "sqlite", driverName: "sqlite"}
|
||||
}
|
||||
}
|
||||
|
||||
func (d dialect) rebind(query string) string {
|
||||
if d.name != "postgres" {
|
||||
return query
|
||||
}
|
||||
var builder strings.Builder
|
||||
index := 1
|
||||
inSingle := false
|
||||
for i := 0; i < len(query); i++ {
|
||||
ch := query[i]
|
||||
if ch == '\'' {
|
||||
inSingle = !inSingle
|
||||
builder.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == '?' && !inSingle {
|
||||
builder.WriteByte('$')
|
||||
builder.WriteString(strconv.Itoa(index))
|
||||
index++
|
||||
continue
|
||||
}
|
||||
builder.WriteByte(ch)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (d dialect) boolValue(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d dialect) insertIgnore(table string, columns, conflict []string) string {
|
||||
placeholders := make([]string, len(columns))
|
||||
for i := range placeholders {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
|
||||
switch d.name {
|
||||
case "mysql":
|
||||
return strings.Replace(base, "INSERT INTO", "INSERT IGNORE INTO", 1)
|
||||
case "postgres":
|
||||
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO NOTHING"
|
||||
default:
|
||||
return strings.Replace(base, "INSERT INTO", "INSERT OR IGNORE INTO", 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (d dialect) upsert(table string, columns, conflict []string) string {
|
||||
placeholders := make([]string, len(columns))
|
||||
for i := range placeholders {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
|
||||
updateColumns := make([]string, 0, len(columns))
|
||||
conflictSet := map[string]bool{}
|
||||
for _, column := range conflict {
|
||||
conflictSet[column] = true
|
||||
}
|
||||
for _, column := range columns {
|
||||
if conflictSet[column] {
|
||||
continue
|
||||
}
|
||||
switch d.name {
|
||||
case "mysql":
|
||||
updateColumns = append(updateColumns, fmt.Sprintf("%s = VALUES(%s)", column, column))
|
||||
case "postgres":
|
||||
updateColumns = append(updateColumns, fmt.Sprintf("%s = EXCLUDED.%s", column, column))
|
||||
default:
|
||||
updateColumns = append(updateColumns, fmt.Sprintf("%s = excluded.%s", column, column))
|
||||
}
|
||||
}
|
||||
if len(updateColumns) == 0 {
|
||||
return d.insertIgnore(table, columns, conflict)
|
||||
}
|
||||
switch d.name {
|
||||
case "mysql":
|
||||
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updateColumns, ", ")
|
||||
case "postgres":
|
||||
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
|
||||
default:
|
||||
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
func (d dialect) textType() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (d dialect) idType() string {
|
||||
switch d.name {
|
||||
case "mysql":
|
||||
return "BIGINT PRIMARY KEY AUTO_INCREMENT"
|
||||
case "postgres":
|
||||
return "BIGSERIAL PRIMARY KEY"
|
||||
default:
|
||||
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||
}
|
||||
}
|
||||
|
||||
func (d dialect) columnDefault(def string) string {
|
||||
def = strings.ReplaceAll(def, `DEFAULT ""`, `DEFAULT ''`)
|
||||
if d.name == "mysql" {
|
||||
def = strings.ReplaceAll(def, `TEXT NOT NULL DEFAULT ''`, `VARCHAR(3000) NOT NULL DEFAULT ''`)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func openSQLDatabase(cfg config.DatabaseConfig, baseDir string) (*sql.DB, dialect, error) {
|
||||
d := dialectFor(cfg.Provider)
|
||||
dsn, err := databaseDSN(cfg, baseDir)
|
||||
if err != nil {
|
||||
return nil, d, err
|
||||
}
|
||||
if d.name == "sqlite" && !strings.HasPrefix(strings.ToLower(dsn), "file:") {
|
||||
if err := os.MkdirAll(filepath.Dir(dsn), 0o750); err != nil {
|
||||
return nil, d, err
|
||||
}
|
||||
}
|
||||
conn, err := sql.Open(d.driverName, dsn)
|
||||
if err != nil {
|
||||
return nil, d, err
|
||||
}
|
||||
conn.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
conn.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
conn.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.PingContext(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, d, err
|
||||
}
|
||||
return conn, d, nil
|
||||
}
|
||||
|
||||
func databaseDSN(cfg config.DatabaseConfig, baseDir string) (string, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
|
||||
switch provider {
|
||||
case "", "sqlite":
|
||||
path := strings.TrimSpace(cfg.SQLitePath)
|
||||
if path == "" {
|
||||
path = filepath.Join(baseDir, "storage", "feedback.sqlite")
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(path), "file:") {
|
||||
return path, nil
|
||||
}
|
||||
if !filepath.IsAbs(path) && !strings.HasPrefix(strings.ToLower(path), "file:") {
|
||||
path = filepath.Join(baseDir, path)
|
||||
}
|
||||
return filepath.Clean(path), nil
|
||||
case "mysql":
|
||||
if strings.TrimSpace(cfg.DSN) != "" {
|
||||
return cfg.DSN, nil
|
||||
}
|
||||
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
|
||||
return "", errors.New("mysql host, name and user are required")
|
||||
}
|
||||
host := cfg.Host
|
||||
if cfg.Port > 0 {
|
||||
host = host + ":" + strconv.Itoa(cfg.Port)
|
||||
}
|
||||
mysqlCfg := mysql.NewConfig()
|
||||
mysqlCfg.User = cfg.User
|
||||
mysqlCfg.Passwd = cfg.Password
|
||||
mysqlCfg.Net = "tcp"
|
||||
mysqlCfg.Addr = host
|
||||
mysqlCfg.DBName = cfg.Name
|
||||
mysqlCfg.ParseTime = true
|
||||
mysqlCfg.Loc = time.Local
|
||||
mysqlCfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||
if cfg.SSLMode != "" && cfg.SSLMode != "disable" {
|
||||
mysqlCfg.TLSConfig = cfg.SSLMode
|
||||
}
|
||||
return mysqlCfg.FormatDSN(), nil
|
||||
case "postgres", "pgsql":
|
||||
if strings.TrimSpace(cfg.DSN) != "" {
|
||||
return cfg.DSN, nil
|
||||
}
|
||||
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
|
||||
return "", errors.New("postgres host, name and user are required")
|
||||
}
|
||||
host := cfg.Host
|
||||
if cfg.Port > 0 {
|
||||
host = host + ":" + strconv.Itoa(cfg.Port)
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(cfg.User, cfg.Password),
|
||||
Host: host,
|
||||
Path: "/" + cfg.Name,
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("sslmode", defaultString(cfg.SSLMode, "disable"))
|
||||
u.RawQuery = params.Encode()
|
||||
return u.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported database provider %q", cfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabase(cfg config.DatabaseConfig, baseDir string) error {
|
||||
conn, d, err := openSQLDatabase(cfg, baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.PingContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
create := `CREATE TEMPORARY TABLE ymhut_connection_test (id INTEGER)`
|
||||
if d.name == "postgres" {
|
||||
create = `CREATE TEMP TABLE ymhut_connection_test (id INTEGER)`
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, d.rebind(create)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SyncResult struct {
|
||||
Direction string `json:"direction"`
|
||||
Tables map[string]int `json:"tables"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type tableSpec struct {
|
||||
Name string
|
||||
Columns []string
|
||||
Conflict []string
|
||||
}
|
||||
|
||||
var syncTables = []tableSpec{
|
||||
{"feedbacks", []string{"code", "received_at", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "note", "public_reply", "handled_by", "assignee", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "remote_addr", "summary_text", "included_files", "mail_sent", "updated_at", "last_activity_at"}, []string{"code"}},
|
||||
{"mail_records", []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}, []string{"id"}},
|
||||
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
|
||||
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
|
||||
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
|
||||
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
||||
{"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}},
|
||||
}
|
||||
|
||||
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
||||
s.mu.RLock()
|
||||
remote := s.remoteDB
|
||||
remoteDialect := s.remoteDialect
|
||||
local := s.localDB
|
||||
localDialect := s.localDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil {
|
||||
return SyncResult{}, fmt.Errorf("remote database is not configured")
|
||||
}
|
||||
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
||||
s.setSyncStatus(result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Store) SyncNow() (SyncResult, error) {
|
||||
return s.syncRemoteToSQLite()
|
||||
}
|
||||
|
||||
func (s *Store) syncRemoteToSQLite() (SyncResult, error) {
|
||||
s.mu.RLock()
|
||||
remote := s.remoteDB
|
||||
remoteDialect := s.remoteDialect
|
||||
local := s.localDB
|
||||
localDialect := s.localDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil {
|
||||
return SyncResult{}, nil
|
||||
}
|
||||
result, err := copyAllTables(remote, remoteDialect, local, localDialect, "remote_to_sqlite")
|
||||
s.setSyncStatus(result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Store) setSyncStatus(result SyncResult, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
s.status.LastSyncError = err.Error()
|
||||
return
|
||||
}
|
||||
if result.FinishedAt != "" {
|
||||
s.status.LastSyncAt = result.FinishedAt
|
||||
}
|
||||
s.status.LastSyncError = ""
|
||||
}
|
||||
|
||||
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
|
||||
result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()}
|
||||
for _, table := range syncTables {
|
||||
copied, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Tables[table.Name] = copied
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
||||
selectSQL := "SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name
|
||||
rows, err := src.Query(srcDialect.rebind(selectSQL))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
insertSQL := dstDialect.rebind(dstDialect.upsert(spec.Name, spec.Columns, spec.Conflict))
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
values := make([]any, len(spec.Columns))
|
||||
ptrs := make([]any, len(spec.Columns))
|
||||
for index := range values {
|
||||
ptrs[index] = &values[index]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return count, err
|
||||
}
|
||||
for index, value := range values {
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
values[index] = string(bytes)
|
||||
}
|
||||
}
|
||||
if _, err := dst.Exec(insertSQL, values...); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
feedbackmail "ymhut-box/server/feedback-mailer/internal/mail"
|
||||
"ymhut-box/server/feedback-mailer/internal/webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrorMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
||||
ErrorTooLarge = "TOO_LARGE"
|
||||
ErrorMissingField = "MISSING_FIELD"
|
||||
ErrorInvalidPayload = "INVALID_PAYLOAD"
|
||||
ErrorInvalidTimestamp = "INVALID_TIMESTAMP"
|
||||
ErrorInvalidSignature = "INVALID_SIGNATURE"
|
||||
ErrorInvalidPackage = "INVALID_PACKAGE"
|
||||
ErrorInvalidEncryptedPackage = "INVALID_ENCRYPTED_PACKAGE"
|
||||
ErrorDecryptFailed = "DECRYPT_FAILED"
|
||||
ErrorHashMismatch = "HASH_MISMATCH"
|
||||
ErrorMailFailed = "MAIL_FAILED"
|
||||
ErrorServerConfig = "SERVER_CONFIG"
|
||||
ErrorNotFound = "NOT_FOUND"
|
||||
|
||||
PackageMagic = "YMHUTFB1"
|
||||
)
|
||||
|
||||
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
hooks *webhook.Dispatcher
|
||||
}
|
||||
|
||||
type StatusPayload struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
HasReply bool `json:"hasReply"`
|
||||
Reply string `json:"reply"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
MailSent bool `json:"mailSent"`
|
||||
Duplicate bool `json:"duplicate,omitempty"`
|
||||
}
|
||||
|
||||
type submissionPayload struct {
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Contact string `json:"contact"`
|
||||
BodyLength int `json:"bodyLength"`
|
||||
PackageEncrypted bool `json:"packageEncrypted"`
|
||||
Encryption string `json:"encryption"`
|
||||
PackageBytes int64 `json:"packageBytes"`
|
||||
PackageSha256 string `json:"packageSha256"`
|
||||
PlainPackageBytes int64 `json:"plainPackageBytes"`
|
||||
PlainPackageSha256 string `json:"plainPackageSha256"`
|
||||
CreatedAt json.RawMessage `json:"createdAt"`
|
||||
}
|
||||
|
||||
type packageInfo struct {
|
||||
Request map[string]any
|
||||
Summary string
|
||||
Files []string
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store, hooks *webhook.Dispatcher) *Service {
|
||||
return &Service{cfg: cfg, store: store, hooks: hooks}
|
||||
}
|
||||
|
||||
func (s *Service) HandleSubmission(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
Fail(c, ErrorMethodNotAllowed, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
|
||||
if s.cfg.MaxRequestBytes > 0 {
|
||||
if c.Request.ContentLength > s.cfg.MaxRequestBytes {
|
||||
Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Request is too large")
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, s.cfg.MaxRequestBytes)
|
||||
}
|
||||
|
||||
payloadText, ok := requireForm(c, "payload")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
timestamp, ok := requireForm(c, "timestamp")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
nonce, ok := requireForm(c, "nonce")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
packageSha256, ok := requireForm(c, "packageSha256")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
signature, ok := requireForm(c, "signature")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var payload submissionPayload
|
||||
if err := json.Unmarshal([]byte(payloadText), &payload); err != nil {
|
||||
Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid payload JSON")
|
||||
return
|
||||
}
|
||||
if err := validatePayload(payloadText, payload); err != nil {
|
||||
Fail(c, errCode(err), http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) {
|
||||
Fail(c, ErrorInvalidTimestamp, http.StatusBadRequest, "Timestamp outside accepted window")
|
||||
return
|
||||
}
|
||||
|
||||
packageSha256 = strings.ToLower(strings.TrimSpace(packageSha256))
|
||||
signature = strings.ToLower(strings.TrimSpace(signature))
|
||||
if !isHexSHA256(packageSha256) {
|
||||
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Invalid package hash")
|
||||
return
|
||||
}
|
||||
if s.cfg.ClientSignatureKey == "" {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Missing client signature key")
|
||||
return
|
||||
}
|
||||
expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
Fail(c, ErrorInvalidSignature, http.StatusUnauthorized, "Invalid request signature")
|
||||
return
|
||||
}
|
||||
|
||||
code := NormalizeCode(payload.FeedbackCode)
|
||||
if code == "" {
|
||||
code = s.generateCode()
|
||||
}
|
||||
|
||||
existing, err := s.store.FetchStatus(code)
|
||||
if err != nil {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error")
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
response := StatusFromRow(*existing)
|
||||
response.Duplicate = true
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("package")
|
||||
if err != nil {
|
||||
Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing package file")
|
||||
return
|
||||
}
|
||||
data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errUploadTooLarge) {
|
||||
Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Feedback package is too large")
|
||||
} else {
|
||||
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Package upload failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte(PackageMagic)) {
|
||||
Fail(c, ErrorInvalidEncryptedPackage, http.StatusBadRequest, "Encrypted package format is invalid")
|
||||
return
|
||||
}
|
||||
actual := sha256Hex(data)
|
||||
if !hmac.Equal([]byte(actual), []byte(packageSha256)) {
|
||||
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Package hash mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
encryptedPath := filepath.Join(s.cfg.StorageDir, code+".ymfb")
|
||||
packagePath := filepath.Join(s.cfg.StorageDir, code+".zip")
|
||||
if err := os.WriteFile(encryptedPath, data, 0o640); err != nil {
|
||||
Fail(c, ErrorInvalidPackage, http.StatusInternalServerError, "Unable to save package")
|
||||
return
|
||||
}
|
||||
|
||||
plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey)
|
||||
if err != nil {
|
||||
Fail(c, ErrorDecryptFailed, http.StatusBadRequest, "Unable to decrypt package")
|
||||
return
|
||||
}
|
||||
if !isZipBytes(plain) {
|
||||
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Decrypted package is not a zip file")
|
||||
return
|
||||
}
|
||||
if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) {
|
||||
if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) {
|
||||
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Decrypted package hash mismatch")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard)
|
||||
if err != nil {
|
||||
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Unable to read feedback package")
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(packagePath, plain, 0o640); err != nil {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to write decrypted package")
|
||||
return
|
||||
}
|
||||
|
||||
record := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), c.ClientIP())
|
||||
if err := s.store.InsertFeedback(record); err != nil {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain feedback")
|
||||
return
|
||||
}
|
||||
s.hooks.Dispatch("feedback.created", record)
|
||||
|
||||
message, err := feedbackmail.BuildFeedbackMessage(s.cfg, record, packagePath)
|
||||
if err != nil {
|
||||
s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "error": err.Error()})
|
||||
Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained")
|
||||
return
|
||||
}
|
||||
mailID, err := s.store.InsertMail(db.MailRecord{
|
||||
FeedbackCode: record.Code,
|
||||
Kind: "feedback",
|
||||
Status: "pending",
|
||||
ToAddress: message.To,
|
||||
Subject: message.Subject,
|
||||
PlainBody: message.PlainBody,
|
||||
HTMLBody: message.HTMLBody,
|
||||
AttachmentPath: message.AttachmentPath,
|
||||
AttachmentName: message.AttachmentName,
|
||||
CreatedAt: db.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain mail record")
|
||||
return
|
||||
}
|
||||
if err := feedbackmail.Send(s.cfg, message); err != nil {
|
||||
_ = s.store.UpdateMailState(mailID, "failed", shortenPlain(err.Error(), 1000))
|
||||
_ = s.store.UpdateFeedbackMailState(code, false)
|
||||
s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "mailId": mailID, "error": err.Error()})
|
||||
Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained")
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateMailState(mailID, "sent", "")
|
||||
_ = s.store.UpdateFeedbackMailState(code, true)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "code": code})
|
||||
}
|
||||
|
||||
func (s *Service) HandleStatus(c *gin.Context) {
|
||||
code := NormalizeCode(c.Query("code"))
|
||||
if code == "" {
|
||||
Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid feedback code")
|
||||
return
|
||||
}
|
||||
row, err := s.store.FetchStatus(code)
|
||||
if err != nil {
|
||||
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error")
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
Fail(c, ErrorNotFound, http.StatusNotFound, "Feedback not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, StatusFromRow(*row))
|
||||
}
|
||||
|
||||
func StatusFromRow(row db.StatusRow) StatusPayload {
|
||||
reply := strings.TrimSpace(row.PublicReply)
|
||||
return StatusPayload{
|
||||
OK: true,
|
||||
Code: row.Code,
|
||||
Status: row.Status,
|
||||
StatusLabel: StatusLabel(row.Status),
|
||||
StatusDetail: row.StatusDetail,
|
||||
Category: row.Category,
|
||||
Priority: row.Priority,
|
||||
HasReply: reply != "",
|
||||
Reply: reply,
|
||||
ReceivedAt: row.ReceivedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
MailSent: row.MailSent,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if feedbackCodePattern.MatchString(code) {
|
||||
return code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SignWithKey(key, timestamp, nonce, packageSha256, payload string) string {
|
||||
material := timestamp + "\n" + nonce + "\n" + packageSha256 + "\n" + payload
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = mac.Write([]byte(material))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func validTimestamp(value string, windowSeconds int64) bool {
|
||||
if !regexp.MustCompile(`^[0-9]{10,}$`).MatchString(value) {
|
||||
return false
|
||||
}
|
||||
seconds, err := time.ParseDuration(value + "s")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delta := time.Now().Unix() - int64(seconds.Seconds())
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return delta <= windowSeconds
|
||||
}
|
||||
|
||||
func validatePayload(payloadText string, payload submissionPayload) error {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(payloadText), &raw); err != nil {
|
||||
return codeError{code: ErrorInvalidPayload, message: "Invalid payload JSON"}
|
||||
}
|
||||
for _, field := range []string{"title", "type", "severity", "bodyLength", "packageBytes", "packageSha256", "plainPackageSha256", "createdAt"} {
|
||||
if _, ok := raw[field]; !ok {
|
||||
return fieldError("Payload missing field: " + field)
|
||||
}
|
||||
}
|
||||
if payload.Title == "" {
|
||||
return fieldError("Payload missing field: title")
|
||||
}
|
||||
if payload.Type == "" {
|
||||
return fieldError("Payload missing field: type")
|
||||
}
|
||||
if payload.Severity == "" {
|
||||
return fieldError("Payload missing field: severity")
|
||||
}
|
||||
if payload.BodyLength < 0 {
|
||||
return fieldError("Payload missing field: bodyLength")
|
||||
}
|
||||
if payload.PackageBytes <= 0 {
|
||||
return fieldError("Payload missing field: packageBytes")
|
||||
}
|
||||
if payload.PackageSha256 == "" {
|
||||
return fieldError("Payload missing field: packageSha256")
|
||||
}
|
||||
if payload.PlainPackageSha256 == "" {
|
||||
return fieldError("Payload missing field: plainPackageSha256")
|
||||
}
|
||||
if len(payload.CreatedAt) == 0 {
|
||||
return fieldError("Payload missing field: createdAt")
|
||||
}
|
||||
if !payload.PackageEncrypted || payload.Encryption != PackageMagic {
|
||||
return codeError{code: ErrorInvalidEncryptedPackage, message: "Encrypted package is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fieldError string
|
||||
|
||||
func (e fieldError) Error() string { return string(e) }
|
||||
|
||||
type codeError struct {
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e codeError) Error() string { return e.message }
|
||||
|
||||
func errCode(err error) string {
|
||||
var coded codeError
|
||||
if errors.As(err, &coded) {
|
||||
return coded.code
|
||||
}
|
||||
return ErrorMissingField
|
||||
}
|
||||
|
||||
func requireForm(c *gin.Context, key string) (string, bool) {
|
||||
value := strings.TrimSpace(c.PostForm(key))
|
||||
if value == "" {
|
||||
Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing field: "+key)
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
var errUploadTooLarge = errors.New("upload too large")
|
||||
|
||||
func readUploadedPackage(file *multipart.FileHeader, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes > 0 && file.Size > maxBytes {
|
||||
return nil, errUploadTooLarge
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
limit := maxBytes + 1
|
||||
if limit <= 1 {
|
||||
limit = 10*1024*1024 + 1
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stream, limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
||||
return nil, errUploadTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func DecryptPackage(data []byte, keyMaterial string) ([]byte, error) {
|
||||
if len(data) < len(PackageMagic)+12+16 || !bytes.HasPrefix(data, []byte(PackageMagic)) {
|
||||
return nil, errors.New("encrypted package format is invalid")
|
||||
}
|
||||
if keyMaterial == "" {
|
||||
keyMaterial = "ymhut-box-feedback-package-v1"
|
||||
}
|
||||
key := sha256.Sum256([]byte(keyMaterial))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := len(PackageMagic)
|
||||
nonce := data[offset : offset+12]
|
||||
offset += 12
|
||||
tag := data[offset : offset+16]
|
||||
offset += 16
|
||||
ciphertext := data[offset:]
|
||||
combined := make([]byte, 0, len(ciphertext)+len(tag))
|
||||
combined = append(combined, ciphertext...)
|
||||
combined = append(combined, tag...)
|
||||
return gcm.Open(nil, nonce, combined, []byte(PackageMagic))
|
||||
}
|
||||
|
||||
func ReadFeedbackPackage(plain []byte) (packageInfo, error) {
|
||||
return ReadFeedbackPackageWithGuard(plain, config.Default(".").UploadGuard)
|
||||
}
|
||||
|
||||
func ReadFeedbackPackageWithGuard(plain []byte, guard config.UploadGuardConfig) (packageInfo, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(plain), int64(len(plain)))
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
if guard.MaxZipFiles <= 0 {
|
||||
guard.MaxZipFiles = 80
|
||||
}
|
||||
if guard.MaxDecompressedBytes <= 0 {
|
||||
guard.MaxDecompressedBytes = 30 * 1024 * 1024
|
||||
}
|
||||
if guard.MaxSingleFileBytes <= 0 {
|
||||
guard.MaxSingleFileBytes = 8 * 1024 * 1024
|
||||
}
|
||||
if guard.MaxCompressionRatio <= 0 {
|
||||
guard.MaxCompressionRatio = 120
|
||||
}
|
||||
if guard.MaxReadableTextBytes <= 0 {
|
||||
guard.MaxReadableTextBytes = 256 * 1024
|
||||
}
|
||||
|
||||
files := []string{}
|
||||
texts := map[string]string{}
|
||||
var total uint64
|
||||
for _, entry := range reader.File {
|
||||
if entry.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
cleanName, err := safeZipName(entry.Name)
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
if len(files)+1 > guard.MaxZipFiles {
|
||||
return packageInfo{}, errors.New("zip contains too many files")
|
||||
}
|
||||
if entry.UncompressedSize64 > uint64(guard.MaxSingleFileBytes) {
|
||||
return packageInfo{}, errors.New("zip entry is too large")
|
||||
}
|
||||
total += entry.UncompressedSize64
|
||||
if total > uint64(guard.MaxDecompressedBytes) {
|
||||
return packageInfo{}, errors.New("zip decompressed size is too large")
|
||||
}
|
||||
if entry.CompressedSize64 == 0 && entry.UncompressedSize64 > 0 {
|
||||
return packageInfo{}, errors.New("zip entry has invalid compression metadata")
|
||||
}
|
||||
if entry.CompressedSize64 > 0 {
|
||||
ratio := float64(entry.UncompressedSize64) / float64(entry.CompressedSize64)
|
||||
if ratio > guard.MaxCompressionRatio {
|
||||
return packageInfo{}, errors.New("zip compression ratio is suspicious")
|
||||
}
|
||||
}
|
||||
|
||||
files = append(files, cleanName)
|
||||
if cleanName != "feedback.json" && cleanName != "summary.txt" {
|
||||
if !guard.AllowUnexpectedZipFiles && !strings.HasPrefix(cleanName, "attachments/") {
|
||||
return packageInfo{}, errors.New("zip contains unexpected file")
|
||||
}
|
||||
continue
|
||||
}
|
||||
text, err := readZipText(entry, guard.MaxReadableTextBytes)
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
texts[cleanName] = text
|
||||
}
|
||||
|
||||
request := map[string]any{}
|
||||
if raw := texts["feedback.json"]; raw != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
|
||||
if nested, ok := parsed["request"].(map[string]any); ok {
|
||||
request = nested
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(request) == 0 && texts["feedback.json"] == "" {
|
||||
return packageInfo{}, errors.New("feedback.json is missing")
|
||||
}
|
||||
return packageInfo{Request: request, Summary: texts["summary.txt"], Files: files}, nil
|
||||
}
|
||||
|
||||
func safeZipName(name string) (string, error) {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.Contains(name, "\x00") || strings.HasPrefix(name, "/") {
|
||||
return "", errors.New("unsafe zip entry name")
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", errors.New("unsafe zip entry path")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func readZipText(entry *zip.File, maxBytes int64) (string, error) {
|
||||
if int64(entry.UncompressedSize64) > maxBytes {
|
||||
return "", nil
|
||||
}
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", nil
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func buildRecord(code string, payload submissionPayload, info packageInfo, encryptedPath, packagePath, packageSha256, plainPackageSha256, remoteAddr string) db.FeedbackRecord {
|
||||
now := db.Now()
|
||||
title := firstText(textFromMap(info.Request, "title"), payload.Title, "未命名反馈")
|
||||
typ := firstText(textFromMap(info.Request, "type"), payload.Type, "issue")
|
||||
severity := firstText(textFromMap(info.Request, "severity"), payload.Severity, "normal")
|
||||
contact := firstText(textFromMap(info.Request, "contact"), payload.Contact, "")
|
||||
body := firstText(textFromMap(info.Request, "body"), "", "")
|
||||
priority := normalizePriority(severity)
|
||||
return db.FeedbackRecord{
|
||||
Code: code,
|
||||
ReceivedAt: now,
|
||||
Title: shortenPlain(title, 240),
|
||||
Type: shortenPlain(typ, 80),
|
||||
Severity: shortenPlain(severity, 80),
|
||||
Category: normalizeCategory(typ),
|
||||
Priority: priority,
|
||||
Contact: shortenPlain(contact, 240),
|
||||
Body: shortenPlain(body, 5000),
|
||||
Status: "new",
|
||||
StatusDetail: "反馈已接收,等待后台处理。",
|
||||
Note: "",
|
||||
PublicReply: "",
|
||||
HandledBy: "",
|
||||
Assignee: "",
|
||||
DueAt: "",
|
||||
ResolvedAt: "",
|
||||
ArchivedAt: "",
|
||||
SLALevel: defaultSLA(priority),
|
||||
SourceChannel: "winui",
|
||||
RiskScore: defaultRisk(priority),
|
||||
Resolution: "",
|
||||
PackagePath: packagePath,
|
||||
EncryptedPackagePath: encryptedPath,
|
||||
PackageSha256: packageSha256,
|
||||
PlainPackageSha256: plainPackageSha256,
|
||||
RemoteAddr: shortenPlain(remoteAddr, 80),
|
||||
SummaryText: shortenPlain(info.Summary, 6000),
|
||||
IncludedFiles: strings.Join(info.Files, ", "),
|
||||
MailSent: false,
|
||||
UpdatedAt: now,
|
||||
LastActivityAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) generateCode() string {
|
||||
for {
|
||||
random := make([]byte, 3)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(random))
|
||||
existing, _ := s.store.FetchStatus(code)
|
||||
if existing == nil {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StatusLabel(status string) string {
|
||||
switch status {
|
||||
case "triaged":
|
||||
return "已归类"
|
||||
case "investigating":
|
||||
return "处理中"
|
||||
case "resolved":
|
||||
return "已解决"
|
||||
case "archived":
|
||||
return "已归档"
|
||||
default:
|
||||
return "新反馈"
|
||||
}
|
||||
}
|
||||
|
||||
func TypeLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "suggestion":
|
||||
return "建议"
|
||||
case "ui":
|
||||
return "界面反馈"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "问题"
|
||||
}
|
||||
}
|
||||
|
||||
func SeverityLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "major":
|
||||
return "影响使用"
|
||||
case "blocking":
|
||||
return "阻塞"
|
||||
default:
|
||||
return "普通"
|
||||
}
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, code string, status int, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"ok": false,
|
||||
"error": code,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func textFromMap(values map[string]any, key string) string {
|
||||
if value, ok := values[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortenPlain(value string, max int) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
||||
if max <= 0 || len([]rune(value)) <= max {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func isHexSHA256(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isZipBytes(data []byte) bool {
|
||||
return bytes.HasPrefix(data, []byte("PK\x03\x04")) ||
|
||||
bytes.HasPrefix(data, []byte("PK\x05\x06")) ||
|
||||
bytes.HasPrefix(data, []byte("PK\x07\x08"))
|
||||
}
|
||||
|
||||
func normalizeCategory(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "suggestion", "ui", "other":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "issue"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePriority(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "major", "blocking":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "normal"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSLA(priority string) string {
|
||||
switch normalizePriority(priority) {
|
||||
case "blocking":
|
||||
return "urgent"
|
||||
case "major":
|
||||
return "elevated"
|
||||
default:
|
||||
return "standard"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRisk(priority string) int {
|
||||
switch normalizePriority(priority) {
|
||||
case "blocking":
|
||||
return 90
|
||||
case "major":
|
||||
return 65
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
func DebugEncryptPackageForTest(plain []byte, keyMaterial string, nonce []byte) ([]byte, error) {
|
||||
if len(nonce) != 12 {
|
||||
return nil, fmt.Errorf("nonce must be 12 bytes")
|
||||
}
|
||||
if keyMaterial == "" {
|
||||
keyMaterial = "ymhut-box-feedback-package-v1"
|
||||
}
|
||||
key := sha256.Sum256([]byte(keyMaterial))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, plain, []byte(PackageMagic))
|
||||
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
|
||||
tag := sealed[len(sealed)-gcm.Overhead():]
|
||||
out := []byte(PackageMagic)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, tag...)
|
||||
out = append(out, ciphertext...)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
)
|
||||
|
||||
func TestSubmissionSignatureIsStable(t *testing.T) {
|
||||
signature := SignWithKey(
|
||||
"ymhut-box-feedback-client-v1",
|
||||
"1760000000",
|
||||
"abc123",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
`{"ok":true}`,
|
||||
)
|
||||
|
||||
const expected = "9bb27ac870cddf4b9eb02961f2f744bb4cf02b7a08e190ede5d836e5c946ad2e"
|
||||
if signature != expected {
|
||||
t.Fatalf("signature mismatch: got %s want %s", signature, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptPackageAndReadFeedbackPackage(t *testing.T) {
|
||||
var zipBuffer bytes.Buffer
|
||||
writer := zip.NewWriter(&zipBuffer)
|
||||
feedbackEntry, err := writer.Create("feedback.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := feedbackEntry.Write([]byte(`{"request":{"title":"Crash","type":"issue","severity":"major","contact":"dev@example.com","body":"Steps"}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaryEntry, err := writer.Create("summary.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := summaryEntry.Write([]byte("summary text")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encrypted, err := DebugEncryptPackageForTest(zipBuffer.Bytes(), "ymhut-box-feedback-package-v1", []byte("123456789012"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := DecryptPackage(encrypted, "ymhut-box-feedback-package-v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(plain, zipBuffer.Bytes()) {
|
||||
t.Fatal("decrypted package did not match original zip")
|
||||
}
|
||||
|
||||
info, err := ReadFeedbackPackage(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Request["title"] != "Crash" || info.Summary != "summary text" {
|
||||
t.Fatalf("unexpected package info: %+v", info)
|
||||
}
|
||||
if len(info.Files) != 2 {
|
||||
t.Fatalf("expected two files, got %v", info.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCode(t *testing.T) {
|
||||
if got := NormalizeCode(" fb-20260604-abc123 "); got != "FB-20260604-ABC123" {
|
||||
t.Fatalf("unexpected normalized code %q", got)
|
||||
}
|
||||
if got := NormalizeCode("FB-20260604-XYZ123"); got != "" {
|
||||
t.Fatalf("invalid code was accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFeedbackPackageRejectsUnsafeZipPath(t *testing.T) {
|
||||
var zipBuffer bytes.Buffer
|
||||
writer := zip.NewWriter(&zipBuffer)
|
||||
entry, err := writer.Create("../evil.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write([]byte("evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ReadFeedbackPackageWithGuard(zipBuffer.Bytes(), config.Default(".").UploadGuard); err == nil {
|
||||
t.Fatal("expected unsafe zip path to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
From string
|
||||
FromName string
|
||||
To string
|
||||
Subject string
|
||||
PlainBody string
|
||||
HTMLBody string
|
||||
AttachmentPath string
|
||||
AttachmentName string
|
||||
}
|
||||
|
||||
func BuildFeedbackMessage(cfg *config.Config, record db.FeedbackRecord, packagePath string) (Message, error) {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: subject,
|
||||
PlainBody: buildFeedbackPlain(record),
|
||||
HTMLBody: buildFeedbackHTML(record),
|
||||
AttachmentPath: packagePath,
|
||||
AttachmentName: record.Code + ".zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildTestMessage(cfg *config.Config) (Message, error) {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: "YMhut Box 反馈中心测试通知",
|
||||
PlainBody: "这是一封来自反馈中心后台的测试通知。\n时间:" + now,
|
||||
HTMLBody: "<p>这是一封来自反馈中心后台的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Send(cfg *config.Config, message Message) error {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smtpSend(channel, message.From, message.To, raw)
|
||||
}
|
||||
|
||||
func channel(cfg *config.Config) (config.MailConfig, error) {
|
||||
channel := cfg.Mail
|
||||
if channel.FromAddress == "" {
|
||||
channel.FromAddress = channel.Username
|
||||
}
|
||||
if channel.FromName == "" {
|
||||
channel.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if channel.Port <= 0 {
|
||||
channel.Port = 465
|
||||
}
|
||||
if channel.TimeoutSeconds <= 0 {
|
||||
channel.TimeoutSeconds = 20
|
||||
}
|
||||
channel.Secure = strings.ToLower(channel.Secure)
|
||||
if channel.Secure == "" {
|
||||
channel.Secure = "ssl"
|
||||
}
|
||||
if channel.Host == "" || channel.FromAddress == "" || channel.DeveloperAddress == "" {
|
||||
return channel, errors.New("通知配置不完整")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func BuildMIME(message Message) (string, error) {
|
||||
boundary := "ymhut_" + randomish()
|
||||
altBoundary := "ymhut_alt_" + randomish()
|
||||
headers := []string{
|
||||
"Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000",
|
||||
"From: " + mimeAddress(message.From, message.FromName),
|
||||
"To: " + message.To,
|
||||
"Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="` + boundary + `"`,
|
||||
}
|
||||
|
||||
body := []string{
|
||||
"--" + boundary,
|
||||
`Content-Type: multipart/alternative; boundary="` + altBoundary + `"`,
|
||||
"",
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.PlainBody)),
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.HTMLBody)),
|
||||
"--" + altBoundary + "--",
|
||||
}
|
||||
|
||||
if message.AttachmentPath != "" {
|
||||
data, err := os.ReadFile(message.AttachmentPath)
|
||||
if err == nil {
|
||||
name := message.AttachmentName
|
||||
if name == "" {
|
||||
name = filepath.Base(message.AttachmentPath)
|
||||
}
|
||||
escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`)
|
||||
body = append(body,
|
||||
"--"+boundary,
|
||||
`Content-Type: application/zip; name="`+escaped+`"`,
|
||||
"Content-Transfer-Encoding: base64",
|
||||
`Content-Disposition: attachment; filename="`+escaped+`"`,
|
||||
"",
|
||||
wrapBase64(data),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, "--"+boundary+"--")
|
||||
return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil
|
||||
}
|
||||
|
||||
func smtpSend(channel config.MailConfig, from, to, rawMessage string) error {
|
||||
address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port))
|
||||
timeout := time.Duration(channel.TimeoutSeconds) * time.Second
|
||||
|
||||
var client *smtp.Client
|
||||
if channel.Secure == "ssl" || channel.Secure == "tls" {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host})
|
||||
if err != nil {
|
||||
return fmt.Errorf("通知连接失败:%w", err)
|
||||
}
|
||||
client, err = smtp.NewClient(conn, channel.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("通知连接失败:%w", err)
|
||||
}
|
||||
client, err = smtp.NewClient(conn, channel.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if channel.Secure == "starttls" {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil {
|
||||
return fmt.Errorf("通知加密握手失败:%w", err)
|
||||
}
|
||||
}
|
||||
if channel.Username != "" || channel.Password != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil {
|
||||
return fmt.Errorf("通知认证失败:%w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(extractEmail(from)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(extractEmail(to)); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write([]byte(rawMessage)); err != nil {
|
||||
writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func buildFeedbackPlain(record db.FeedbackRecord) string {
|
||||
return strings.Join([]string{
|
||||
"YMhut Box 反馈单",
|
||||
"反馈编号:" + record.Code,
|
||||
"标题:" + record.Title,
|
||||
"类型:" + typeLabel(record.Type),
|
||||
"严重程度:" + severityLabel(record.Severity),
|
||||
"联系方式:" + record.Contact,
|
||||
"接收时间:" + record.ReceivedAt,
|
||||
"包含文件:" + record.IncludedFiles,
|
||||
"原始包校验:" + record.PlainPackageSha256,
|
||||
"",
|
||||
"正文:",
|
||||
record.Body,
|
||||
"",
|
||||
"反馈包摘要:",
|
||||
record.SummaryText,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func buildFeedbackHTML(record db.FeedbackRecord) string {
|
||||
rows := [][2]string{
|
||||
{"反馈编号", record.Code},
|
||||
{"标题", record.Title},
|
||||
{"类型", typeLabel(record.Type)},
|
||||
{"严重程度", severityLabel(record.Severity)},
|
||||
{"联系方式", record.Contact},
|
||||
{"接收时间", record.ReceivedAt},
|
||||
{"包含文件", record.IncludedFiles},
|
||||
{"原始包校验", record.PlainPackageSha256},
|
||||
}
|
||||
html := `<h2>YMhut Box 反馈单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
|
||||
for _, row := range rows {
|
||||
html += "<tr><th align=\"left\">" + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
|
||||
}
|
||||
html += "</table>"
|
||||
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
|
||||
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
|
||||
return html
|
||||
}
|
||||
|
||||
func typeLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "suggestion":
|
||||
return "建议"
|
||||
case "ui":
|
||||
return "界面反馈"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "问题"
|
||||
}
|
||||
}
|
||||
|
||||
func severityLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "major":
|
||||
return "影响使用"
|
||||
case "blocking":
|
||||
return "阻塞"
|
||||
default:
|
||||
return "普通"
|
||||
}
|
||||
}
|
||||
|
||||
func htmlEscape(value string) string {
|
||||
value = strings.ReplaceAll(value, "&", "&")
|
||||
value = strings.ReplaceAll(value, "<", "<")
|
||||
value = strings.ReplaceAll(value, ">", ">")
|
||||
value = strings.ReplaceAll(value, `"`, """)
|
||||
return strings.ReplaceAll(value, "'", "'")
|
||||
}
|
||||
|
||||
func mimeAddress(address, name string) string {
|
||||
if name == "" {
|
||||
return address
|
||||
}
|
||||
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
|
||||
}
|
||||
|
||||
func extractEmail(value string) string {
|
||||
re := regexp.MustCompile(`<([^>]+)>`)
|
||||
if match := re.FindStringSubmatch(value); len(match) == 2 {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func wrapBase64(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var builder strings.Builder
|
||||
for len(encoded) > 76 {
|
||||
builder.WriteString(encoded[:76])
|
||||
builder.WriteString("\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
builder.WriteString(encoded)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomish() string {
|
||||
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
)
|
||||
|
||||
type rateLimitSet struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*visitorBucket
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type visitorBucket struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
func newRateLimitSet(cfg *config.Config) *rateLimitSet {
|
||||
return &rateLimitSet{cfg: cfg, buckets: map[string]*visitorBucket{}}
|
||||
}
|
||||
|
||||
func (s *rateLimitSet) allow(kind, ip string) bool {
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
limit, burst := s.policy(kind)
|
||||
key := kind + ":" + ip
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.buckets) > 4096 {
|
||||
for key, bucket := range s.buckets {
|
||||
if now.Sub(bucket.lastSeen) > 10*time.Minute {
|
||||
delete(s.buckets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
bucket, ok := s.buckets[key]
|
||||
if !ok {
|
||||
bucket = &visitorBucket{limiter: rate.NewLimiter(limit, burst)}
|
||||
s.buckets[key] = bucket
|
||||
}
|
||||
bucket.lastSeen = now
|
||||
return bucket.limiter.Allow()
|
||||
}
|
||||
|
||||
func (s *rateLimitSet) middleware(kind string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !s.allow(kind, c.ClientIP()) {
|
||||
tooManyRequests(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rateLimitSet) adminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
kind := "admin_read"
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions {
|
||||
kind = "admin_write"
|
||||
}
|
||||
if !s.allow(kind, c.ClientIP()) {
|
||||
tooManyRequests(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rateLimitSet) policy(kind string) (rate.Limit, int) {
|
||||
perMinute := s.cfg.RateLimit.AdminReadPerMinute
|
||||
burst := s.cfg.RateLimit.AdminReadBurst
|
||||
switch kind {
|
||||
case "submission":
|
||||
perMinute = s.cfg.RateLimit.SubmissionPerMinute
|
||||
burst = s.cfg.RateLimit.SubmissionBurst
|
||||
case "status":
|
||||
perMinute = s.cfg.RateLimit.StatusPerMinute
|
||||
burst = s.cfg.RateLimit.StatusBurst
|
||||
case "captcha":
|
||||
perMinute = s.cfg.RateLimit.CaptchaPerMinute
|
||||
burst = s.cfg.RateLimit.CaptchaBurst
|
||||
case "login":
|
||||
perMinute = s.cfg.RateLimit.LoginPerMinute
|
||||
burst = s.cfg.RateLimit.LoginBurst
|
||||
case "admin_write":
|
||||
perMinute = s.cfg.RateLimit.AdminWritePerMinute
|
||||
burst = s.cfg.RateLimit.AdminWriteBurst
|
||||
}
|
||||
if perMinute <= 0 {
|
||||
perMinute = 60
|
||||
}
|
||||
if burst <= 0 {
|
||||
burst = 5
|
||||
}
|
||||
return rate.Limit(float64(perMinute) / 60.0), burst
|
||||
}
|
||||
|
||||
func tooManyRequests(c *gin.Context) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"ok": false,
|
||||
"error": "RATE_LIMITED",
|
||||
"message": "Too many requests, please retry later",
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Event string `json:"event"`
|
||||
Delivery string `json:"delivery"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func NewDispatcher(cfg *config.Config, store *db.Store) *Dispatcher {
|
||||
return &Dispatcher{cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Dispatch(event string, data any) {
|
||||
if d == nil || len(d.cfg.Webhooks) == 0 {
|
||||
return
|
||||
}
|
||||
for _, hook := range d.cfg.Webhooks {
|
||||
if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) {
|
||||
continue
|
||||
}
|
||||
hookCopy := hook
|
||||
go d.Deliver(hookCopy, event, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) DispatchSync(event string, data any) {
|
||||
if d == nil || len(d.cfg.Webhooks) == 0 {
|
||||
return
|
||||
}
|
||||
for _, hook := range d.cfg.Webhooks {
|
||||
if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) {
|
||||
continue
|
||||
}
|
||||
d.Deliver(hook, event, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) DispatchTest(data any) int {
|
||||
if d == nil || len(d.cfg.Webhooks) == 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, hook := range d.cfg.Webhooks {
|
||||
if !hook.Enabled || hook.URL == "" {
|
||||
continue
|
||||
}
|
||||
d.Deliver(hook, "feedback.test", data)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Deliver(hook config.WebhookConfig, event string, data any) {
|
||||
if d == nil || hook.URL == "" {
|
||||
return
|
||||
}
|
||||
payload, deliveryKey := buildPayload(event, data)
|
||||
sum := sha256.Sum256(payload)
|
||||
id, err := d.store.InsertWebhookDelivery(db.WebhookDelivery{
|
||||
WebhookName: hook.Name,
|
||||
Event: event,
|
||||
Status: "pending",
|
||||
PayloadSHA256: hex.EncodeToString(sum[:]),
|
||||
CreatedAt: db.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
maxRetries := hook.MaxRetries
|
||||
if maxRetries < 0 {
|
||||
maxRetries = 0
|
||||
}
|
||||
attempts := 0
|
||||
status := "failed"
|
||||
responseCode := 0
|
||||
errorMessage := ""
|
||||
for attempts <= maxRetries {
|
||||
attempts++
|
||||
code, err := postJSON(hook, event, deliveryKey, payload)
|
||||
responseCode = code
|
||||
if err == nil && code >= 200 && code < 300 {
|
||||
status = "sent"
|
||||
errorMessage = ""
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
errorMessage = err.Error()
|
||||
} else {
|
||||
errorMessage = "webhook returned HTTP " + http.StatusText(code)
|
||||
if errorMessage == "webhook returned HTTP " {
|
||||
errorMessage = "webhook returned HTTP status"
|
||||
}
|
||||
}
|
||||
if attempts <= maxRetries {
|
||||
time.Sleep(time.Duration(attempts) * 350 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
_ = d.store.FinishWebhookDelivery(id, status, attempts, responseCode, errorMessage)
|
||||
}
|
||||
|
||||
func buildPayload(event string, data any) ([]byte, string) {
|
||||
now := db.Now()
|
||||
rawDelivery := sha256.Sum256([]byte(event + "\n" + now + "\n" + db.ToJSON(data)))
|
||||
delivery := hex.EncodeToString(rawDelivery[:16])
|
||||
payload := Event{Event: event, Delivery: delivery, OccurredAt: now, Data: data}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return []byte(`{"event":"` + event + `"}`), delivery
|
||||
}
|
||||
return encoded, delivery
|
||||
}
|
||||
|
||||
func postJSON(hook config.WebhookConfig, event, delivery string, body []byte) (int, error) {
|
||||
timeout := hook.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "YMhut-Feedback-Webhook/1.0")
|
||||
request.Header.Set("X-YMhut-Event", event)
|
||||
request.Header.Set("X-YMhut-Delivery", delivery)
|
||||
request.Header.Set("X-YMhut-Signature", sign(hook.Secret, body))
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func sign(secret string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func matchesEvent(events []string, event string) bool {
|
||||
if len(events) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range events {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "*" || candidate == event {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(candidate, ".*") && strings.HasPrefix(event, strings.TrimSuffix(candidate, "*")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/auth"
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
"ymhut-box/server/feedback-mailer/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
baseDir := findBaseDir()
|
||||
cfg, err := config.Load(baseDir)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
authService := auth.NewService(cfg)
|
||||
router := web.NewRouter(cfg, store, authService)
|
||||
|
||||
addr := cfg.Listen
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
log.Printf("YMhut Box feedback service listening on %s", addr)
|
||||
server := &http.Server{Addr: addr, Handler: router}
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
log.Println("YMhut Box feedback service stopped")
|
||||
}
|
||||
|
||||
func findBaseDir() string {
|
||||
if value := os.Getenv("YMHUT_FEEDBACK_HOME"); value != "" {
|
||||
if abs, err := filepath.Abs(value); err == nil {
|
||||
return abs
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil && looksLikeServiceRoot(wd) {
|
||||
return wd
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err == nil {
|
||||
return wd
|
||||
}
|
||||
|
||||
return "."
|
||||
}
|
||||
|
||||
func looksLikeServiceRoot(path string) bool {
|
||||
for _, name := range []string{"config.txt", "admin-web", "web"} {
|
||||
if _, err := os.Stat(filepath.Join(path, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user