@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# YMhut Unified Management
|
||||||
|
|
||||||
|
This service unifies the old `server/update` and `server/feedback-mailer` projects.
|
||||||
|
|
||||||
|
## What It Provides
|
||||||
|
|
||||||
|
- Go backend with SQLite by default.
|
||||||
|
- Optional MySQL configuration with runtime status and failover hooks.
|
||||||
|
- Legacy routes for `update-info.json`, `tool-status.json`, `media-types.json`, `modules.json`, downloads, and feedback.
|
||||||
|
- New client discovery through `/api/client/bootstrap`.
|
||||||
|
- Admin login with default account `admin/admin`.
|
||||||
|
- Login captcha, HttpOnly session cookie, and CSRF checks.
|
||||||
|
- In-process media/data source service with scheduled health checks.
|
||||||
|
- Vue admin/portal source and Rust WASM helper source.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server\unified-management
|
||||||
|
go mod tidy
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
The startup preflight prints:
|
||||||
|
|
||||||
|
- the accepted entrypoint: `go run main.go`
|
||||||
|
- the listen address
|
||||||
|
- storage, SQLite, legacy JSON, downloads, and frontend dist status
|
||||||
|
|
||||||
|
Alternative command entrypoint:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run .\cmd\unified-management
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Build Check
|
||||||
|
|
||||||
|
Startup preflight checks both built web apps:
|
||||||
|
|
||||||
|
- `web/admin/dist/index.html`
|
||||||
|
- `web/admin/dist/assets`
|
||||||
|
- `web/portal/dist/index.html`
|
||||||
|
- `web/portal/dist/assets`
|
||||||
|
|
||||||
|
If either frontend is missing, the service still starts, but the preflight log tells you to run the frontend build. Release binaries built with `embed_web` report that embedded frontend assets are being used instead. Build the local dist files with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server\unified-management\web\admin
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ..\portal
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Go server serves portal assets from `/assets/*` and admin assets from `/admin/assets/*`. Disk `dist` files are preferred during development; embedded assets are used as the release fallback.
|
||||||
|
|
||||||
|
## Release Binaries
|
||||||
|
|
||||||
|
Build frontend assets and produce merged backend binaries for Windows and Linux. The scripts run `npm run build` for both web apps and compile Go with `-tags embed_web`, so the resulting binaries include the admin and portal frontends:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server\unified-management
|
||||||
|
.\scripts\build-release.ps1 -Version 0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/macOS shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server/unified-management
|
||||||
|
VERSION=0.1.0 ./scripts/build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs are written to `dist-release/`:
|
||||||
|
|
||||||
|
- `ymhut-unified-management-windows-amd64.exe`
|
||||||
|
- `ymhut-unified-management-linux-amd64`
|
||||||
|
- `ymhut-unified-management-linux-arm64`
|
||||||
|
|
||||||
|
## Default Port
|
||||||
|
|
||||||
|
The default listen address is `:33550`.
|
||||||
|
|
||||||
|
Override it with either:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:YMHUT_LISTEN=":33551"
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PORT="33551"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- `YMHUT_LISTEN`: listen address, default `:33550`
|
||||||
|
- `YMHUT_BASE_URL`: public endpoint, default `https://update.ymhut.cn`
|
||||||
|
- `YMHUT_DB_PROVIDER`: `sqlite` or `mysql`
|
||||||
|
- `YMHUT_SQLITE_PATH`: SQLite path
|
||||||
|
- `YMHUT_MYSQL_DSN`: MySQL DSN
|
||||||
|
- `YMHUT_UPDATE_PUBLIC_DIR`: legacy update public directory
|
||||||
|
- `YMHUT_DOWNLOADS_DIR`: package download directory
|
||||||
|
- `YMHUT_SOURCE_CHECK_SECONDS`: source health check interval
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- `web/admin`: Vue admin console source
|
||||||
|
- `web/portal`: Vue public portal source
|
||||||
|
- `web/wasm`: Rust WASM helpers
|
||||||
|
|
||||||
|
Rust WASM functions:
|
||||||
|
|
||||||
|
- `compareVersions`
|
||||||
|
- `normalizeReleaseManifest`
|
||||||
|
- `validateSourceCatalog`
|
||||||
|
- `scoreEndpointHealth`
|
||||||
|
- `mergeLegacyMediaTypes`
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/auth"
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
"ymhut-box/server/unified-management/internal/feedback"
|
||||||
|
"ymhut-box/server/unified-management/internal/legacy"
|
||||||
|
"ymhut-box/server/unified-management/internal/notices"
|
||||||
|
"ymhut-box/server/unified-management/internal/releases"
|
||||||
|
"ymhut-box/server/unified-management/internal/sources"
|
||||||
|
"ymhut-box/server/unified-management/internal/synclegacy"
|
||||||
|
"ymhut-box/server/unified-management/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("YMhut unified management %s preflight", config.Version)
|
||||||
|
log.Printf("entrypoint ok: go run main.go")
|
||||||
|
log.Printf("listen: %s", cfg.Listen)
|
||||||
|
for _, line := range config.FormatPreflight(config.Preflight(cfg)) {
|
||||||
|
log.Print(line)
|
||||||
|
}
|
||||||
|
if !cfg.Initialized && os.Getenv("YMHUT_SKIP_SETUP") != "1" {
|
||||||
|
log.Printf("setup required: open http://%s/setup", cfg.Listen)
|
||||||
|
runServer(cfg.Listen, web.NewSetupRouter(cfg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||||
|
log.Fatalf("ensure default admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noticeService := notices.NewService(cfg, store)
|
||||||
|
if err := noticeService.Import(context.Background()); err != nil {
|
||||||
|
log.Printf("update notice import skipped: %v", err)
|
||||||
|
}
|
||||||
|
releaseService := releases.NewService(cfg, store, noticeService)
|
||||||
|
sourceService := sources.NewService(cfg, store)
|
||||||
|
feedbackService := feedback.NewService(cfg, store)
|
||||||
|
legacyService := legacy.NewService(cfg, store)
|
||||||
|
legacySyncService := synclegacy.New(cfg, store, noticeService)
|
||||||
|
authService := auth.NewService(store)
|
||||||
|
|
||||||
|
if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
|
||||||
|
log.Printf("legacy media source import skipped: %v", err)
|
||||||
|
}
|
||||||
|
sourceService.Start(context.Background())
|
||||||
|
defer sourceService.Stop()
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: web.NewRouter(cfg, store, authService, feedbackService, releaseService, sourceService, legacyService, noticeService, legacySyncService),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
runHTTPServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(addr string, handler http.Handler) {
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
runHTTPServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHTTPServer(server *http.Server) {
|
||||||
|
go func() {
|
||||||
|
log.Printf("YMhut unified management listening on %s", server.Addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-stop
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "ymhut-box/server/unified-management/cmd/unified-management/app"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module ymhut-box/server/unified-management
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
|
modernc.org/sqlite v1.38.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // 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,53 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
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/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/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/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
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,294 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionCookie = "ymhut_unified_session"
|
||||||
|
captchaTTL = 5 * time.Minute
|
||||||
|
sessionTTL = 12 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store *db.Store
|
||||||
|
mu sync.Mutex
|
||||||
|
captchas map[string]captchaEntry
|
||||||
|
sessions map[string]sessionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type captchaEntry struct {
|
||||||
|
answer string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionEntry struct {
|
||||||
|
username string
|
||||||
|
csrf string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Captcha struct {
|
||||||
|
ID string `json:"captchaId"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *db.Store) *Service {
|
||||||
|
return &Service{
|
||||||
|
store: store,
|
||||||
|
captchas: map[string]captchaEntry{},
|
||||||
|
sessions: map[string]sessionEntry{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Bootstrap(ctx context.Context) (map[string]any, error) {
|
||||||
|
isDefault, err := s.store.IsDefaultAdminPassword(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"defaultUsername": "admin",
|
||||||
|
"defaultPassword": "",
|
||||||
|
"isDefaultPassword": isDefault,
|
||||||
|
}
|
||||||
|
if isDefault {
|
||||||
|
payload["defaultPassword"] = "admin"
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string) (string, string, bool, error) {
|
||||||
|
if !s.consumeCaptcha(captchaID, captcha) {
|
||||||
|
return "", "", false, nil
|
||||||
|
}
|
||||||
|
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return "", "", false, err
|
||||||
|
}
|
||||||
|
sessionID := randomToken(32)
|
||||||
|
csrf := randomToken(32)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cleanupLocked()
|
||||||
|
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return sessionID, csrf, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(SessionCookie); err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, cookie.Value)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
clearCookie(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UserForRequest(r *http.Request) (string, string, bool) {
|
||||||
|
cookie, err := r.Cookie(SessionCookie)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.cleanupLocked()
|
||||||
|
session, ok := s.sessions[cookie.Value]
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return session.username, session.csrf, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Require(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, csrf, ok := s.UserForRequest(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
||||||
|
actual := r.Header.Get("X-CSRF-Token")
|
||||||
|
if actual == "" || subtle.ConstantTimeCompare([]byte(csrf), []byte(actual)) != 1 {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "CSRF_INVALID", "message": "Invalid CSRF token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: SessionCookie,
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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{246, 248, 244, 255}}, image.Point{}, draw.Src)
|
||||||
|
for i := 0; i < 26; i++ {
|
||||||
|
x := (i*37 + 13) % 180
|
||||||
|
y := (i*19 + 7) % 64
|
||||||
|
img.Set(x, y, color.RGBA{111, 119, 130, 255})
|
||||||
|
}
|
||||||
|
for index, digit := range answer {
|
||||||
|
drawDigit(img, int(digit-'0'), 18+index*32, 13, color.RGBA{28, 61, 89, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload map[string]any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = jsonNewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonEncoder interface {
|
||||||
|
Encode(v any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonNewEncoder(w http.ResponseWriter) jsonEncoder {
|
||||||
|
return json.NewEncoder(w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
StorageDir: root,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: root + "/test.sqlite",
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewService(store)
|
||||||
|
payload, err := service.Bootstrap(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" {
|
||||||
|
t.Fatalf("unexpected bootstrap payload: %#v", payload)
|
||||||
|
}
|
||||||
|
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload, err = service.Bootstrap(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["isDefaultPassword"] != false || payload["defaultPassword"] != "" {
|
||||||
|
t.Fatalf("default password leaked after change: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultListen = ":33550"
|
||||||
|
|
||||||
|
var Version = "0.1.0"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
BaseDir string `json:"base_dir"`
|
||||||
|
ConfigPath string `json:"-"`
|
||||||
|
Initialized bool `json:"initialized"`
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
StorageDir string `json:"storage_dir"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
UpdatePublicDir string `json:"update_public_dir"`
|
||||||
|
UpdateNoticeDir string `json:"update_notice_dir"`
|
||||||
|
DownloadsDir string `json:"downloads_dir"`
|
||||||
|
AdminWebDir string `json:"admin_web_dir"`
|
||||||
|
PortalWebDir string `json:"portal_web_dir"`
|
||||||
|
SetupWebDir string `json:"setup_web_dir"`
|
||||||
|
LegacyUpdateDir string `json:"legacy_update_dir"`
|
||||||
|
LegacyFeedbackDir string `json:"legacy_feedback_dir"`
|
||||||
|
LegacyUpdateNoticeDir string `json:"legacy_update_notice_dir"`
|
||||||
|
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"`
|
||||||
|
Database DatabaseConfig `json:"database"`
|
||||||
|
UploadGuard UploadGuardConfig `json:"upload_guard"`
|
||||||
|
SourceCheckSeconds int `json:"source_check_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
SQLitePath string `json:"sqlite_path"`
|
||||||
|
MySQLDSN string `json:"mysql_dsn"`
|
||||||
|
FailoverEnabled bool `json:"failover_enabled"`
|
||||||
|
HotSyncEnabled bool `json:"hot_sync_enabled"`
|
||||||
|
HealthIntervalSec int `json:"health_interval_sec"`
|
||||||
|
MaxOpenConns int `json:"max_open_conns"`
|
||||||
|
MaxIdleConns int `json:"max_idle_conns"`
|
||||||
|
ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
root, err := ResolveBaseDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := defaults(root)
|
||||||
|
path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json"))
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Initialized = true
|
||||||
|
}
|
||||||
|
cfg.BaseDir = root
|
||||||
|
cfg.ConfigPath = path
|
||||||
|
applyEnv(cfg)
|
||||||
|
normalize(root, cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaults(root string) *Config {
|
||||||
|
return &Config{
|
||||||
|
BaseDir: root,
|
||||||
|
ConfigPath: filepath.Join(root, "config.json"),
|
||||||
|
Initialized: false,
|
||||||
|
Listen: DefaultListen,
|
||||||
|
BaseURL: "https://update.ymhut.cn",
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
DataDir: filepath.Join(root, "data"),
|
||||||
|
UpdatePublicDir: filepath.Join(root, "data", "update", "public"),
|
||||||
|
UpdateNoticeDir: filepath.Join(root, "data", "update-notice"),
|
||||||
|
DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"),
|
||||||
|
AdminWebDir: filepath.Join(root, "web", "admin", "dist"),
|
||||||
|
PortalWebDir: filepath.Join(root, "web", "portal", "dist"),
|
||||||
|
SetupWebDir: filepath.Join(root, "web", "setup", "dist"),
|
||||||
|
LegacyUpdateDir: filepath.Clean(filepath.Join(root, "..", "update")),
|
||||||
|
LegacyFeedbackDir: filepath.Clean(filepath.Join(root, "..", "feedback-mailer")),
|
||||||
|
LegacyUpdateNoticeDir: filepath.Clean(filepath.Join(root, "..", "..", "update-notice")),
|
||||||
|
ClientSignatureKey: "ymhut-box-feedback-client-v1",
|
||||||
|
PackageEncryptionKey: "ymhut-box-feedback-package-v1",
|
||||||
|
TimestampWindowSeconds: 600,
|
||||||
|
MaxRequestBytes: 12 * 1024 * 1024,
|
||||||
|
MaxPackageBytes: 10 * 1024 * 1024,
|
||||||
|
SourceCheckSeconds: 300,
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HotSyncEnabled: true,
|
||||||
|
HealthIntervalSec: 30,
|
||||||
|
MaxOpenConns: 10,
|
||||||
|
MaxIdleConns: 4,
|
||||||
|
ConnMaxLifetimeSeconds: 300,
|
||||||
|
},
|
||||||
|
UploadGuard: UploadGuardConfig{
|
||||||
|
MaxZipFiles: 80,
|
||||||
|
MaxDecompressedBytes: 30 * 1024 * 1024,
|
||||||
|
MaxSingleFileBytes: 8 * 1024 * 1024,
|
||||||
|
MaxCompressionRatio: 120,
|
||||||
|
MaxReadableTextBytes: 256 * 1024,
|
||||||
|
AllowUnexpectedZipFiles: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnv(cfg *Config) {
|
||||||
|
if value := os.Getenv("YMHUT_BASE_DIR"); value != "" {
|
||||||
|
cfg.BaseDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("PORT"); value != "" {
|
||||||
|
cfg.Listen = ":" + value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_LISTEN"); value != "" {
|
||||||
|
cfg.Listen = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_BASE_URL"); value != "" {
|
||||||
|
cfg.BaseURL = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_STORAGE_DIR"); value != "" {
|
||||||
|
cfg.StorageDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_DATA_DIR"); value != "" {
|
||||||
|
cfg.DataDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_UPDATE_PUBLIC_DIR"); value != "" {
|
||||||
|
cfg.UpdatePublicDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_UPDATE_NOTICE_DIR"); value != "" {
|
||||||
|
cfg.UpdateNoticeDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_DOWNLOADS_DIR"); value != "" {
|
||||||
|
cfg.DownloadsDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_LEGACY_UPDATE_DIR"); value != "" {
|
||||||
|
cfg.LegacyUpdateDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_LEGACY_FEEDBACK_DIR"); value != "" {
|
||||||
|
cfg.LegacyFeedbackDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_LEGACY_UPDATE_NOTICE_DIR"); value != "" {
|
||||||
|
cfg.LegacyUpdateNoticeDir = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_DB_PROVIDER"); value != "" {
|
||||||
|
cfg.Database.Provider = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_SQLITE_PATH"); value != "" {
|
||||||
|
cfg.Database.SQLitePath = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" {
|
||||||
|
cfg.Database.MySQLDSN = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" {
|
||||||
|
cfg.ClientSignatureKey = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_PACKAGE_ENCRYPTION_KEY"); value != "" {
|
||||||
|
cfg.PackageEncryptionKey = value
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_TIMESTAMP_WINDOW_SECONDS"); value != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
cfg.TimestampWindowSeconds = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_MAX_REQUEST_BYTES"); value != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
cfg.MaxRequestBytes = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_MAX_PACKAGE_BYTES"); value != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
cfg.MaxPackageBytes = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := os.Getenv("YMHUT_SOURCE_CHECK_SECONDS"); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.SourceCheckSeconds = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(root string, cfg *Config) {
|
||||||
|
cfg.BaseDir = absPath(root, firstNonEmpty(cfg.BaseDir, root))
|
||||||
|
if cfg.ConfigPath == "" {
|
||||||
|
cfg.ConfigPath = filepath.Join(cfg.BaseDir, "config.json")
|
||||||
|
}
|
||||||
|
if cfg.Listen == "" {
|
||||||
|
cfg.Listen = DefaultListen
|
||||||
|
}
|
||||||
|
if cfg.StorageDir == "" {
|
||||||
|
cfg.StorageDir = filepath.Join(cfg.BaseDir, "storage")
|
||||||
|
}
|
||||||
|
cfg.StorageDir = absPath(cfg.BaseDir, cfg.StorageDir)
|
||||||
|
if cfg.DataDir == "" {
|
||||||
|
cfg.DataDir = filepath.Join(cfg.BaseDir, "data")
|
||||||
|
}
|
||||||
|
cfg.DataDir = absPath(cfg.BaseDir, cfg.DataDir)
|
||||||
|
if cfg.UpdatePublicDir == "" {
|
||||||
|
cfg.UpdatePublicDir = filepath.Join(cfg.DataDir, "update", "public")
|
||||||
|
}
|
||||||
|
cfg.UpdatePublicDir = absPath(cfg.BaseDir, cfg.UpdatePublicDir)
|
||||||
|
if cfg.UpdateNoticeDir == "" {
|
||||||
|
cfg.UpdateNoticeDir = filepath.Join(cfg.DataDir, "update-notice")
|
||||||
|
}
|
||||||
|
cfg.UpdateNoticeDir = absPath(cfg.BaseDir, cfg.UpdateNoticeDir)
|
||||||
|
if cfg.DownloadsDir == "" {
|
||||||
|
cfg.DownloadsDir = filepath.Join(cfg.UpdatePublicDir, "downloads")
|
||||||
|
}
|
||||||
|
cfg.DownloadsDir = absPath(cfg.BaseDir, cfg.DownloadsDir)
|
||||||
|
if cfg.AdminWebDir == "" {
|
||||||
|
cfg.AdminWebDir = filepath.Join(cfg.BaseDir, "web", "admin", "dist")
|
||||||
|
}
|
||||||
|
cfg.AdminWebDir = absPath(cfg.BaseDir, cfg.AdminWebDir)
|
||||||
|
if cfg.PortalWebDir == "" {
|
||||||
|
cfg.PortalWebDir = filepath.Join(cfg.BaseDir, "web", "portal", "dist")
|
||||||
|
}
|
||||||
|
cfg.PortalWebDir = absPath(cfg.BaseDir, cfg.PortalWebDir)
|
||||||
|
if cfg.SetupWebDir == "" {
|
||||||
|
cfg.SetupWebDir = filepath.Join(cfg.BaseDir, "web", "setup", "dist")
|
||||||
|
}
|
||||||
|
cfg.SetupWebDir = absPath(cfg.BaseDir, cfg.SetupWebDir)
|
||||||
|
if cfg.LegacyUpdateDir == "" {
|
||||||
|
cfg.LegacyUpdateDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "update"))
|
||||||
|
}
|
||||||
|
cfg.LegacyUpdateDir = absPath(cfg.BaseDir, cfg.LegacyUpdateDir)
|
||||||
|
if cfg.LegacyFeedbackDir == "" {
|
||||||
|
cfg.LegacyFeedbackDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "feedback-mailer"))
|
||||||
|
}
|
||||||
|
cfg.LegacyFeedbackDir = absPath(cfg.BaseDir, cfg.LegacyFeedbackDir)
|
||||||
|
if cfg.LegacyUpdateNoticeDir == "" {
|
||||||
|
cfg.LegacyUpdateNoticeDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "..", "update-notice"))
|
||||||
|
}
|
||||||
|
cfg.LegacyUpdateNoticeDir = absPath(cfg.BaseDir, cfg.LegacyUpdateNoticeDir)
|
||||||
|
if cfg.Database.Provider == "" {
|
||||||
|
cfg.Database.Provider = "sqlite"
|
||||||
|
}
|
||||||
|
if cfg.Database.SQLitePath == "" {
|
||||||
|
cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite")
|
||||||
|
}
|
||||||
|
cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath)
|
||||||
|
if cfg.Database.HealthIntervalSec <= 0 {
|
||||||
|
cfg.Database.HealthIntervalSec = 30
|
||||||
|
}
|
||||||
|
if cfg.Database.MaxOpenConns <= 0 {
|
||||||
|
cfg.Database.MaxOpenConns = 10
|
||||||
|
}
|
||||||
|
if cfg.Database.MaxIdleConns <= 0 {
|
||||||
|
cfg.Database.MaxIdleConns = 4
|
||||||
|
}
|
||||||
|
if cfg.Database.ConnMaxLifetimeSeconds <= 0 {
|
||||||
|
cfg.Database.ConnMaxLifetimeSeconds = 300
|
||||||
|
}
|
||||||
|
if cfg.ClientSignatureKey == "" {
|
||||||
|
cfg.ClientSignatureKey = "ymhut-box-feedback-client-v1"
|
||||||
|
}
|
||||||
|
if cfg.PackageEncryptionKey == "" {
|
||||||
|
cfg.PackageEncryptionKey = "ymhut-box-feedback-package-v1"
|
||||||
|
}
|
||||||
|
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.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
|
||||||
|
}
|
||||||
|
if cfg.SourceCheckSeconds <= 0 {
|
||||||
|
cfg.SourceCheckSeconds = 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBaseDir() (string, error) {
|
||||||
|
if value := os.Getenv("YMHUT_BASE_DIR"); value != "" {
|
||||||
|
return filepath.Abs(value)
|
||||||
|
}
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
if filepath.Base(cwd) == "unified-management" {
|
||||||
|
return filepath.Abs(cwd)
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(cwd, "server", "unified-management")
|
||||||
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||||
|
return filepath.Abs(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return os.Getwd()
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Dir(exe))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalize(firstNonEmpty(cfg.BaseDir, "."), cfg)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(cfg.ConfigPath, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func absPath(base, value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(value) || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||||
|
return filepath.Clean(value)
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(base, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
webassets "ymhut-box/server/unified-management/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Check struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Preflight(cfg *Config) []Check {
|
||||||
|
checks := []Check{
|
||||||
|
checkDir("storage", cfg.StorageDir, true),
|
||||||
|
checkParent("sqlite", cfg.Database.SQLitePath),
|
||||||
|
checkDir("update public", cfg.UpdatePublicDir, false),
|
||||||
|
checkDir("update notice", cfg.UpdateNoticeDir, false),
|
||||||
|
checkDir("downloads", cfg.DownloadsDir, false),
|
||||||
|
checkFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), false),
|
||||||
|
checkFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), false),
|
||||||
|
checkFile("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json"), false),
|
||||||
|
checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
|
||||||
|
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
|
||||||
|
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
|
||||||
|
}
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDir(name, path string, create bool) Check {
|
||||||
|
if create {
|
||||||
|
if err := os.MkdirAll(path, 0o750); err != nil {
|
||||||
|
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return Check{Name: name, Status: "missing", Path: path, Message: "directory not found"}
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return Check{Name: name, Status: "error", Path: path, Message: "path is not a directory"}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "ok", Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkParent(name, path string) Check {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "ok", Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkFile(name, path string, required bool) Check {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
status := "missing"
|
||||||
|
if required {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: status, Path: path, Message: "file not found"}
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return Check{Name: name, Status: "error", Path: path, Message: "path is a directory"}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "ok", Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWebBuild(name, path, embedRoot string) Check {
|
||||||
|
dir := checkDir(name, path, false)
|
||||||
|
if dir.Status != "ok" {
|
||||||
|
if embeddedWebBuildOK(embedRoot) {
|
||||||
|
return Check{Name: name, Status: "ok", Path: path, Message: "using embedded frontend assets"}
|
||||||
|
}
|
||||||
|
dir.Message = "frontend dist missing; run npm install && npm run build, or build release with -tags embed_web"
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
index := filepath.Join(path, "index.html")
|
||||||
|
if file := checkFile(name+" index", index, true); file.Status != "ok" {
|
||||||
|
if embeddedWebBuildOK(embedRoot) {
|
||||||
|
return Check{Name: name, Status: "ok", Path: path, Message: "disk index missing; using embedded frontend assets"}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "missing", Path: index, Message: "index.html missing; run npm run build"}
|
||||||
|
}
|
||||||
|
assets := filepath.Join(path, "assets")
|
||||||
|
if assetDir := checkDir(name+" assets", assets, false); assetDir.Status != "ok" {
|
||||||
|
if embeddedWebBuildOK(embedRoot) {
|
||||||
|
return Check{Name: name, Status: "ok", Path: path, Message: "disk assets missing; using embedded frontend assets"}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "missing", Path: assets, Message: "assets directory missing; run npm run build"}
|
||||||
|
}
|
||||||
|
return Check{Name: name, Status: "ok", Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embeddedWebBuildOK(embedRoot string) bool {
|
||||||
|
if !webassets.Embedded {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := webassets.ReadFile(filepath.ToSlash(filepath.Join(embedRoot, "index.html"))); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entries, err := webassets.ReadDir(filepath.ToSlash(filepath.Join(embedRoot, "assets")))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatPreflight(checks []Check) []string {
|
||||||
|
lines := make([]string, 0, len(checks))
|
||||||
|
for _, check := range checks {
|
||||||
|
line := fmt.Sprintf("[%s] %s", check.Status, check.Name)
|
||||||
|
if check.Path != "" {
|
||||||
|
line += " -> " + check.Path
|
||||||
|
}
|
||||||
|
if check.Message != "" {
|
||||||
|
line += " (" + check.Message + ")"
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dialect struct {
|
||||||
|
name string
|
||||||
|
driver string
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialectFor(provider string) dialect {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
|
case "mysql":
|
||||||
|
return dialect{name: "mysql", driver: "mysql"}
|
||||||
|
default:
|
||||||
|
return dialect{name: "sqlite", driver: "sqlite"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dialect) rebind(query string) string {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dialect) idType() string {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
return "BIGINT PRIMARY KEY AUTO_INCREMENT"
|
||||||
|
}
|
||||||
|
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dialect) boolExpr(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ", "))
|
||||||
|
conflictSet := map[string]bool{}
|
||||||
|
for _, column := range conflict {
|
||||||
|
conflictSet[column] = true
|
||||||
|
}
|
||||||
|
updates := []string{}
|
||||||
|
for _, column := range columns {
|
||||||
|
if conflictSet[column] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.name == "mysql" {
|
||||||
|
updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", column, column))
|
||||||
|
} else {
|
||||||
|
updates = append(updates, fmt.Sprintf("%s = excluded.%s", column, column))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
return strings.Replace(base, "INSERT INTO", "INSERT IGNORE INTO", 1)
|
||||||
|
}
|
||||||
|
return strings.Replace(base, "INSERT INTO", "INSERT OR IGNORE INTO", 1)
|
||||||
|
}
|
||||||
|
if d.name == "mysql" {
|
||||||
|
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ")
|
||||||
|
}
|
||||||
|
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updates, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dialect) limitOffset(limit, offset int) string {
|
||||||
|
return fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSQLDatabase(cfg config.DatabaseConfig) (*sql.DB, dialect, error) {
|
||||||
|
d := dialectFor(cfg.Provider)
|
||||||
|
dsn := strings.TrimSpace(cfg.SQLitePath)
|
||||||
|
if d.name == "mysql" {
|
||||||
|
dsn = strings.TrimSpace(cfg.MySQLDSN)
|
||||||
|
if dsn == "" {
|
||||||
|
return nil, d, errors.New("mysql_dsn is required")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if dsn == "" {
|
||||||
|
return nil, d, errors.New("sqlite path is required")
|
||||||
|
}
|
||||||
|
if !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.driver, 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 TestDatabase(cfg config.DatabaseConfig) error {
|
||||||
|
if cfg.Provider == "" {
|
||||||
|
cfg.Provider = "sqlite"
|
||||||
|
}
|
||||||
|
if cfg.MaxOpenConns <= 0 {
|
||||||
|
cfg.MaxOpenConns = 1
|
||||||
|
}
|
||||||
|
if cfg.MaxIdleConns <= 0 {
|
||||||
|
cfg.MaxIdleConns = 1
|
||||||
|
}
|
||||||
|
if cfg.ConnMaxLifetimeSeconds <= 0 {
|
||||||
|
cfg.ConnMaxLifetimeSeconds = 60
|
||||||
|
}
|
||||||
|
conn, d, err := openSQLDatabase(cfg)
|
||||||
|
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_unified_connection_test (id INTEGER)"
|
||||||
|
if _, err := tx.ExecContext(ctx, d.rebind(create)); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
items := make([]string, n)
|
||||||
|
for i := range items {
|
||||||
|
items[i] = "?"
|
||||||
|
}
|
||||||
|
return strings.Join(items, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiDefault(value string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "unified.sqlite")
|
||||||
|
prototype := state{
|
||||||
|
Admins: []adminRow{{
|
||||||
|
ID: 1,
|
||||||
|
Username: "admin",
|
||||||
|
PasswordHash: passwordHash("admin"),
|
||||||
|
PasswordChanged: false,
|
||||||
|
CreatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
UpdatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
}},
|
||||||
|
Feedbacks: []Feedback{{Code: "FB-20260101-ABCDEF", Title: "Imported", Type: "issue", Severity: "normal", Body: "hello"}},
|
||||||
|
Sources: []Source{{CategoryID: "ip", CategoryName: "IP", SourceID: "ip-demo", Name: "IP Demo", APIURL: "https://example.com/ip", Enabled: true, ClientVisible: true}},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(prototype)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err := Open(&config.Config{
|
||||||
|
StorageDir: root,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: path,
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
ConnMaxLifetimeSeconds: 60,
|
||||||
|
},
|
||||||
|
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if _, _, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.GetFeedback("FB-20260101-ABCDEF"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count, err := store.CountSources(); err != nil || count != 1 {
|
||||||
|
t.Fatalf("CountSources = %d, %v", count, err)
|
||||||
|
}
|
||||||
|
matches, _ := filepath.Glob(path + ".json-prototype-*.bak")
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("expected prototype backup, got %v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package feedback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PackageMagic = "YMHUTFB1"
|
||||||
|
|
||||||
|
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
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) *Service {
|
||||||
|
return &Service{cfg: cfg, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if strings.Contains(contentType, "multipart/form-data") {
|
||||||
|
if item, err := s.submitMultipart(r); err == nil {
|
||||||
|
return item, nil
|
||||||
|
} else if hasSignedFields(r) {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.submitSimple(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) {
|
||||||
|
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
item := db.Feedback{
|
||||||
|
Code: db.NewFeedbackCode(),
|
||||||
|
Title: value(payload, "title", "客户端反馈"),
|
||||||
|
Type: value(payload, "type", "issue"),
|
||||||
|
Severity: value(payload, "severity", "normal"),
|
||||||
|
Contact: value(payload, "contact", ""),
|
||||||
|
Body: value(payload, "body", value(payload, "message", "")),
|
||||||
|
Status: "new",
|
||||||
|
RemoteAddr: r.RemoteAddr,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Body) == "" {
|
||||||
|
item.Body = "No feedback body provided."
|
||||||
|
}
|
||||||
|
return item, s.store.InsertFeedback(item)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
}
|
||||||
|
item := db.Feedback{
|
||||||
|
Code: db.NewFeedbackCode(),
|
||||||
|
Title: firstNonEmpty(r.FormValue("title"), r.FormValue("subject"), "客户端反馈"),
|
||||||
|
Type: firstNonEmpty(r.FormValue("type"), r.FormValue("category"), "issue"),
|
||||||
|
Severity: firstNonEmpty(r.FormValue("severity"), r.FormValue("priority"), "normal"),
|
||||||
|
Contact: firstNonEmpty(r.FormValue("contact"), r.FormValue("email")),
|
||||||
|
Body: firstNonEmpty(r.FormValue("body"), r.FormValue("message"), r.FormValue("description")),
|
||||||
|
Status: "new",
|
||||||
|
RemoteAddr: r.RemoteAddr,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Body) == "" {
|
||||||
|
item.Body = "No feedback body provided."
|
||||||
|
}
|
||||||
|
return item, s.store.InsertFeedback(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||||
|
if s.cfg.MaxRequestBytes > 0 {
|
||||||
|
if r.ContentLength > s.cfg.MaxRequestBytes {
|
||||||
|
return db.Feedback{}, errors.New("request is too large")
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(nilResponseWriter{}, r.Body, s.cfg.MaxRequestBytes)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
payloadText := strings.TrimSpace(r.FormValue("payload"))
|
||||||
|
timestamp := strings.TrimSpace(r.FormValue("timestamp"))
|
||||||
|
nonce := strings.TrimSpace(r.FormValue("nonce"))
|
||||||
|
packageSha256 := strings.ToLower(strings.TrimSpace(r.FormValue("packageSha256")))
|
||||||
|
signature := strings.ToLower(strings.TrimSpace(r.FormValue("signature")))
|
||||||
|
if payloadText == "" || timestamp == "" || nonce == "" || packageSha256 == "" || signature == "" {
|
||||||
|
return db.Feedback{}, errors.New("signed multipart fields are required")
|
||||||
|
}
|
||||||
|
if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) {
|
||||||
|
return db.Feedback{}, errors.New("timestamp outside accepted window")
|
||||||
|
}
|
||||||
|
if !isHexSHA256(packageSha256) {
|
||||||
|
return db.Feedback{}, errors.New("invalid package hash")
|
||||||
|
}
|
||||||
|
expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText)
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||||
|
return db.Feedback{}, errors.New("invalid request signature")
|
||||||
|
}
|
||||||
|
var payload submissionPayload
|
||||||
|
if err := json.Unmarshal([]byte(payloadText), &payload); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
if err := validatePayload(payload); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
code := NormalizeCode(payload.FeedbackCode)
|
||||||
|
if code == "" {
|
||||||
|
code = db.NewFeedbackCode()
|
||||||
|
}
|
||||||
|
if existing, err := s.store.GetFeedback(code); err == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("package")
|
||||||
|
if err != nil {
|
||||||
|
return db.Feedback{}, errors.New("missing package file")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(data, []byte(PackageMagic)) {
|
||||||
|
return db.Feedback{}, errors.New("encrypted package format is invalid")
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(sha256Hex(data)), []byte(packageSha256)) {
|
||||||
|
return db.Feedback{}, errors.New("package hash mismatch")
|
||||||
|
}
|
||||||
|
plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
if !isZipBytes(plain) {
|
||||||
|
return db.Feedback{}, errors.New("decrypted package is not a zip")
|
||||||
|
}
|
||||||
|
if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) {
|
||||||
|
if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) {
|
||||||
|
return db.Feedback{}, errors.New("decrypted package hash mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard)
|
||||||
|
if err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.cfg.StorageDir, "feedback")
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
encryptedPath := filepath.Join(dir, code+".ymfb")
|
||||||
|
packagePath := filepath.Join(dir, code+".zip")
|
||||||
|
if err := os.WriteFile(encryptedPath, data, 0o640); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(packagePath, plain, 0o640); err != nil {
|
||||||
|
return db.Feedback{}, err
|
||||||
|
}
|
||||||
|
item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr)
|
||||||
|
return item, s.store.InsertFeedback(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSignedFields(r *http.Request) bool {
|
||||||
|
if r.MultipartForm == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, key := range []string{"payload", "timestamp", "nonce", "packageSha256", "signature"} {
|
||||||
|
if strings.TrimSpace(r.FormValue(key)) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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(payload submissionPayload) error {
|
||||||
|
if payload.Title == "" || payload.Type == "" || payload.Severity == "" {
|
||||||
|
return errors.New("payload title, type and severity are required")
|
||||||
|
}
|
||||||
|
if payload.PackageBytes <= 0 || payload.PackageSha256 == "" || payload.PlainPackageSha256 == "" {
|
||||||
|
return errors.New("payload package hashes are required")
|
||||||
|
}
|
||||||
|
if !payload.PackageEncrypted || payload.Encryption != PackageMagic {
|
||||||
|
return errors.New("encrypted package is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUploadedPackage(file multipart.File, maxBytes int64) ([]byte, error) {
|
||||||
|
limit := maxBytes + 1
|
||||||
|
if limit <= 1 {
|
||||||
|
limit = 10*1024*1024 + 1
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
||||||
|
return nil, errors.New("feedback package is too large")
|
||||||
|
}
|
||||||
|
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 := append(append([]byte{}, ciphertext...), tag...)
|
||||||
|
return gcm.Open(nil, nonce, combined, []byte(PackageMagic))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 && float64(entry.UncompressedSize64)/float64(entry.CompressedSize64) > 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
|
||||||
|
} else {
|
||||||
|
request = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.Feedback {
|
||||||
|
title := firstNonEmpty(textFromMap(info.Request, "title"), payload.Title, "未命名反馈")
|
||||||
|
typ := firstNonEmpty(textFromMap(info.Request, "type"), payload.Type, "issue")
|
||||||
|
severity := firstNonEmpty(textFromMap(info.Request, "severity"), payload.Severity, "normal")
|
||||||
|
contact := firstNonEmpty(textFromMap(info.Request, "contact"), payload.Contact)
|
||||||
|
body := firstNonEmpty(textFromMap(info.Request, "body"), info.Summary)
|
||||||
|
return db.Feedback{
|
||||||
|
Code: code,
|
||||||
|
Title: title,
|
||||||
|
Type: typ,
|
||||||
|
Severity: severity,
|
||||||
|
Contact: contact,
|
||||||
|
Body: body,
|
||||||
|
Status: "new",
|
||||||
|
StatusDetail: "反馈已接收,等待后台处理。",
|
||||||
|
SourceChannel: "winui",
|
||||||
|
PackagePath: packagePath,
|
||||||
|
EncryptedPackagePath: encryptedPath,
|
||||||
|
PackageSha256: packageSha256,
|
||||||
|
PlainPackageSha256: plainPackageSha256,
|
||||||
|
SummaryText: info.Summary,
|
||||||
|
IncludedFiles: strings.Join(info.Files, ", "),
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func value(payload map[string]any, key, fallback string) string {
|
||||||
|
if raw, ok := payload[key].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFromMap(values map[string]any, key string) string {
|
||||||
|
if value, ok := values[key].(string); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type nilResponseWriter struct{}
|
||||||
|
|
||||||
|
func (nilResponseWriter) Header() http.Header { return http.Header{} }
|
||||||
|
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||||
|
func (nilResponseWriter) WriteHeader(int) {}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package feedback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignedMultipartSubmission(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := testConfig(root)
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
service := NewService(cfg, store)
|
||||||
|
plain := zipBytes(t, map[string]string{
|
||||||
|
"feedback.json": `{"request":{"title":"Crash on launch","type":"issue","severity":"major","contact":"user@example.com","body":"It crashes."}}`,
|
||||||
|
"summary.txt": "launch failure",
|
||||||
|
})
|
||||||
|
encrypted := encryptPackageForTest(t, plain, cfg.PackageEncryptionKey)
|
||||||
|
encryptedHash := sha256HexTest(encrypted)
|
||||||
|
plainHash := sha256HexTest(plain)
|
||||||
|
timestamp := itoa(int(time.Now().Unix()))
|
||||||
|
payload := `{"feedbackCode":"FB-20260625-ABCDEF","title":"Crash on launch","type":"issue","severity":"major","contact":"user@example.com","bodyLength":11,"packageEncrypted":true,"encryption":"YMHUTFB1","packageBytes":` + itoa(len(encrypted)) + `,"packageSha256":"` + encryptedHash + `","plainPackageBytes":` + itoa(len(plain)) + `,"plainPackageSha256":"` + plainHash + `","createdAt":"2026-06-25T00:00:00Z"}`
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
_ = writer.WriteField("payload", payload)
|
||||||
|
_ = writer.WriteField("timestamp", timestamp)
|
||||||
|
_ = writer.WriteField("nonce", "abc123")
|
||||||
|
_ = writer.WriteField("packageSha256", encryptedHash)
|
||||||
|
_ = writer.WriteField("signature", SignWithKey(cfg.ClientSignatureKey, timestamp, "abc123", encryptedHash, payload))
|
||||||
|
part, err := writer.CreateFormFile("package", "feedback.ymfb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(part, bytes.NewReader(encrypted))
|
||||||
|
_ = writer.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
item, err := service.Submit(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item.Code != "FB-20260625-ABCDEF" || !strings.Contains(item.IncludedFiles, "feedback.json") {
|
||||||
|
t.Fatalf("unexpected item: %#v", item)
|
||||||
|
}
|
||||||
|
if _, err := store.GetFeedback(item.Code); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipPathEscapeRejected(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := testConfig(root)
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
plain := zipBytes(t, map[string]string{"../escape.txt": "bad", "feedback.json": `{}`})
|
||||||
|
if _, err := ReadFeedbackPackageWithGuard(plain, cfg.UploadGuard); err == nil {
|
||||||
|
t.Fatal("expected unsafe zip entry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig(root string) *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
ClientSignatureKey: "ymhut-box-feedback-client-v1",
|
||||||
|
PackageEncryptionKey: "ymhut-box-feedback-package-v1",
|
||||||
|
TimestampWindowSeconds: 600,
|
||||||
|
MaxRequestBytes: 12 << 20,
|
||||||
|
MaxPackageBytes: 10 << 20,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
ConnMaxLifetimeSeconds: 60,
|
||||||
|
},
|
||||||
|
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipBytes(t *testing.T, files map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buf)
|
||||||
|
for name, body := range files {
|
||||||
|
entry, err := writer.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = entry.Write([]byte(body))
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptPackageForTest(t *testing.T, plain []byte, keyMaterial string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
key := sha256.Sum256([]byte(keyMaterial))
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonce := []byte("123456789012")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256HexTest(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(value int) string {
|
||||||
|
if value == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for value > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + value%10)
|
||||||
|
value /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Snapshot(cfg *config.Config, store *db.Store) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"version": config.Version,
|
||||||
|
"service": map[string]any{
|
||||||
|
"name": "YMhut Unified Management",
|
||||||
|
"baseUrl": cfg.BaseURL,
|
||||||
|
},
|
||||||
|
"database": store.Status(),
|
||||||
|
"preflight": config.Preflight(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package legacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
type Document struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Parsed map[string]any `json:"parsed"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Revisions []db.LegacyJsonRevision `json:"revisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveRequest struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Parsed map[string]any `json:"parsed"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||||
|
return &Service{cfg: cfg, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, name string) (Document, error) {
|
||||||
|
fileName, err := fileNameFor(name)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
parsed, formatted, err := parseAndFormat(name, data)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||||
|
updatedAt := ""
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
updatedAt = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Validate(ctx context.Context, name string, req SaveRequest) (Document, error) {
|
||||||
|
raw, err := requestRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
parsed, formatted, err := parseAndFormat(name, []byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
return Document{Name: name, Raw: formatted, Parsed: parsed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor string) (Document, error) {
|
||||||
|
fileName, err := fileNameFor(name)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
raw, err := requestRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
parsed, formatted, err := parseAndFormat(name, []byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||||||
|
current, _ := os.ReadFile(path)
|
||||||
|
if len(current) > 0 {
|
||||||
|
_, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before save", actor)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(path, []byte(formatted)); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
_, _ = s.store.SaveLegacyRevision(name, formatted, req.Note, actor)
|
||||||
|
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.saved", Target: name, Message: "Legacy JSON saved"})
|
||||||
|
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||||
|
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Restore(ctx context.Context, name string, revisionID int64, actor string) (Document, error) {
|
||||||
|
fileName, err := fileNameFor(name)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
revision, err := s.store.GetLegacyRevision(name, revisionID)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
parsed, formatted, err := parseAndFormat(name, []byte(revision.Raw))
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||||||
|
current, _ := os.ReadFile(path)
|
||||||
|
if len(current) > 0 {
|
||||||
|
_, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before restore", actor)
|
||||||
|
}
|
||||||
|
if err := atomicWrite(path, []byte(formatted)); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
_, _ = s.store.SaveLegacyRevision(name, formatted, "restored revision", actor)
|
||||||
|
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.restored", Target: name, Message: "Legacy JSON restored"})
|
||||||
|
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||||
|
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileNameFor(name string) (string, error) {
|
||||||
|
switch strings.TrimSpace(name) {
|
||||||
|
case "update-info":
|
||||||
|
return "update-info.json", nil
|
||||||
|
case "media-types":
|
||||||
|
return "media-types.json", nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported legacy document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRaw(req SaveRequest) (string, error) {
|
||||||
|
if strings.TrimSpace(req.Raw) != "" {
|
||||||
|
return req.Raw, nil
|
||||||
|
}
|
||||||
|
if req.Parsed == nil {
|
||||||
|
return "", errors.New("raw or parsed JSON is required")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req.Parsed)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAndFormat(name string, data []byte) (map[string]any, string, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := decoder.Decode(&parsed); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := validate(name, parsed); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(parsed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return parsed, string(out) + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(name string, parsed map[string]any) error {
|
||||||
|
switch name {
|
||||||
|
case "update-info":
|
||||||
|
if _, ok := parsed["app_version"]; !ok {
|
||||||
|
if _, ok := parsed["title"]; !ok {
|
||||||
|
return errors.New("update-info requires app_version or title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "media-types":
|
||||||
|
if _, ok := parsed["categories"].([]any); !ok {
|
||||||
|
return errors.New("media-types requires categories array")
|
||||||
|
}
|
||||||
|
if _, ok := parsed["layout_version"]; !ok {
|
||||||
|
parsed["layout_version"] = "1.0.0"
|
||||||
|
}
|
||||||
|
if _, ok := parsed["last_updated"]; !ok {
|
||||||
|
parsed["last_updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func atomicWrite(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package legacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveValidateAndRestoreLegacyJSON(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
public := filepath.Join(root, "public")
|
||||||
|
if err := os.MkdirAll(public, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(public, "media-types.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"layout_version":"1","categories":[]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
UpdatePublicDir: public,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
ConnMaxLifetimeSeconds: 60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
service := NewService(cfg, store)
|
||||||
|
if _, err := service.Validate(context.Background(), "media-types", SaveRequest{Raw: `{"not_categories":[]}`}); err == nil {
|
||||||
|
t.Fatal("expected validation failure")
|
||||||
|
}
|
||||||
|
saved, err := service.Save(context.Background(), "media-types", SaveRequest{Raw: `{"categories":[{"id":"image","name":"Image","subcategories":[]}]}`, Note: "test"}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.Parsed["layout_version"] == nil {
|
||||||
|
t.Fatal("layout_version was not filled")
|
||||||
|
}
|
||||||
|
revisions, err := store.ListLegacyRevisions("media-types", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(revisions) < 2 {
|
||||||
|
t.Fatalf("expected auto backup and saved revision, got %d", len(revisions))
|
||||||
|
}
|
||||||
|
restored, err := service.Restore(context.Background(), "media-types", revisions[len(revisions)-1].ID, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restored.Parsed["categories"] == nil {
|
||||||
|
t.Fatal("restored document missing categories")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
package notices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
type Document struct {
|
||||||
|
Notice db.ReleaseNotice `json:"notice"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Parsed map[string]any `json:"parsed"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Revisions []db.ReleaseNoticeRevision `json:"revisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveRequest struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Parsed map[string]any `json:"parsed"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||||
|
return &Service{cfg: cfg, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Import(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(s.cfg.UpdateNoticeDir) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.cfg.UpdateNoticeDir); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.importTotalIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.cfg.UpdateNoticeDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") || strings.EqualFold(entry.Name(), "total.json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.cfg.UpdateNoticeDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item, _, _, err := parseNotice(data, versionFromFile(entry.Name()), entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpsertReleaseNotice(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(limit int) ([]db.ReleaseNotice, error) {
|
||||||
|
return s.store.ListReleaseNotices(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(version string) (Document, error) {
|
||||||
|
item, err := s.store.GetReleaseNotice(version)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
raw := item.RawJSON
|
||||||
|
parsed, formatted, err := parseAndFormat([]byte(raw), version, item.NoticeFile)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
revisions, _ := s.store.ListReleaseNoticeRevisions(version, 20)
|
||||||
|
updatedAt := item.UpdatedAt
|
||||||
|
path := filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, version+".json"))
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
updatedAt = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return Document{Notice: item, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Validate(ctx context.Context, version string, req SaveRequest) (Document, error) {
|
||||||
|
raw, err := requestRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
return Document{Notice: item, Raw: formatted, Parsed: parsed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Save(ctx context.Context, version string, req SaveRequest, actor string) (Document, error) {
|
||||||
|
raw, err := requestRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
item.RawJSON = formatted
|
||||||
|
if current, err := s.store.GetReleaseNotice(item.Version); err == nil && current.RawJSON != "" {
|
||||||
|
_, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before save", actor)
|
||||||
|
}
|
||||||
|
saved, err := s.store.UpsertReleaseNotice(item)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
_, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, req.Note, actor)
|
||||||
|
if err := s.writeNoticeFile(saved, formatted); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
if err := s.writeTotalIndex(saved, parsed); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
if err := s.syncLegacyUpdateInfo(saved, parsed); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "Release notice saved"})
|
||||||
|
return s.Get(saved.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
|
||||||
|
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
|
||||||
|
if err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
return s.Save(ctx, version, SaveRequest{Raw: revision.RawJSON, Note: "restored revision"}, actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importTotalIndex() error {
|
||||||
|
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var root map[string]any
|
||||||
|
if err := json.Unmarshal(data, &root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if latest, ok := root["latest"].(map[string]any); ok {
|
||||||
|
raw, _ := json.MarshalIndent(latest, "", " ")
|
||||||
|
item, _, _, err := parseNotice(raw, stringValue(root, "latest_version"), stringValue(root, "latest_notice_file"))
|
||||||
|
if err == nil {
|
||||||
|
_, _ = s.store.UpsertReleaseNotice(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range arrayValue(root, "versions") {
|
||||||
|
version := stringValue(entry, "version")
|
||||||
|
if version == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, _ := json.MarshalIndent(entry, "", " ")
|
||||||
|
item, _, _, err := parseNotice(raw, version, stringValue(entry, "notice_file"))
|
||||||
|
if err == nil {
|
||||||
|
_, _ = s.store.UpsertReleaseNotice(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) writeNoticeFile(item db.ReleaseNotice, raw string) error {
|
||||||
|
if err := os.MkdirAll(s.cfg.UpdateNoticeDir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, item.Version+".json")), []byte(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any) error {
|
||||||
|
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
|
||||||
|
root := map[string]any{"schema_version": 1, "product": "YMhut Box", "versions": []any{}}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &root)
|
||||||
|
}
|
||||||
|
root["latest_version"] = newestVersion(stringValue(root, "latest_version"), item.Version)
|
||||||
|
if root["latest_version"] == item.Version {
|
||||||
|
root["latest_notice_file"] = item.NoticeFile
|
||||||
|
root["latest"] = latestMap(item, parsed)
|
||||||
|
}
|
||||||
|
root["last_updated"] = db.Now()
|
||||||
|
versions := arrayValue(root, "versions")
|
||||||
|
next := make([]any, 0, len(versions)+1)
|
||||||
|
found := false
|
||||||
|
for _, entry := range versions {
|
||||||
|
if stringValue(entry, "version") == item.Version {
|
||||||
|
next = append(next, summaryMap(item, parsed))
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, entry)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
next = append(next, summaryMap(item, parsed))
|
||||||
|
}
|
||||||
|
sort.SliceStable(next, func(i, j int) bool {
|
||||||
|
return compareVersion(stringValue(next[i], "version"), stringValue(next[j], "version")) > 0
|
||||||
|
})
|
||||||
|
root["versions"] = next
|
||||||
|
data, err := json.MarshalIndent(root, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(path, append(data, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
|
||||||
|
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||||
|
payload := map[string]any{}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &payload)
|
||||||
|
}
|
||||||
|
payload["app_version"] = item.Version
|
||||||
|
setNonEmpty(payload, "build", item.Build)
|
||||||
|
setNonEmpty(payload, "channel", item.Channel)
|
||||||
|
setNonEmpty(payload, "title", item.Title)
|
||||||
|
setNonEmpty(payload, "message", item.Message)
|
||||||
|
setNonEmpty(payload, "message_md", item.MessageMD)
|
||||||
|
setNonEmpty(payload, "release_notes", item.ReleaseNotes)
|
||||||
|
setNonEmpty(payload, "release_notes_md", item.ReleaseNotesMD)
|
||||||
|
setNonEmpty(payload, "download_url", item.DownloadURL)
|
||||||
|
payload["last_updated"] = firstNonEmpty(item.PublishedAt, db.Now())
|
||||||
|
for _, key := range []string{"update_notes", "last_update_notes", "download_mirrors", "detected_packages", "detected_product", "category_list", "home_notes", "tool_metadata", "api_keys"} {
|
||||||
|
if value, ok := parsed[key]; ok {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(path, append(data, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
|
||||||
|
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
|
||||||
|
return parsed, formatted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNotice, map[string]any, string, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := decoder.Decode(&parsed); err != nil {
|
||||||
|
return db.ReleaseNotice{}, nil, "", err
|
||||||
|
}
|
||||||
|
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
|
||||||
|
if version == "" {
|
||||||
|
return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required")
|
||||||
|
}
|
||||||
|
if noticeFile == "" {
|
||||||
|
noticeFile = version + ".json"
|
||||||
|
}
|
||||||
|
formattedBytes, err := json.MarshalIndent(parsed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return db.ReleaseNotice{}, nil, "", err
|
||||||
|
}
|
||||||
|
formatted := string(formattedBytes) + "\n"
|
||||||
|
item := db.ReleaseNotice{
|
||||||
|
Version: version,
|
||||||
|
Build: stringValue(parsed, "build"),
|
||||||
|
Channel: firstNonEmpty(stringValue(parsed, "channel"), "stable"),
|
||||||
|
Title: firstNonEmpty(stringValue(parsed, "title"), "YMhut Box "+version),
|
||||||
|
Message: firstNonEmpty(stringValue(parsed, "message"), stringValue(parsed, "summary"), stringValue(parsed, "home_notes")),
|
||||||
|
ReleaseNotes: firstNonEmpty(stringValue(parsed, "release_notes"), stringValue(parsed, "summary")),
|
||||||
|
MessageMD: stringValue(parsed, "message_md"),
|
||||||
|
ReleaseNotesMD: stringValue(parsed, "release_notes_md"),
|
||||||
|
DownloadURL: stringValue(parsed, "download_url"),
|
||||||
|
NoticeFile: noticeFile,
|
||||||
|
RawJSON: formatted,
|
||||||
|
PublishedAt: normalizeTime(firstNonEmpty(stringValue(parsed, "published_at"), stringValue(parsed, "release_date"), stringValue(parsed, "last_updated"))),
|
||||||
|
}
|
||||||
|
return item, parsed, formatted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRaw(req SaveRequest) (string, error) {
|
||||||
|
if strings.TrimSpace(req.Raw) != "" {
|
||||||
|
return req.Raw, nil
|
||||||
|
}
|
||||||
|
if req.Parsed == nil {
|
||||||
|
return "", errors.New("raw or parsed JSON is required")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req.Parsed)
|
||||||
|
return string(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
|
||||||
|
out := summaryMap(item, parsed)
|
||||||
|
out["title"] = item.Title
|
||||||
|
out["message"] = item.Message
|
||||||
|
out["download_url"] = item.DownloadURL
|
||||||
|
out["release_notes"] = item.ReleaseNotes
|
||||||
|
out["message_md"] = item.MessageMD
|
||||||
|
out["release_notes_md"] = item.ReleaseNotesMD
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
|
||||||
|
out := map[string]any{
|
||||||
|
"version": item.Version,
|
||||||
|
"build": item.Build,
|
||||||
|
"channel": item.Channel,
|
||||||
|
"release_date": dateOnly(item.PublishedAt),
|
||||||
|
"notice_file": item.NoticeFile,
|
||||||
|
"summary": firstNonEmpty(item.Message, item.ReleaseNotes),
|
||||||
|
}
|
||||||
|
if value, ok := parsed["highlights"]; ok {
|
||||||
|
out["highlights"] = value
|
||||||
|
}
|
||||||
|
if value, ok := parsed["categories"]; ok {
|
||||||
|
out["categories"] = value
|
||||||
|
} else if value, ok := parsed["update_notes"]; ok {
|
||||||
|
out["categories"] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func PublicNotice(item db.ReleaseNotice) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"version": item.Version,
|
||||||
|
"build": item.Build,
|
||||||
|
"channel": item.Channel,
|
||||||
|
"title": item.Title,
|
||||||
|
"message": item.Message,
|
||||||
|
"release_notes": item.ReleaseNotes,
|
||||||
|
"message_md": item.MessageMD,
|
||||||
|
"release_notes_md": item.ReleaseNotesMD,
|
||||||
|
"download_url": item.DownloadURL,
|
||||||
|
"notice_file": item.NoticeFile,
|
||||||
|
"published_at": item.PublishedAt,
|
||||||
|
"updated_at": item.UpdatedAt,
|
||||||
|
"releaseNotes": item.ReleaseNotes,
|
||||||
|
"messageMarkdown": item.MessageMD,
|
||||||
|
"releaseNotesMarkdown": item.ReleaseNotesMD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PublicList(items []db.ReleaseNotice) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, PublicNotice(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrayValue(root map[string]any, key string) []any {
|
||||||
|
if values, ok := root[key].([]any); ok {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
return []any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(root any, key string) string {
|
||||||
|
obj, ok := root.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch value := obj[key].(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
case json.Number:
|
||||||
|
return value.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNonEmpty(target map[string]any, key, value string) {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTime(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateOnly(value string) string {
|
||||||
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return t.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if len(value) >= 10 {
|
||||||
|
return value[:10]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func newestVersion(current, candidate string) string {
|
||||||
|
if current == "" || compareVersion(candidate, current) >= 0 {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareVersion(a, b string) int {
|
||||||
|
as := strings.Split(a, ".")
|
||||||
|
bs := strings.Split(b, ".")
|
||||||
|
for len(as) < 4 {
|
||||||
|
as = append(as, "0")
|
||||||
|
}
|
||||||
|
for len(bs) < 4 {
|
||||||
|
bs = append(bs, "0")
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
ai := parseInt(as[i])
|
||||||
|
bi := parseInt(bs[i])
|
||||||
|
if ai > bi {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ai < bi {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(value string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionFromFile(name string) string {
|
||||||
|
return strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func atomicWrite(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package notices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveNoticeSyncsFilesAndLegacyUpdateInfo(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
public := filepath.Join(root, "public")
|
||||||
|
noticeDir := filepath.Join(root, "update-notice")
|
||||||
|
if err := os.MkdirAll(public, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeJSON(t, filepath.Join(public, "update-info.json"), map[string]any{"app_version": "1.0.0"})
|
||||||
|
writeJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{"schema_version": 1, "versions": []any{}})
|
||||||
|
|
||||||
|
store, err := db.Open(&config.Config{
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
UpdatePublicDir: public,
|
||||||
|
UpdateNoticeDir: noticeDir,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
ConnMaxLifetimeSeconds: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
service := NewService(&config.Config{UpdatePublicDir: public, UpdateNoticeDir: noticeDir}, store)
|
||||||
|
raw := `{"app_version":"2.0.1","title":"YMhut Box 2.0.1","message":"hello","release_notes":"notes","release_notes_md":"## Notes","download_url":"https://update.ymhut.cn/downloads/app.exe","update_notes":{"发布":"说明"}}`
|
||||||
|
doc, err := service.Save(context.Background(), "2.0.1", SaveRequest{Raw: raw, Note: "test"}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if doc.Notice.Version != "2.0.1" {
|
||||||
|
t.Fatalf("unexpected version %q", doc.Notice.Version)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(noticeDir, "2.0.1.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updateInfo := readJSONFile(t, filepath.Join(public, "update-info.json"))
|
||||||
|
if updateInfo["app_version"] != "2.0.1" || updateInfo["release_notes"] != "notes" {
|
||||||
|
t.Fatalf("legacy update-info not synced: %#v", updateInfo)
|
||||||
|
}
|
||||||
|
total := readJSONFile(t, filepath.Join(noticeDir, "total.json"))
|
||||||
|
if total["latest_version"] != "2.0.1" {
|
||||||
|
t.Fatalf("total index not synced: %#v", total)
|
||||||
|
}
|
||||||
|
revisions, err := store.ListReleaseNoticeRevisions("2.0.1", 10)
|
||||||
|
if err != nil || len(revisions) == 0 {
|
||||||
|
t.Fatalf("expected revision, got %d, %v", len(revisions), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, path string, payload any) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSONFile(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package releases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
"ymhut-box/server/unified-management/internal/notices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
notices *notices.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||||||
|
service := &Service{cfg: cfg, store: store}
|
||||||
|
if len(noticeService) > 0 {
|
||||||
|
service.notices = noticeService[0]
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
|
||||||
|
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||||||
|
manifest := s.Manifest(r)
|
||||||
|
for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} {
|
||||||
|
if value, ok := manifest[key]; ok {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Manifest(r *http.Request) map[string]any {
|
||||||
|
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||||||
|
packages := s.ScanPackages(r)
|
||||||
|
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
|
||||||
|
if modules == nil {
|
||||||
|
modules = []any{}
|
||||||
|
}
|
||||||
|
payload["manifest_version"] = 2
|
||||||
|
payload["service_version"] = config.Version
|
||||||
|
payload["packages"] = packages
|
||||||
|
payload["modules"] = modules
|
||||||
|
payload["assets"] = []any{}
|
||||||
|
payload["generated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if s.notices != nil {
|
||||||
|
if items, err := s.notices.List(50); err == nil && len(items) > 0 {
|
||||||
|
publicNotices := notices.PublicList(items)
|
||||||
|
payload["notices"] = publicNotices
|
||||||
|
payload["latest_notice"] = publicNotices[0]
|
||||||
|
latestNotice := items[0]
|
||||||
|
setIfMissing(payload, "app_version", latestNotice.Version)
|
||||||
|
setIfMissing(payload, "title", latestNotice.Title)
|
||||||
|
setIfMissing(payload, "message", latestNotice.Message)
|
||||||
|
setIfMissing(payload, "message_md", latestNotice.MessageMD)
|
||||||
|
setIfMissing(payload, "release_notes", latestNotice.ReleaseNotes)
|
||||||
|
setIfMissing(payload, "release_notes_md", latestNotice.ReleaseNotesMD)
|
||||||
|
setIfMissing(payload, "download_url", latestNotice.DownloadURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(packages) > 0 {
|
||||||
|
latest := packages[0]
|
||||||
|
payload["app_version"] = latest.Version
|
||||||
|
payload["download_url"] = latest.URL
|
||||||
|
payload["download_mirrors"] = []map[string]any{{
|
||||||
|
"id": "primary",
|
||||||
|
"name": "官方直连",
|
||||||
|
"url": latest.URL,
|
||||||
|
"type": "direct",
|
||||||
|
"sha256": latest.SHA256,
|
||||||
|
"enabled": true,
|
||||||
|
}}
|
||||||
|
payload["detected_product"] = latest.Name
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIfMissing(payload map[string]any, key, value string) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing, ok := payload[key].(string); !ok || strings.TrimSpace(existing) == "" {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ScanPackages(r *http.Request) []Package {
|
||||||
|
entries, err := os.ReadDir(s.cfg.DownloadsDir)
|
||||||
|
if err != nil {
|
||||||
|
return []Package{}
|
||||||
|
}
|
||||||
|
base := requestBaseURL(r, s.cfg.BaseURL)
|
||||||
|
items := []Package{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if !(strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".appinstaller") || strings.HasSuffix(lower, ".msi")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version := detectVersion(name)
|
||||||
|
platform, arch := detectPlatform(name)
|
||||||
|
product := detectProduct(name)
|
||||||
|
url := base + "/downloads/" + name
|
||||||
|
items = append(items, Package{
|
||||||
|
ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
|
||||||
|
Name: product,
|
||||||
|
Version: version,
|
||||||
|
Platform: platform,
|
||||||
|
Arch: arch,
|
||||||
|
URL: url,
|
||||||
|
SHA256: sha256File(filepath.Join(s.cfg.DownloadsDir, name)),
|
||||||
|
Size: info.Size(),
|
||||||
|
Required: strings.Contains(strings.ToLower(product), "ymhut"),
|
||||||
|
Enabled: true,
|
||||||
|
FileName: name,
|
||||||
|
UpdatedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return compareVersion(items[i].Version, items[j].Version) > 0
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StaticJSON(name string) map[string]any {
|
||||||
|
return readJSON(filepath.Join(s.cfg.UpdatePublicDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(path string) map[string]any {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(r *http.Request, fallback string) string {
|
||||||
|
if r != nil {
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
} else {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Host != "" {
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(fallback, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:\.\d+)?`)
|
||||||
|
|
||||||
|
func detectVersion(name string) string {
|
||||||
|
match := versionPattern.FindString(name)
|
||||||
|
if match == "" {
|
||||||
|
return "0.0.0"
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPlatform(name string) (string, string) {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
platform := "windows"
|
||||||
|
if strings.Contains(lower, "appinstaller") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msi") {
|
||||||
|
platform = "windows"
|
||||||
|
}
|
||||||
|
arch := "x64"
|
||||||
|
if strings.Contains(lower, "arm64") {
|
||||||
|
arch = "arm64"
|
||||||
|
} else if strings.Contains(lower, "x86") && !strings.Contains(lower, "x64") {
|
||||||
|
arch = "x86"
|
||||||
|
}
|
||||||
|
return platform, arch
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectProduct(name string) string {
|
||||||
|
if strings.Contains(strings.ToLower(name), "ymhut") {
|
||||||
|
return "YMhut Box"
|
||||||
|
}
|
||||||
|
return "YMhut Package"
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareVersion(a, b string) int {
|
||||||
|
as := strings.Split(a, ".")
|
||||||
|
bs := strings.Split(b, ".")
|
||||||
|
for len(as) < 4 {
|
||||||
|
as = append(as, "0")
|
||||||
|
}
|
||||||
|
for len(bs) < 4 {
|
||||||
|
bs = append(bs, "0")
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
ai, _ := strconv.Atoi(as[i])
|
||||||
|
bi, _ := strconv.Atoi(bs[i])
|
||||||
|
if ai > bi {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ai < bi {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256File(path string) string {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package releases
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareVersion(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a string
|
||||||
|
b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"2.0.6.31", "2.0.6.2", 1},
|
||||||
|
{"2.0.10", "2.0.9", 1},
|
||||||
|
{"2.0.6.2", "2.0.6.31", -1},
|
||||||
|
{"2.0.6", "2.0.6.0", 0},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := compareVersion(tc.a, tc.b); got != tc.want {
|
||||||
|
t.Fatalf("compareVersion(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPackageMetadata(t *testing.T) {
|
||||||
|
platform, arch := detectPlatform("YMhutBox_2.0.6.31_x64.msix")
|
||||||
|
if platform != "windows" || arch != "x64" {
|
||||||
|
t.Fatalf("detectPlatform returned %s/%s", platform, arch)
|
||||||
|
}
|
||||||
|
if version := detectVersion("YMhut_Box_WinUI_Setup_2.0.6.31.exe"); version != "2.0.6.31" {
|
||||||
|
t.Fatalf("detectVersion returned %q", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
client *http.Client
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyMedia struct {
|
||||||
|
Categories []legacyCategory `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyCategory struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Subcategories []legacySubcategory `json:"subcategories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacySubcategory struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
APIURL string `json:"api_url"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
RefreshInterval int `json:"refresh_interval"`
|
||||||
|
SupportedFormats []string `json:"supported_formats"`
|
||||||
|
Downloadable bool `json:"downloadable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
store: store,
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Start(ctx context.Context) {
|
||||||
|
s.once.Do(func() {
|
||||||
|
go s.loop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Stop() {
|
||||||
|
close(s.stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loop() {
|
||||||
|
ticker := time.NewTicker(time.Duration(s.cfg.SourceCheckSeconds) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
s.CheckDue(context.Background())
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.CheckDue(context.Background())
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportLegacyMediaTypesIfEmpty(ctx context.Context) error {
|
||||||
|
count, err := s.store.CountSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.ImportLegacyMediaTypes(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
|
||||||
|
data, err := os.ReadFile(filepath.Join(s.cfg.UpdatePublicDir, "media-types.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var legacy legacyMedia
|
||||||
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, category := range legacy.Categories {
|
||||||
|
for _, sub := range category.Subcategories {
|
||||||
|
if strings.TrimSpace(sub.APIURL) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formats, _ := json.Marshal(sub.SupportedFormats)
|
||||||
|
_, err := s.store.UpsertSource(db.Source{
|
||||||
|
CategoryID: defaultString(category.ID, "media"),
|
||||||
|
CategoryName: defaultString(category.Name, category.ID),
|
||||||
|
SourceID: defaultString(sub.ID, category.ID+"-"+sub.Name),
|
||||||
|
Name: defaultString(sub.Name, sub.ID),
|
||||||
|
Description: sub.Description,
|
||||||
|
Method: "GET",
|
||||||
|
APIURL: sub.APIURL,
|
||||||
|
ThumbnailURL: sub.ThumbnailURL,
|
||||||
|
ProxyMode: "client_direct",
|
||||||
|
TimeoutMS: 8000,
|
||||||
|
RetryCount: 1,
|
||||||
|
CheckIntervalSec: maxInt(sub.RefreshInterval, 300),
|
||||||
|
Enabled: legacyEnabled(category.Enabled),
|
||||||
|
ClientVisible: true,
|
||||||
|
SupportedFormats: string(formats),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||||
|
items, err := s.store.ListSources(includeHidden)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
categories := map[string]map[string]any{}
|
||||||
|
for _, item := range items {
|
||||||
|
cat, ok := categories[item.CategoryID]
|
||||||
|
if !ok {
|
||||||
|
cat = map[string]any{
|
||||||
|
"id": item.CategoryID,
|
||||||
|
"name": item.CategoryName,
|
||||||
|
"enabled": true,
|
||||||
|
"subcategories": []map[string]any{},
|
||||||
|
}
|
||||||
|
categories[item.CategoryID] = cat
|
||||||
|
}
|
||||||
|
var formats []string
|
||||||
|
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||||
|
sub := map[string]any{
|
||||||
|
"id": item.SourceID,
|
||||||
|
"name": item.Name,
|
||||||
|
"description": item.Description,
|
||||||
|
"api_url": item.APIURL,
|
||||||
|
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
|
"thumbnail_url": item.ThumbnailURL,
|
||||||
|
"method": item.Method,
|
||||||
|
"proxy_mode": item.ProxyMode,
|
||||||
|
"proxyMode": item.ProxyMode,
|
||||||
|
"refresh_interval": item.CheckIntervalSec,
|
||||||
|
"cacheSeconds": item.CacheSeconds,
|
||||||
|
"supported_formats": formats,
|
||||||
|
"downloadable": true,
|
||||||
|
"health": map[string]any{
|
||||||
|
"status": item.LastStatus,
|
||||||
|
"latency_ms": item.LastLatencyMS,
|
||||||
|
"last_checked_at": item.LastCheckedAt,
|
||||||
|
"last_error": item.LastError,
|
||||||
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
||||||
|
}
|
||||||
|
out := []map[string]any{}
|
||||||
|
for _, cat := range categories {
|
||||||
|
out = append(out, cat)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"layout_version": "2.0.0",
|
||||||
|
"last_updated": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"categories": out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
||||||
|
items, err := s.store.ListSources(includeHidden)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []map[string]any{}
|
||||||
|
for _, item := range items {
|
||||||
|
var formats []string
|
||||||
|
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"id": item.SourceID,
|
||||||
|
"category": item.CategoryID,
|
||||||
|
"name": item.Name,
|
||||||
|
"method": item.Method,
|
||||||
|
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
|
"proxyMode": item.ProxyMode,
|
||||||
|
"clientVisible": item.ClientVisible,
|
||||||
|
"enabled": item.Enabled,
|
||||||
|
"cacheSeconds": item.CacheSeconds,
|
||||||
|
"supportedFormats": formats,
|
||||||
|
"health": map[string]any{
|
||||||
|
"status": item.LastStatus,
|
||||||
|
"latencyMs": item.LastLatencyMS,
|
||||||
|
"lastCheckedAt": item.LastCheckedAt,
|
||||||
|
"lastError": item.LastError,
|
||||||
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CheckDue(ctx context.Context) {
|
||||||
|
items, err := s.store.ListSources(true)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.LastCheckedAt != "" {
|
||||||
|
if last, err := time.Parse(time.RFC3339, item.LastCheckedAt); err == nil && now.Sub(last) < time.Duration(item.CheckIntervalSec)*time.Second {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.CheckOne(ctx, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CheckSourceID(ctx context.Context, sourceID string) (db.Source, error) {
|
||||||
|
item, err := s.store.GetSourceBySourceID(sourceID)
|
||||||
|
if err != nil {
|
||||||
|
return db.Source{}, err
|
||||||
|
}
|
||||||
|
return item, s.CheckOne(ctx, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||||
|
if strings.TrimSpace(item.APIURL) == "" {
|
||||||
|
return errors.New("source api_url is empty")
|
||||||
|
}
|
||||||
|
timeout := time.Duration(item.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 8 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, item.Method, item.APIURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
latency := int(time.Since(start).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
_ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
status := "ok"
|
||||||
|
message := ""
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
status = "degraded"
|
||||||
|
message = resp.Status
|
||||||
|
}
|
||||||
|
return s.store.RecordSourceCheck(item.ID, status, latency, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyEnabled(value *bool) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(value, fallback int) int {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package synclegacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
"ymhut-box/server/unified-management/internal/notices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
notices *notices.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
Paths map[string]any `json:"paths"`
|
||||||
|
Stats map[string]int `json:"stats"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Started string `json:"startedAt"`
|
||||||
|
Finished string `json:"finishedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, store *db.Store, noticeService *notices.Service) *Service {
|
||||||
|
return &Service{cfg: cfg, store: store, notices: noticeService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Preview(ctx context.Context) Result {
|
||||||
|
return s.run(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) Result {
|
||||||
|
return s.run(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||||
|
result := Result{
|
||||||
|
Ok: true,
|
||||||
|
DryRun: dryRun,
|
||||||
|
Paths: map[string]any{
|
||||||
|
"legacyUpdateDir": s.cfg.LegacyUpdateDir,
|
||||||
|
"legacyFeedbackDir": s.cfg.LegacyFeedbackDir,
|
||||||
|
"legacyUpdateNoticeDir": s.cfg.LegacyUpdateNoticeDir,
|
||||||
|
"updatePublicDir": s.cfg.UpdatePublicDir,
|
||||||
|
"updateNoticeDir": s.cfg.UpdateNoticeDir,
|
||||||
|
},
|
||||||
|
Stats: map[string]int{},
|
||||||
|
Started: db.Now(),
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
result.Finished = db.Now()
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
result.Ok = false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
s.previewPath(&result, "legacy_update", s.cfg.LegacyUpdateDir)
|
||||||
|
s.previewPath(&result, "legacy_feedback", s.cfg.LegacyFeedbackDir)
|
||||||
|
s.previewPath(&result, "legacy_update_notice", s.cfg.LegacyUpdateNoticeDir)
|
||||||
|
if dryRun {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if err := s.backupCurrent(); err != nil {
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
s.syncUpdatePublic(&result)
|
||||||
|
s.syncNotices(ctx, &result)
|
||||||
|
s.syncFeedbackSQLite(&result)
|
||||||
|
_ = s.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "legacy.sync", Target: "legacy-projects", Message: fmt.Sprintf("Legacy sync finished: ok=%v copied=%d imported=%d errors=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) previewPath(result *Result, key, path string) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, key+": "+err.Error())
|
||||||
|
result.Stats["missingPaths"]++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
result.Warnings = append(result.Warnings, key+": path is not a directory")
|
||||||
|
result.Stats["missingPaths"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) backupCurrent() error {
|
||||||
|
backupRoot := filepath.Join(s.cfg.StorageDir, "backups", "legacy-sync-"+time.Now().UTC().Format("20060102-150405"))
|
||||||
|
for _, path := range []string{s.cfg.UpdatePublicDir, s.cfg.UpdateNoticeDir} {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
target := filepath.Join(backupRoot, filepath.Base(path))
|
||||||
|
if err := copyDir(path, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncUpdatePublic(result *Result) {
|
||||||
|
sourcePublic := filepath.Join(s.cfg.LegacyUpdateDir, "public")
|
||||||
|
if _, err := os.Stat(sourcePublic); err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "update public not found: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, name := range []string{"update-info.json", "media-types.json", "tool-status.json", "modules.json"} {
|
||||||
|
source := filepath.Join(sourcePublic, name)
|
||||||
|
if _, err := os.Stat(source); err == nil {
|
||||||
|
if err := copyFile(source, filepath.Join(s.cfg.UpdatePublicDir, name)); err != nil {
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
} else {
|
||||||
|
result.Stats["copiedFiles"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceDownloads := filepath.Join(sourcePublic, "downloads")
|
||||||
|
if _, err := os.Stat(sourceDownloads); err == nil {
|
||||||
|
if err := copyDir(sourceDownloads, s.cfg.DownloadsDir); err != nil {
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
} else {
|
||||||
|
result.Stats["copiedDirectories"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncNotices(ctx context.Context, result *Result) {
|
||||||
|
for _, source := range []string{filepath.Join(s.cfg.LegacyUpdateDir, "update-notice"), s.cfg.LegacyUpdateNoticeDir} {
|
||||||
|
if _, err := os.Stat(source); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := copyDir(source, s.cfg.UpdateNoticeDir); err != nil {
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Stats["copiedDirectories"]++
|
||||||
|
}
|
||||||
|
if s.notices != nil {
|
||||||
|
if err := s.notices.Import(ctx); err != nil {
|
||||||
|
result.Errors = append(result.Errors, "notice import: "+err.Error())
|
||||||
|
} else {
|
||||||
|
result.Stats["noticeImports"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncFeedbackSQLite(result *Result) {
|
||||||
|
path := filepath.Join(s.cfg.LegacyFeedbackDir, "storage", "feedback.sqlite")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "feedback sqlite not found: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldDB, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer oldDB.Close()
|
||||||
|
s.importOldFeedbacks(oldDB, result)
|
||||||
|
s.importOldComments(oldDB, result)
|
||||||
|
s.importOldEvents(oldDB, result)
|
||||||
|
s.importOldTags(oldDB, result)
|
||||||
|
s.importOldMail(oldDB, result)
|
||||||
|
s.importOldWebhooks(oldDB, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldFeedbacks(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT 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 FROM feedbacks`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "feedbacks: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var item db.Feedback
|
||||||
|
var mailSent int
|
||||||
|
if err := rows.Scan(&item.Code, &item.CreatedAt, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact, &item.Body, &item.Status, &item.StatusDetail, &item.Note, &item.PublicReply, &item.HandledBy, &item.Assignee, &item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore, &item.Resolution, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256, &item.PlainPackageSha256, &item.RemoteAddr, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.UpdatedAt, &item.LastActivityAt); err != nil {
|
||||||
|
result.Errors = append(result.Errors, "feedback scan: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.MailSent = mailSent == 1
|
||||||
|
item.PackagePath = s.copyLegacyFeedbackFile(item.PackagePath, item.Code, result)
|
||||||
|
item.EncryptedPackagePath = s.copyLegacyFeedbackFile(item.EncryptedPackagePath, item.Code, result)
|
||||||
|
if err := s.store.InsertFeedback(item); err != nil && !strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
|
result.Errors = append(result.Errors, "feedback import "+item.Code+": "+err.Error())
|
||||||
|
} else {
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldComments(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "comments: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var comment db.FeedbackComment
|
||||||
|
var internal int
|
||||||
|
var oldID int64
|
||||||
|
if err := rows.Scan(&oldID, &comment.Code, &comment.Author, &comment.Body, &internal, &comment.CreatedAt); err != nil {
|
||||||
|
result.Errors = append(result.Errors, "comment scan: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comment.Internal = internal == 1
|
||||||
|
if _, err := s.store.InsertFeedbackComment(comment); err == nil {
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldEvents(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "events: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var event db.LegacyFeedbackEvent
|
||||||
|
if err := rows.Scan(&event.ID, &event.FeedbackCode, &event.EventType, &event.Actor, &event.FromValue, &event.ToValue, &event.Message, &event.CreatedAt); err != nil {
|
||||||
|
result.Errors = append(result.Errors, "event scan: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertFeedbackEvent(event); err == nil {
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldTags(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT feedback_code, tag, created_at FROM feedback_tags`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "tags: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var code, tag, createdAt string
|
||||||
|
if err := rows.Scan(&code, &tag, &createdAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertFeedbackTag(code, tag, createdAt); err == nil {
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldMail(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "mail_records: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var item db.LegacyMailRecord
|
||||||
|
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Status, &item.ToAddress, &item.Subject, &item.AttachmentPath, &item.AttachmentName, &item.ErrorMessage, &item.CreatedAt, &item.SentAt); err != nil {
|
||||||
|
result.Errors = append(result.Errors, "mail scan: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.AttachmentPath = s.copyLegacyFeedbackFile(item.AttachmentPath, item.FeedbackCode, result)
|
||||||
|
if err := s.store.UpsertMailRecord(item); err == nil {
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
|
||||||
|
rows, err := oldDB.Query(`SELECT id, webhook_name, event, status, attempts, response_code, error_message, payload_sha256, created_at, finished_at FROM webhook_deliveries`)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "webhook_deliveries: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, event, status, message, payload, createdAt, finishedAt string
|
||||||
|
var attempts, response int
|
||||||
|
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: event + " " + message, CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||||
|
result.Stats["importedRows"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) copyLegacyFeedbackFile(path, code string, result *Result) string {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
source := path
|
||||||
|
if !filepath.IsAbs(source) {
|
||||||
|
source = filepath.Join(s.cfg.LegacyFeedbackDir, source)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(source)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
target := filepath.Join(s.cfg.StorageDir, "legacy-feedback", safeName(code), filepath.Base(source))
|
||||||
|
if err := copyFile(source, target); err != nil {
|
||||||
|
result.Warnings = append(result.Warnings, "copy attachment: "+err.Error())
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
result.Stats["copiedFiles"]++
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(source, target string) error {
|
||||||
|
sourceInfo, err := os.Stat(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sourceInfo.IsDir() {
|
||||||
|
return errors.New(source + " is not a directory")
|
||||||
|
}
|
||||||
|
return filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(source, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(target, rel)
|
||||||
|
if entry.IsDir() {
|
||||||
|
return os.MkdirAll(dest, 0o750)
|
||||||
|
}
|
||||||
|
return copyFile(path, dest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(source, target string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeName(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '-'
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,979 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/auth"
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
"ymhut-box/server/unified-management/internal/feedback"
|
||||||
|
"ymhut-box/server/unified-management/internal/health"
|
||||||
|
"ymhut-box/server/unified-management/internal/legacy"
|
||||||
|
"ymhut-box/server/unified-management/internal/notices"
|
||||||
|
"ymhut-box/server/unified-management/internal/releases"
|
||||||
|
"ymhut-box/server/unified-management/internal/sources"
|
||||||
|
"ymhut-box/server/unified-management/internal/synclegacy"
|
||||||
|
webassets "ymhut-box/server/unified-management/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type router struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
auth *auth.Service
|
||||||
|
feedback *feedback.Service
|
||||||
|
releases *releases.Service
|
||||||
|
sources *sources.Service
|
||||||
|
legacy *legacy.Service
|
||||||
|
notices *notices.Service
|
||||||
|
syncer *synclegacy.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
|
||||||
|
r := &router{
|
||||||
|
cfg: cfg,
|
||||||
|
store: store,
|
||||||
|
auth: authService,
|
||||||
|
feedback: feedbackService,
|
||||||
|
releases: releaseService,
|
||||||
|
sources: sourceService,
|
||||||
|
legacy: legacyService,
|
||||||
|
}
|
||||||
|
for _, item := range optional {
|
||||||
|
switch typed := item.(type) {
|
||||||
|
case *notices.Service:
|
||||||
|
r.notices = typed
|
||||||
|
case *synclegacy.Service:
|
||||||
|
r.syncer = typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withSecurity(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch {
|
||||||
|
case path == "/" && req.Method == http.MethodPost:
|
||||||
|
r.handleFeedbackSubmit(w, req)
|
||||||
|
case path == "/" && req.URL.Query().Get("api") == "status":
|
||||||
|
r.handleFeedbackStatus(w, req)
|
||||||
|
case isPortalRoute(path):
|
||||||
|
r.servePortal(w, req)
|
||||||
|
case path == "/api/auth/bootstrap" || path == "/api/admin/auth/bootstrap":
|
||||||
|
r.handleAuthBootstrap(w, req)
|
||||||
|
case path == "/api/auth/captcha" || path == "/api/admin/auth/captcha":
|
||||||
|
r.handleCaptcha(w, req)
|
||||||
|
case path == "/api/auth/login" || path == "/api/admin/auth/login":
|
||||||
|
r.handleLogin(w, req)
|
||||||
|
case path == "/api/auth/logout" || path == "/api/admin/auth/logout":
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleLogout)).ServeHTTP(w, req)
|
||||||
|
case path == "/api/admin/auth/password":
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
|
||||||
|
case path == "/api/client/bootstrap":
|
||||||
|
r.handleClientBootstrap(w, req)
|
||||||
|
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
|
||||||
|
writeJSON(w, http.StatusOK, r.releases.Manifest(req))
|
||||||
|
case path == "/api/client/sources":
|
||||||
|
r.handleClientSources(w, req)
|
||||||
|
case path == "/api/client/endpoints":
|
||||||
|
r.handleClientEndpoints(w, req)
|
||||||
|
case path == "/api/client/notices" || strings.HasPrefix(path, "/api/client/notices/"):
|
||||||
|
r.handleClientNotices(w, req)
|
||||||
|
case path == "/api/client/endpoint-calls" || path == "/api/client/source-calls":
|
||||||
|
r.handleSourceCall(w, req)
|
||||||
|
case path == "/update-info.json" || path == "/update-info":
|
||||||
|
writeJSON(w, http.StatusOK, r.releases.LegacyUpdateInfo(req))
|
||||||
|
case path == "/tool-status.json" || path == "/tool-status":
|
||||||
|
writeJSON(w, http.StatusOK, r.releases.StaticJSON("tool-status.json"))
|
||||||
|
case path == "/modules.json" || path == "/modules" || path == "/api/modules":
|
||||||
|
writeJSON(w, http.StatusOK, r.releases.StaticJSON("modules.json"))
|
||||||
|
case path == "/media-types.json" || path == "/media-types":
|
||||||
|
r.handleLegacyMediaTypes(w, req)
|
||||||
|
case strings.HasPrefix(path, "/downloads/"):
|
||||||
|
r.handleDownload(w, req)
|
||||||
|
case strings.HasPrefix(path, "/admin/assets/"):
|
||||||
|
serveStaticAsset(w, req, r.cfg.AdminWebDir, "admin/dist", strings.TrimPrefix(path, "/admin/"))
|
||||||
|
case strings.HasPrefix(path, "/assets/"):
|
||||||
|
serveStaticAsset(w, req, r.cfg.PortalWebDir, "portal/dist", strings.TrimPrefix(path, "/"))
|
||||||
|
case strings.HasPrefix(path, "/api/admin/feedbacks"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminFeedbacks)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/dashboard"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminDashboard)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/sync"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminSync)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/releases"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminReleases)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/sources"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminSources)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/endpoints"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminEndpoints)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/legacy"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/database"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminDatabase)).ServeHTTP(w, req)
|
||||||
|
case strings.HasPrefix(path, "/api/admin/system"):
|
||||||
|
r.auth.Require(http.HandlerFunc(r.handleAdminSystem)).ServeHTTP(w, req)
|
||||||
|
case path == "/admin" || path == "/admin/":
|
||||||
|
http.Redirect(w, req, "/admin/dashboard", http.StatusFound)
|
||||||
|
case path == "/admin/login" || strings.HasPrefix(path, "/admin/"):
|
||||||
|
r.serveAdmin(w, req)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
|
||||||
|
payload, err := r.auth.Bootstrap(req.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "BOOTSTRAP_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
|
||||||
|
captcha, err := r.auth.NewCaptcha()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "captchaId": captcha.ID, "image": captcha.Image})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
Captcha string `json:"captcha"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Username == "" {
|
||||||
|
body.Username = "admin"
|
||||||
|
}
|
||||||
|
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.SetSessionCookie(w, sessionID)
|
||||||
|
_ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "Admin login", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
|
||||||
|
r.auth.Logout(w, req)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.store.ChangeAdminPassword(req.Context(), "admin", body.CurrentPassword, body.NewPassword); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "Admin password changed", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
|
||||||
|
release := r.releases.Manifest(req)
|
||||||
|
sourceCatalog, _ := r.sources.Catalog(false)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"serviceVersion": config.Version,
|
||||||
|
"baseUrl": requestBaseURL(req, r.cfg.BaseURL),
|
||||||
|
"capabilities": map[string]bool{
|
||||||
|
"dynamicSources": true,
|
||||||
|
"sourceHealth": true,
|
||||||
|
"feedbackStatus": true,
|
||||||
|
"releaseManifest": true,
|
||||||
|
"endpointCalls": true,
|
||||||
|
"legacyJson": true,
|
||||||
|
},
|
||||||
|
"endpoints": map[string]string{
|
||||||
|
"releases": "/api/client/releases",
|
||||||
|
"sources": "/api/client/sources",
|
||||||
|
"clientEndpoints": "/api/client/endpoints",
|
||||||
|
"endpointCalls": "/api/client/endpoint-calls",
|
||||||
|
"notices": "/api/client/notices",
|
||||||
|
"feedback": "/",
|
||||||
|
},
|
||||||
|
"cache": map[string]int{
|
||||||
|
"bootstrapSeconds": 300,
|
||||||
|
"releasesSeconds": 300,
|
||||||
|
"sourcesSeconds": 600,
|
||||||
|
"healthSeconds": 300,
|
||||||
|
},
|
||||||
|
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
|
||||||
|
"release": release,
|
||||||
|
"sources": sourceCatalog,
|
||||||
|
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
|
||||||
|
"health": health.Snapshot(r.cfg, r.store),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) {
|
||||||
|
catalog, err := r.sources.Catalog(false)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleClientEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||||
|
items, err := r.sources.Endpoints(false)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleClientNotices(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.notices == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
if path == "/api/client/notices" {
|
||||||
|
items, err := r.notices.List(100)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "NOTICES_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": notices.PublicList(items)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimPrefix(path, "/api/client/notices/")
|
||||||
|
if version == "" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.notices.Get(version)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "notice": notices.PublicNotice(doc.Notice), "raw": doc.Parsed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleLegacyMediaTypes(w http.ResponseWriter, req *http.Request) {
|
||||||
|
catalog, err := r.sources.Catalog(false)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "MEDIA_TYPES_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleSourceCall(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body db.SourceCall
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Client = firstNonEmpty(body.Client, req.UserAgent())
|
||||||
|
if err := r.store.RecordSourceCall(body); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SOURCE_CALL_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request) {
|
||||||
|
item, err := r.feedback.Submit(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "FEEDBACK_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "code": item.Code})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
|
||||||
|
code := strings.TrimSpace(req.URL.Query().Get("code"))
|
||||||
|
if code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_CODE", errors.New("code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := r.store.GetFeedback(code)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
if req.Method == http.MethodGet && path == "/api/admin/feedbacks" {
|
||||||
|
if req.URL.Query().Get("page") != "" {
|
||||||
|
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
|
||||||
|
perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage"))
|
||||||
|
items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{
|
||||||
|
Status: req.URL.Query().Get("status"),
|
||||||
|
Category: req.URL.Query().Get("category"),
|
||||||
|
Priority: req.URL.Query().Get("priority"),
|
||||||
|
Query: req.URL.Query().Get("q"),
|
||||||
|
Assignee: req.URL.Query().Get("assignee"),
|
||||||
|
Sort: req.URL.Query().Get("sort"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "page": map[string]any{"items": items, "total": total, "page": page, "perPage": perPage}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(req.URL.Query().Get("limit"))
|
||||||
|
items, err := r.store.ListFeedbacks(limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodGet && path == "/api/admin/feedbacks/export" {
|
||||||
|
items, _, err := r.store.ListFeedbacksFiltered(1, 100, db.FeedbackFilters{
|
||||||
|
Status: req.URL.Query().Get("status"),
|
||||||
|
Category: req.URL.Query().Get("category"),
|
||||||
|
Priority: req.URL.Query().Get("priority"),
|
||||||
|
Query: req.URL.Query().Get("q"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "EXPORT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="feedbacks.csv"`)
|
||||||
|
writer := csv.NewWriter(w)
|
||||||
|
_ = writer.Write([]string{"code", "created_at", "title", "status", "category", "priority", "contact", "status_detail", "public_reply"})
|
||||||
|
for _, item := range items {
|
||||||
|
_ = writer.Write([]string{item.Code, item.CreatedAt, item.Title, item.Status, item.Category, item.Priority, item.Contact, item.StatusDetail, item.PublicReply})
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||||
|
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||||
|
detail, err := r.store.GetFeedbackDetail(code)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": detail})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPatch && path == "/api/admin/feedbacks/bulk" {
|
||||||
|
var body struct {
|
||||||
|
Codes []string `json:"codes"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusDetail string `json:"statusDetail"`
|
||||||
|
PublicReply string `json:"publicReply"`
|
||||||
|
Assignee string `json:"assignee"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || len(body.Codes) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("codes are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.store.BulkUpdateFeedback(body.Codes, db.FeedbackUpdate{Status: body.Status, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Assignee: body.Assignee, Actor: "admin", Tags: body.Tags}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "BULK_UPDATE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": len(body.Codes)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/comments") {
|
||||||
|
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/comments")
|
||||||
|
var body struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Internal bool `json:"internal"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment, err := r.store.InsertFeedbackComment(db.FeedbackComment{Code: code, Author: firstNonEmpty(body.Author, "admin"), Body: body.Body, Internal: body.Internal})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "COMMENT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||||
|
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusDetail string `json:"statusDetail"`
|
||||||
|
PublicReply string `json:"publicReply"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
name := ""
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "/api/admin/legacy/update-info"):
|
||||||
|
name = "update-info"
|
||||||
|
case strings.HasPrefix(path, "/api/admin/legacy/media-types"):
|
||||||
|
name = "media-types"
|
||||||
|
default:
|
||||||
|
parts := strings.Split(strings.TrimPrefix(path, "/api/admin/legacy/"), "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
name = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodGet && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||||
|
doc, err := r.legacy.Get(req.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "LEGACY_GET_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPut && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||||
|
var body legacy.SaveRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.legacy.Save(req.Context(), name, body, "admin")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "LEGACY_SAVE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name == "media-types" {
|
||||||
|
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && strings.HasSuffix(path, "/validate") {
|
||||||
|
var body legacy.SaveRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.legacy.Validate(req.Context(), name, body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "LEGACY_VALIDATE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && strings.HasSuffix(path, "/restore") {
|
||||||
|
var body struct {
|
||||||
|
RevisionID int64 `json:"revisionId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.legacy.Restore(req.Context(), name, body.RevisionID, "admin")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "LEGACY_RESTORE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name == "media-types" {
|
||||||
|
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch {
|
||||||
|
case req.Method == http.MethodGet && path == "/api/admin/database/status":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/database/test":
|
||||||
|
var body config.DatabaseConfig
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Provider == "" {
|
||||||
|
body.Provider = r.cfg.Database.Provider
|
||||||
|
}
|
||||||
|
if body.SQLitePath == "" {
|
||||||
|
body.SQLitePath = r.cfg.Database.SQLitePath
|
||||||
|
}
|
||||||
|
if body.MySQLDSN == "" {
|
||||||
|
body.MySQLDSN = r.cfg.Database.MySQLDSN
|
||||||
|
}
|
||||||
|
if err := db.TestDatabase(body); err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||||
|
result, err := r.store.ImportSQLiteToRemote()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "DATABASE_IMPORT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/database/sync":
|
||||||
|
result, err := r.store.SyncNow()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "DATABASE_SYNC_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overview, err := r.store.DashboardOverview(80)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overview["health"] = health.Snapshot(r.cfg, r.store)
|
||||||
|
writeJSON(w, http.StatusOK, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.syncer == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch {
|
||||||
|
case req.Method == http.MethodGet && path == "/api/admin/sync/legacy/preview":
|
||||||
|
writeJSON(w, http.StatusOK, r.syncer.Preview(req.Context()))
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/sync/legacy/run":
|
||||||
|
writeJSON(w, http.StatusOK, r.syncer.Run(req.Context()))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := r.sources.Endpoints(true)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
if strings.HasPrefix(path, "/api/admin/releases/notices") {
|
||||||
|
r.handleAdminReleaseNotices(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch path {
|
||||||
|
case "/api/admin/releases":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
||||||
|
case "/api/admin/releases/legacy-preview":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updateInfo": r.releases.LegacyUpdateInfo(req), "toolStatus": r.releases.StaticJSON("tool-status.json")})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.notices == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
if req.Method == http.MethodPost && path == "/api/admin/releases/notices/import" {
|
||||||
|
if err := r.notices.Import(req.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "NOTICE_IMPORT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, _ := r.notices.List(100)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodGet && path == "/api/admin/releases/notices" {
|
||||||
|
items, err := r.notices.List(100)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "NOTICE_LIST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, "/api/admin/releases/notices/")
|
||||||
|
if rest == "" || rest == path {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
version := parts[0]
|
||||||
|
if req.Method == http.MethodGet && len(parts) == 1 {
|
||||||
|
doc, err := r.notices.Get(version)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPut && len(parts) == 1 {
|
||||||
|
var body notices.SaveRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.notices.Save(req.Context(), version, body, "admin")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "NOTICE_SAVE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "validate" {
|
||||||
|
var body notices.SaveRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.notices.Validate(req.Context(), version, body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "NOTICE_VALIDATE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restore" {
|
||||||
|
var body struct {
|
||||||
|
RevisionID int64 `json:"revisionId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := r.notices.Restore(req.Context(), version, body.RevisionID, "admin")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "NOTICE_RESTORE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch {
|
||||||
|
case req.Method == http.MethodGet && path == "/api/admin/sources":
|
||||||
|
catalog, err := r.sources.Catalog(true)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "catalog": catalog})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/sources/import-media-types":
|
||||||
|
if err := r.sources.ImportLegacyMediaTypes(req.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
||||||
|
go r.sources.CheckDue(req.Context())
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true})
|
||||||
|
case req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/sources/") && strings.HasSuffix(path, "/check"):
|
||||||
|
sourceID := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/sources/"), "/check")
|
||||||
|
item, err := r.sources.CheckSourceID(req.Context(), sourceID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "CHECK_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
||||||
|
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
||||||
|
var item db.Source
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := r.store.UpsertSource(item)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
|
||||||
|
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
||||||
|
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
|
||||||
|
if err := r.store.DeleteSource(sourceID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch path {
|
||||||
|
case "/api/admin/system/health":
|
||||||
|
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
||||||
|
case "/api/admin/system/audit":
|
||||||
|
items, err := r.store.ListAuditLogs(100)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||||
|
case "/api/admin/system/database/sync":
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finishedAt, err := r.store.CopySQLiteToRemote()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "finishedAt": finishedAt})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) handleDownload(w http.ResponseWriter, req *http.Request) {
|
||||||
|
name := strings.TrimPrefix(cleanPath(req.URL.Path), "/downloads/")
|
||||||
|
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid filename"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(r.cfg.DownloadsDir, name)
|
||||||
|
resolved, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, _ := filepath.Abs(r.cfg.DownloadsDir)
|
||||||
|
if !strings.HasPrefix(resolved, base) {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, req, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot, assetPath string) {
|
||||||
|
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tryServeDiskFile(w, req, root, assetPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||||
|
resolved, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
base, _ := filepath.Abs(root)
|
||||||
|
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
info, err := os.Stat(resolved)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.ServeFile(w, req, resolved)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveEmbeddedFile(w http.ResponseWriter, req *http.Request, name string) bool {
|
||||||
|
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid embedded asset path"))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
data, err := webassets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
|
||||||
|
index := filepath.Join(r.cfg.PortalWebDir, "index.html")
|
||||||
|
if _, err := os.Stat(index); err == nil {
|
||||||
|
http.ServeFile(w, req, index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serveEmbeddedFile(w, req, "portal/dist/index.html") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
||||||
|
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
||||||
|
if _, err := os.Stat(index); err == nil {
|
||||||
|
http.ServeFile(w, req, index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPortalRoute(path string) bool {
|
||||||
|
switch path {
|
||||||
|
case "/", "/releases", "/sources", "/feedback", "/compatibility":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSecurity(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code string, err error) {
|
||||||
|
message := ""
|
||||||
|
if err != nil {
|
||||||
|
message = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if path != "/" {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(r *http.Request, fallback string) string {
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
} else {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Host != "" {
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
return strings.TrimRight(fallback, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/auth"
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
"ymhut-box/server/unified-management/internal/feedback"
|
||||||
|
"ymhut-box/server/unified-management/internal/legacy"
|
||||||
|
"ymhut-box/server/unified-management/internal/notices"
|
||||||
|
"ymhut-box/server/unified-management/internal/releases"
|
||||||
|
"ymhut-box/server/unified-management/internal/sources"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompatibilityRoutes(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/media-types.json", "/modules.json"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("%s did not return JSON: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/client/bootstrap", "/api/client/endpoints", "/api/client/sources", "/api/client/notices"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path == "/api/client/sources" {
|
||||||
|
if payload["categories"] == nil {
|
||||||
|
t.Fatalf("%s missing categories: %#v", path, payload)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Fatalf("%s missing ok=true: %#v", path, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, item := range []struct {
|
||||||
|
Path string
|
||||||
|
ContentTypes []string
|
||||||
|
}{
|
||||||
|
{Path: "/assets/portal.css", ContentTypes: []string{"text/css"}},
|
||||||
|
{Path: "/assets/portal.js", ContentTypes: []string{"text/javascript", "application/javascript"}},
|
||||||
|
{Path: "/admin/assets/admin.css", ContentTypes: []string{"text/css"}},
|
||||||
|
{Path: "/admin/assets/admin.js", ContentTypes: []string{"text/javascript", "application/javascript"}},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, item.Path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) {
|
||||||
|
t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(value string, needles []string) bool {
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(value, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseNoticesRoutes(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/client/notices", "/api/client/notices/2.0.0"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/client/releases", nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["notices"] == nil {
|
||||||
|
t.Fatalf("release manifest missing notices: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminLegacyRequiresAuth(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/media-types", bytes.NewBufferString(`{"raw":"{}"}`))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected unauthorized, got %d", res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRouter(t *testing.T) (http.Handler, func()) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
public := filepath.Join(root, "public")
|
||||||
|
noticeDir := filepath.Join(root, "update-notice")
|
||||||
|
if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adminDist := filepath.Join(root, "admin")
|
||||||
|
portalDist := filepath.Join(root, "portal")
|
||||||
|
for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(portalDist, "index.html"), []byte(`<!doctype html><link rel="stylesheet" href="/assets/portal.css"><script type="module" src="/assets/portal.js"></script>`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.css"), []byte(`body{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.js"), []byte(`console.log("portal")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(adminDist, "index.html"), []byte(`<!doctype html><link rel="stylesheet" href="/admin/assets/admin.css"><script type="module" src="/admin/assets/admin.js"></script>`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.css"), []byte(`body{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.js"), []byte(`console.log("admin")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustWriteJSON(t, filepath.Join(public, "update-info.json"), map[string]any{"app_version": "0.0.1"})
|
||||||
|
mustWriteJSON(t, filepath.Join(public, "tool-status.json"), map[string]any{"ok": true})
|
||||||
|
mustWriteJSON(t, filepath.Join(public, "modules.json"), map[string]any{"modules": []any{}})
|
||||||
|
mustWriteJSON(t, filepath.Join(public, "media-types.json"), map[string]any{
|
||||||
|
"categories": []map[string]any{{
|
||||||
|
"id": "image", "name": "image",
|
||||||
|
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
|
||||||
|
"schema_version": 1,
|
||||||
|
"latest_version": "2.0.0",
|
||||||
|
"latest_notice_file": "2.0.0.json",
|
||||||
|
"latest": map[string]any{"version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release"},
|
||||||
|
"versions": []map[string]any{{"version": "2.0.0", "notice_file": "2.0.0.json", "summary": "Initial release"}},
|
||||||
|
})
|
||||||
|
mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"})
|
||||||
|
cfg := &config.Config{
|
||||||
|
Listen: ":0",
|
||||||
|
BaseURL: "https://update.ymhut.cn",
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
UpdatePublicDir: public,
|
||||||
|
UpdateNoticeDir: noticeDir,
|
||||||
|
DownloadsDir: filepath.Join(public, "downloads"),
|
||||||
|
AdminWebDir: adminDist,
|
||||||
|
PortalWebDir: portalDist,
|
||||||
|
SourceCheckSeconds: 3600,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HotSyncEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store, err := db.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sourceService := sources.NewService(cfg, store)
|
||||||
|
if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
noticeService := notices.NewService(cfg, store)
|
||||||
|
if err := noticeService.Import(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler := NewRouter(
|
||||||
|
cfg,
|
||||||
|
store,
|
||||||
|
auth.NewService(store),
|
||||||
|
feedback.NewService(cfg, store),
|
||||||
|
releases.NewService(cfg, store, noticeService),
|
||||||
|
sourceService,
|
||||||
|
legacy.NewService(cfg, store),
|
||||||
|
noticeService,
|
||||||
|
)
|
||||||
|
return handler, func() { _ = store.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWriteJSON(t *testing.T, path string, payload any) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type setupRouter struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type setupRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
SQLitePath string `json:"sqlitePath"`
|
||||||
|
MySQLDSN string `json:"mysqlDsn"`
|
||||||
|
MySQL setupMySQLConfig `json:"mysql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setupMySQLConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Charset string `json:"charset"`
|
||||||
|
ParseTime bool `json:"parseTime"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSetupRouter(cfg *config.Config) http.Handler {
|
||||||
|
return withSecurity(&setupRouter{cfg: cfg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
path := cleanPath(req.URL.Path)
|
||||||
|
switch {
|
||||||
|
case path == "/" || path == "/setup":
|
||||||
|
r.serveSetup(w, req)
|
||||||
|
case strings.HasPrefix(path, "/setup/assets/"):
|
||||||
|
serveStaticAsset(w, req, r.cfg.SetupWebDir, "setup/dist", strings.TrimPrefix(path, "/setup/"))
|
||||||
|
case path == "/api/setup/status":
|
||||||
|
writeJSON(w, http.StatusOK, r.status())
|
||||||
|
case path == "/api/setup/database/test":
|
||||||
|
r.handleDatabaseTest(w, req)
|
||||||
|
case path == "/api/setup/complete":
|
||||||
|
r.handleComplete(w, req)
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "SETUP_REQUIRED", errors.New("system setup is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, req, "/setup", http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) status() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"initialized": r.cfg.Initialized,
|
||||||
|
"baseDir": r.cfg.BaseDir,
|
||||||
|
"configPath": r.cfg.ConfigPath,
|
||||||
|
"defaults": map[string]any{
|
||||||
|
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
||||||
|
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
|
||||||
|
"mysqlDsn": maskDSN(r.cfg.Database.MySQLDSN),
|
||||||
|
"baseUrl": r.cfg.BaseURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next, body, err := r.decodeSetupDatabase(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
if err := db.TestDatabase(next); err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"provider": next.Provider,
|
||||||
|
"latencyMs": time.Since(started).Milliseconds(),
|
||||||
|
"maskedDsn": maskedDatabaseTarget(r.cfg.BaseDir, next),
|
||||||
|
"normalized": map[string]any{
|
||||||
|
"provider": next.Provider,
|
||||||
|
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
|
||||||
|
"sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath),
|
||||||
|
"mysqlDsn": maskDSN(next.MySQLDSN),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) handleComplete(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextDB, body, err := r.decodeSetupDatabase(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.TestDatabase(nextDB); err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := *r.cfg
|
||||||
|
next.Initialized = true
|
||||||
|
next.BaseURL = firstNonEmpty(strings.TrimSpace(body.BaseURL), next.BaseURL)
|
||||||
|
next.Database = nextDB
|
||||||
|
if strings.EqualFold(next.Database.Provider, "mysql") {
|
||||||
|
next.Database.FailoverEnabled = true
|
||||||
|
next.Database.HotSyncEnabled = true
|
||||||
|
}
|
||||||
|
store, err := db.Open(&next)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "DATABASE_OPEN_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.EnsureDefaultAdmin(req.Context()); err != nil {
|
||||||
|
_ = store.Close()
|
||||||
|
writeError(w, http.StatusInternalServerError, "ADMIN_INIT_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = store.Close()
|
||||||
|
if err := config.Save(&next); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "CONFIG_SAVE_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "initialized": true, "message": "Setup completed. Restart the service, then open /admin/login."})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseConfig, setupRequest, error) {
|
||||||
|
var body setupRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
return config.DatabaseConfig{}, body, err
|
||||||
|
}
|
||||||
|
next := r.cfg.Database
|
||||||
|
next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite")))
|
||||||
|
if body.SQLitePath != "" {
|
||||||
|
next.SQLitePath = body.SQLitePath
|
||||||
|
}
|
||||||
|
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
|
||||||
|
next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath)
|
||||||
|
}
|
||||||
|
if next.Provider == "sqlite" {
|
||||||
|
next.MySQLDSN = ""
|
||||||
|
} else if body.MySQLDSN != "" {
|
||||||
|
next.MySQLDSN = body.MySQLDSN
|
||||||
|
} else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" {
|
||||||
|
dsn, err := buildMySQLDSN(body.MySQL)
|
||||||
|
if err != nil {
|
||||||
|
return config.DatabaseConfig{}, body, err
|
||||||
|
}
|
||||||
|
next.MySQLDSN = dsn
|
||||||
|
}
|
||||||
|
if next.Provider != "sqlite" && next.Provider != "mysql" {
|
||||||
|
return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql")
|
||||||
|
}
|
||||||
|
if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" {
|
||||||
|
return config.DatabaseConfig{}, body, errors.New("mysql connection is required")
|
||||||
|
}
|
||||||
|
if next.MaxOpenConns <= 0 {
|
||||||
|
next.MaxOpenConns = 10
|
||||||
|
}
|
||||||
|
if next.MaxIdleConns <= 0 {
|
||||||
|
next.MaxIdleConns = 4
|
||||||
|
}
|
||||||
|
if next.ConnMaxLifetimeSeconds <= 0 {
|
||||||
|
next.ConnMaxLifetimeSeconds = 300
|
||||||
|
}
|
||||||
|
if next.HealthIntervalSec <= 0 {
|
||||||
|
next.HealthIntervalSec = 30
|
||||||
|
}
|
||||||
|
return next, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
|
||||||
|
index := filepath.Join(r.cfg.SetupWebDir, "index.html")
|
||||||
|
if tryServeDiskFile(w, req, r.cfg.SetupWebDir, "index.html") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serveEmbeddedFile(w, req, "setup/dist/index.html") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMySQLDSN(input setupMySQLConfig) (string, error) {
|
||||||
|
host := strings.TrimSpace(input.Host)
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := input.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 3306
|
||||||
|
}
|
||||||
|
database := strings.TrimSpace(input.Database)
|
||||||
|
username := strings.TrimSpace(input.Username)
|
||||||
|
if database == "" {
|
||||||
|
return "", errors.New("mysql database is required")
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
return "", errors.New("mysql username is required")
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
|
||||||
|
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
|
||||||
|
if tls := strings.TrimSpace(input.TLS); tls != "" {
|
||||||
|
params.Set("tls", tls)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskDSN(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
at := strings.Index(value, "@")
|
||||||
|
colon := strings.Index(value, ":")
|
||||||
|
if at > -1 && colon > -1 && colon < at {
|
||||||
|
return value[:colon+1] + "******" + value[at:]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
|
||||||
|
if strings.EqualFold(cfg.Provider, "mysql") {
|
||||||
|
return maskDSN(cfg.MySQLDSN)
|
||||||
|
}
|
||||||
|
return relativeToBase(base, cfg.SQLitePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeToBase(base, value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if base != "" {
|
||||||
|
if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupRouterServesBuiltAssetsAndBlocksBusinessAPI(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
setupDist := filepath.Join(root, "setup")
|
||||||
|
if err := os.MkdirAll(filepath.Join(setupDist, "assets"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(setupDist, "index.html"), []byte(`<!doctype html><script type="module" src="/setup/assets/setup.js"></script><link rel="stylesheet" href="/setup/assets/setup.css">`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(setupDist, "assets", "setup.css"), []byte(`body{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(setupDist, "assets", "setup.js"), []byte(`console.log("setup")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler := NewSetupRouter(setupConfig(root, setupDist))
|
||||||
|
|
||||||
|
for _, item := range []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
typ string
|
||||||
|
}{
|
||||||
|
{"/setup", http.StatusOK, "text/html"},
|
||||||
|
{"/setup/assets/setup.css", http.StatusOK, "text/css"},
|
||||||
|
{"/setup/assets/setup.js", http.StatusOK, "javascript"},
|
||||||
|
{"/api/client/bootstrap", http.StatusServiceUnavailable, "application/json"},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, item.path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != item.want {
|
||||||
|
t.Fatalf("%s returned %d: %s", item.path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if got := res.Header().Get("Content-Type"); !strings.Contains(got, item.typ) {
|
||||||
|
t.Fatalf("%s content-type = %q, want %q", item.path, got, item.typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupSQLiteCompleteCreatesConfigAndDefaultAdmin(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"provider":"sqlite","baseUrl":"https://update.ymhut.cn","sqlitePath":"storage/unified.sqlite"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/setup/complete", body)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete returned %d: %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg config.Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.Initialized || cfg.Database.Provider != "sqlite" {
|
||||||
|
t.Fatalf("unexpected config: %#v", cfg)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "storage", "unified.sqlite")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupSQLiteIgnoresStructuredMySQLDefaults(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"provider":"sqlite","baseUrl":"https://update.ymhut.cn","sqlitePath":"storage/unified.sqlite","mysql":{"host":"127.0.0.1","port":3306,"database":"ymhut_unified","username":"","password":"","charset":"utf8mb4","parseTime":true,"tls":"false"}}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/setup/database/test", body)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sqlite test returned %d: %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(res.Body.String(), "mysql username is required") {
|
||||||
|
t.Fatalf("sqlite test should ignore structured mysql defaults: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupStructuredMySQLValidationReturnsFailureWithoutSaving(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"provider":"mysql","mysql":{"host":"127.0.0.1","port":1,"database":"ymhut","username":"root","password":"secret","charset":"utf8mb4","parseTime":true,"tls":"false"}}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/setup/database/test", body)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("mysql test returned %d, want 502: %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "config.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("config should not be written on failed test: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(res.Body.String(), "secret") {
|
||||||
|
t.Fatalf("response leaked password: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupConfig(root, setupDist string) *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
BaseDir: root,
|
||||||
|
ConfigPath: filepath.Join(root, "config.json"),
|
||||||
|
BaseURL: "https://update.ymhut.cn",
|
||||||
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
SetupWebDir: setupDist,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
HealthIntervalSec: 30,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ymhut-box/server/unified-management/cmd/unified-management/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
param(
|
||||||
|
[string]$Version = "dev",
|
||||||
|
[string]$OutDir = "dist-release",
|
||||||
|
[switch]$SkipFrontend
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||||
|
$Out = Join-Path $Root $OutDir
|
||||||
|
|
||||||
|
function Run-Step {
|
||||||
|
param(
|
||||||
|
[string]$WorkingDirectory,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
Push-Location $WorkingDirectory
|
||||||
|
try {
|
||||||
|
& $FilePath @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipFrontend) {
|
||||||
|
foreach ($App in @("admin", "portal", "setup")) {
|
||||||
|
$WebDir = Join-Path $Root "web\$App"
|
||||||
|
if (-not (Test-Path (Join-Path $WebDir "node_modules"))) {
|
||||||
|
Run-Step $WebDir "npm" @("install")
|
||||||
|
}
|
||||||
|
Run-Step $WebDir "npm" @("run", "build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||||
|
|
||||||
|
$Targets = @(
|
||||||
|
@{ GOOS = "windows"; GOARCH = "amd64"; Name = "ymhut-unified-management-windows-amd64.exe" },
|
||||||
|
@{ GOOS = "linux"; GOARCH = "amd64"; Name = "ymhut-unified-management-linux-amd64" },
|
||||||
|
@{ GOOS = "linux"; GOARCH = "arm64"; Name = "ymhut-unified-management-linux-arm64" }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($Target in $Targets) {
|
||||||
|
$env:GOOS = $Target.GOOS
|
||||||
|
$env:GOARCH = $Target.GOARCH
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
$Output = Join-Path $Out $Target.Name
|
||||||
|
Run-Step $Root "go" @("build", "-tags", "embed_web", "-buildvcs=false", "-trimpath", "-ldflags", "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$Version", "-o", $Output, ".\cmd\unified-management")
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "Built release binaries in $Out"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:-dev}"
|
||||||
|
OUT_DIR="${OUT_DIR:-dist-release}"
|
||||||
|
SKIP_FRONTEND="${SKIP_FRONTEND:-0}"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT="$ROOT/$OUT_DIR"
|
||||||
|
|
||||||
|
if [[ "$SKIP_FRONTEND" != "1" ]]; then
|
||||||
|
for app in admin portal; do
|
||||||
|
web_dir="$ROOT/web/$app"
|
||||||
|
if [[ ! -d "$web_dir/node_modules" ]]; then
|
||||||
|
(cd "$web_dir" && npm install)
|
||||||
|
fi
|
||||||
|
(cd "$web_dir" && npm run build)
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
build_target() {
|
||||||
|
local goos="$1"
|
||||||
|
local goarch="$2"
|
||||||
|
local name="$3"
|
||||||
|
(cd "$ROOT" && \
|
||||||
|
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
|
||||||
|
go build -tags embed_web -buildvcs=false -trimpath -ldflags "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$VERSION" \
|
||||||
|
-o "$OUT/$name" ./cmd/unified-management)
|
||||||
|
}
|
||||||
|
|
||||||
|
build_target windows amd64 ymhut-unified-management-windows-amd64.exe
|
||||||
|
build_target linux amd64 ymhut-unified-management-linux-amd64
|
||||||
|
build_target linux arm64 ymhut-unified-management-linux-arm64
|
||||||
|
|
||||||
|
echo "Built release binaries in $OUT"
|
||||||
@@ -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 Unified Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1387
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "ymhut-unified-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"lucide-vue-next": "^0.468.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue": "^3.5.16",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Code2,
|
||||||
|
Database,
|
||||||
|
FileJson,
|
||||||
|
HeartPulse,
|
||||||
|
LayoutDashboard,
|
||||||
|
ListChecks,
|
||||||
|
LogOut,
|
||||||
|
MessageSquareText,
|
||||||
|
Network,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import AuditView from "./views/AuditView.vue";
|
||||||
|
import DashboardView from "./views/DashboardView.vue";
|
||||||
|
import DatabaseView from "./views/DatabaseView.vue";
|
||||||
|
import EndpointsView from "./views/EndpointsView.vue";
|
||||||
|
import FeedbacksView from "./views/FeedbacksView.vue";
|
||||||
|
import HealthView from "./views/HealthView.vue";
|
||||||
|
import LegacyJsonView from "./views/LegacyJsonView.vue";
|
||||||
|
import ReleasesView from "./views/ReleasesView.vue";
|
||||||
|
import SettingsView from "./views/SettingsView.vue";
|
||||||
|
import SourcesView from "./views/SourcesView.vue";
|
||||||
|
|
||||||
|
type LegacyName = "update-info" | "media-types";
|
||||||
|
|
||||||
|
type Captcha = {
|
||||||
|
captchaId: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthBootstrap = {
|
||||||
|
isDefaultPassword: boolean;
|
||||||
|
defaultUsername: string;
|
||||||
|
defaultPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteItem = {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||||
|
const loading = ref(false);
|
||||||
|
const toast = ref("");
|
||||||
|
const autoRefreshPaused = ref(false);
|
||||||
|
let refreshTimer: number | undefined;
|
||||||
|
|
||||||
|
const captcha = ref<Captcha | null>(null);
|
||||||
|
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||||
|
const dashboard = ref<any>({});
|
||||||
|
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||||
|
const selectedFeedback = ref<any | null>(null);
|
||||||
|
const releases = ref<any>(null);
|
||||||
|
const releaseNotices = ref<any[]>([]);
|
||||||
|
const selectedNotice = ref<any | null>(null);
|
||||||
|
const sources = ref<any>({ categories: [] });
|
||||||
|
const endpoints = ref<any[]>([]);
|
||||||
|
const database = ref<any>(null);
|
||||||
|
const healthSnapshot = ref<any>(null);
|
||||||
|
const auditLogs = ref<any[]>([]);
|
||||||
|
const legacySync = ref<any>(null);
|
||||||
|
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||||
|
|
||||||
|
const loginForm = reactive({ username: "admin", password: "", captcha: "" });
|
||||||
|
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||||
|
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
||||||
|
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" });
|
||||||
|
const commentDraft = reactive({ body: "", internal: true });
|
||||||
|
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
|
||||||
|
const sourceDraft = reactive({
|
||||||
|
sourceId: "",
|
||||||
|
categoryId: "custom",
|
||||||
|
categoryName: "自定义接口",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
method: "GET",
|
||||||
|
apiUrl: "",
|
||||||
|
urlTemplate: "",
|
||||||
|
thumbnailUrl: "",
|
||||||
|
proxyMode: "client_direct",
|
||||||
|
timeoutMs: 8000,
|
||||||
|
retryCount: 1,
|
||||||
|
cacheSeconds: 300,
|
||||||
|
checkIntervalSec: 300,
|
||||||
|
enabled: true,
|
||||||
|
clientVisible: true,
|
||||||
|
supportedFormats: "[\"json\"]",
|
||||||
|
});
|
||||||
|
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
|
||||||
|
"update-info": { raw: "", note: "", preview: null },
|
||||||
|
"media-types": { raw: "", note: "", preview: null },
|
||||||
|
});
|
||||||
|
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||||
|
|
||||||
|
const routes: RouteItem[] = [
|
||||||
|
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||||
|
{ path: "/admin/feedbacks", label: "反馈工单", description: "旧客户端反馈与处理流转", icon: MessageSquareText },
|
||||||
|
{ path: "/admin/releases", label: "发布与日志", description: "发布包、版本公告和兼容日志", icon: ArrowDownToLine },
|
||||||
|
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "可视化维护 update-info.json", icon: FileJson },
|
||||||
|
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
|
||||||
|
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
||||||
|
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
||||||
|
{ path: "/admin/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database },
|
||||||
|
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
|
||||||
|
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
|
||||||
|
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
|
||||||
|
];
|
||||||
|
|
||||||
|
const navGroups = [
|
||||||
|
{ label: "概览", items: routes.filter((item) => ["/admin/dashboard"].includes(item.path)) },
|
||||||
|
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
|
||||||
|
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
|
||||||
|
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
|
||||||
|
{ label: "系统运维", items: routes.filter((item) => ["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
|
||||||
|
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", icon: ArrowDownToLine },
|
||||||
|
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "编辑旧版 update-info.json", icon: FileJson },
|
||||||
|
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "同步旧客户端媒体源结构", icon: ClipboardList },
|
||||||
|
{ path: "/admin/sources", label: "接口源目录", description: "新增接口并执行健康检测", icon: Network },
|
||||||
|
{ path: "/admin/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
|
||||||
|
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||||
|
const activeLegacyName = computed<LegacyName | null>(() => {
|
||||||
|
if (currentPath.value.endsWith("/update-info")) return "update-info";
|
||||||
|
if (currentPath.value.endsWith("/media-types")) return "media-types";
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const kpis = computed(() => dashboard.value?.kpis || {});
|
||||||
|
const sourceHealth = computed(() => dashboard.value?.sourceHealth || {});
|
||||||
|
const feedbackStatus = computed(() => dashboard.value?.feedbackStatus || {});
|
||||||
|
const heartbeats = computed(() => dashboard.value?.heartbeats || []);
|
||||||
|
const clientCalls = computed(() => dashboard.value?.clientCalls || []);
|
||||||
|
const releasePackages = computed(() => releases.value?.packages || []);
|
||||||
|
const sourceCategories = computed(() => sources.value?.categories || []);
|
||||||
|
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
|
||||||
|
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => endpointStatus(item) === "ok").length);
|
||||||
|
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||||
|
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
||||||
|
|
||||||
|
const heartbeatOption = computed(() => ({
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
grid: { left: 44, right: 18, top: 28, bottom: 34 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)),
|
||||||
|
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||||
|
},
|
||||||
|
yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "接口延迟",
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
areaStyle: { opacity: 0.18 },
|
||||||
|
data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0),
|
||||||
|
color: "#2563eb",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const healthOption = computed(() => ({
|
||||||
|
tooltip: { trigger: "item" },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "接口健康",
|
||||||
|
type: "pie",
|
||||||
|
radius: ["48%", "72%"],
|
||||||
|
data: objectEntries(sourceHealth.value),
|
||||||
|
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const feedbackOption = computed(() => ({
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
grid: { left: 34, right: 12, top: 20, bottom: 28 },
|
||||||
|
xAxis: { type: "category", data: objectEntries(feedbackStatus.value).map((item) => item.name) },
|
||||||
|
yAxis: { type: "value", splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||||
|
series: [{ name: "工单", type: "bar", data: objectEntries(feedbackStatus.value).map((item) => item.value), color: "#0f766e" }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const availabilityOption = computed(() => {
|
||||||
|
const total = Number(kpis.value.sourceTotal || 0);
|
||||||
|
const ok = Number(sourceHealth.value.ok || 0);
|
||||||
|
const value = total ? Math.round((ok / total) * 100) : 0;
|
||||||
|
return {
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "gauge",
|
||||||
|
progress: { show: true, width: 12 },
|
||||||
|
axisLine: { lineStyle: { width: 12 } },
|
||||||
|
axisLabel: { distance: 16 },
|
||||||
|
pointer: { width: 4 },
|
||||||
|
detail: { formatter: "{value}%", fontSize: 24 },
|
||||||
|
data: [{ value, name: "可用率" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewContext = computed(() => ({
|
||||||
|
activeLegacyLabel: activeLegacyLabel.value,
|
||||||
|
activeLegacyName: activeLegacyName.value,
|
||||||
|
addFeedbackComment,
|
||||||
|
auditLogs: auditLogs.value,
|
||||||
|
autoRefreshPaused: autoRefreshPaused.value,
|
||||||
|
availabilityOption: availabilityOption.value,
|
||||||
|
changePassword,
|
||||||
|
checkSources,
|
||||||
|
clientCalls: clientCalls.value,
|
||||||
|
commentDraft,
|
||||||
|
copyEndpointToSource,
|
||||||
|
database: database.value,
|
||||||
|
databaseForm,
|
||||||
|
endpointStatus,
|
||||||
|
endpoints: endpoints.value,
|
||||||
|
feedbackFilters,
|
||||||
|
feedbackOption: feedbackOption.value,
|
||||||
|
feedbackPage: feedbackPage.value,
|
||||||
|
feedbackUpdate,
|
||||||
|
formatBytes,
|
||||||
|
healthOption: healthOption.value,
|
||||||
|
healthSnapshot: healthSnapshot.value,
|
||||||
|
healthyEndpointCount: healthyEndpointCount.value,
|
||||||
|
heartbeatOption: heartbeatOption.value,
|
||||||
|
heartbeats: heartbeats.value,
|
||||||
|
importNotices,
|
||||||
|
kpis: kpis.value,
|
||||||
|
labelStatus,
|
||||||
|
latestNotice: latestNotice.value,
|
||||||
|
legacyDocuments,
|
||||||
|
legacyDrafts,
|
||||||
|
legacySync: legacySync.value,
|
||||||
|
loadAudit,
|
||||||
|
loadFeedbacks,
|
||||||
|
navigate,
|
||||||
|
noticeDraft,
|
||||||
|
openFeedback,
|
||||||
|
openNotice,
|
||||||
|
passwordForm,
|
||||||
|
pretty,
|
||||||
|
previewLegacySync,
|
||||||
|
quickActions,
|
||||||
|
releaseNotices: releaseNotices.value,
|
||||||
|
releasePackages: releasePackages.value,
|
||||||
|
releases: releases.value,
|
||||||
|
restoreLegacy,
|
||||||
|
restoreNotice,
|
||||||
|
runLegacySync,
|
||||||
|
saveFeedbackUpdate,
|
||||||
|
saveLegacy,
|
||||||
|
saveNotice,
|
||||||
|
saveSource,
|
||||||
|
selectedFeedback: selectedFeedback.value,
|
||||||
|
selectedNotice: selectedNotice.value,
|
||||||
|
sourceCategories: sourceCategories.value,
|
||||||
|
sourceDraft,
|
||||||
|
statusTone,
|
||||||
|
syncDatabase,
|
||||||
|
testDatabase,
|
||||||
|
toggleAutoRefresh,
|
||||||
|
validateLegacy,
|
||||||
|
validateNotice,
|
||||||
|
visibleEndpointCount: visibleEndpointCount.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
|
||||||
|
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
|
||||||
|
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAdminPath(value: string) {
|
||||||
|
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
|
||||||
|
if (value === "/") return "/admin/dashboard";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(next: string) {
|
||||||
|
if (currentPath.value === next) {
|
||||||
|
void load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void router.push(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToast(message: string) {
|
||||||
|
toast.value = message;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (toast.value === message) toast.value = "";
|
||||||
|
}, 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guarded(task: () => Promise<void>) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
toast.value = message;
|
||||||
|
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||||
|
navigate("/admin/login");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthBootstrap() {
|
||||||
|
authBootstrap.value = await api<AuthBootstrap>("/api/admin/auth/bootstrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||||
|
});
|
||||||
|
csrf.value = data.csrfToken;
|
||||||
|
localStorage.setItem("ymhut.csrf", csrf.value);
|
||||||
|
navigate("/admin/dashboard");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
|
||||||
|
csrf.value = "";
|
||||||
|
localStorage.removeItem("ymhut.csrf");
|
||||||
|
navigate("/admin/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
await guarded(async () => {
|
||||||
|
if (currentPath.value === "/admin/login") {
|
||||||
|
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||||
|
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
|
||||||
|
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||||
|
if (currentPath.value === "/admin/sources") await loadSources();
|
||||||
|
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
||||||
|
if (currentPath.value === "/admin/database") await loadDatabase();
|
||||||
|
if (currentPath.value === "/admin/health") await loadHealth();
|
||||||
|
if (currentPath.value === "/admin/audit") await loadAudit();
|
||||||
|
if (currentPath.value === "/admin/settings") await previewLegacySync();
|
||||||
|
const legacyName = activeLegacyName.value;
|
||||||
|
if (legacyName) await loadLegacy(legacyName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeedbacks() {
|
||||||
|
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||||
|
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||||
|
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
||||||
|
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||||
|
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFeedback(item: any) {
|
||||||
|
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
|
||||||
|
selectedFeedback.value = data.feedback;
|
||||||
|
feedbackUpdate.status = data.feedback.status || "new";
|
||||||
|
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
||||||
|
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFeedbackUpdate() {
|
||||||
|
if (!selectedFeedback.value) return;
|
||||||
|
await guarded(async () => {
|
||||||
|
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}`, { method: "PATCH", body: JSON.stringify(feedbackUpdate) });
|
||||||
|
setToast("反馈工单已更新");
|
||||||
|
await openFeedback(selectedFeedback.value);
|
||||||
|
await loadFeedbacks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFeedbackComment() {
|
||||||
|
if (!selectedFeedback.value || !commentDraft.body.trim()) return;
|
||||||
|
await guarded(async () => {
|
||||||
|
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ author: "admin", body: commentDraft.body, internal: commentDraft.internal }),
|
||||||
|
});
|
||||||
|
commentDraft.body = "";
|
||||||
|
await openFeedback(selectedFeedback.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReleases() {
|
||||||
|
const [releaseData, noticeData] = await Promise.all([
|
||||||
|
api<{ manifest: any }>("/api/admin/releases"),
|
||||||
|
api<{ items: any[] }>("/api/admin/releases/notices"),
|
||||||
|
]);
|
||||||
|
releases.value = releaseData.manifest;
|
||||||
|
releaseNotices.value = noticeData.items || [];
|
||||||
|
if (releaseNotices.value.length && !noticeDraft.version) await openNotice(releaseNotices.value[0].version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importNotices() {
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ items: any[] }>("/api/admin/releases/notices/import", { method: "POST", body: "{}" });
|
||||||
|
releaseNotices.value = data.items || [];
|
||||||
|
setToast("版本日志已从目录重新导入");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNotice(version: string) {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(version)}`);
|
||||||
|
selectedNotice.value = data.document;
|
||||||
|
noticeDraft.version = version;
|
||||||
|
noticeDraft.raw = data.document.raw || "";
|
||||||
|
noticeDraft.preview = data.document.parsed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateNotice() {
|
||||||
|
if (!noticeDraft.version) return;
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ raw: noticeDraft.raw }),
|
||||||
|
});
|
||||||
|
noticeDraft.raw = data.document.raw;
|
||||||
|
noticeDraft.preview = data.document.parsed;
|
||||||
|
setToast("版本日志 JSON 校验通过");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotice() {
|
||||||
|
if (!noticeDraft.version) return;
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ raw: noticeDraft.raw, note: noticeDraft.note }),
|
||||||
|
});
|
||||||
|
selectedNotice.value = data.document;
|
||||||
|
noticeDraft.note = "";
|
||||||
|
setToast("版本日志已保存并同步兼容更新信息");
|
||||||
|
await loadReleases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreNotice(revisionId: number) {
|
||||||
|
if (!noticeDraft.version) return;
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ revisionId }),
|
||||||
|
});
|
||||||
|
selectedNotice.value = data.document;
|
||||||
|
noticeDraft.raw = data.document.raw || "";
|
||||||
|
noticeDraft.preview = data.document.parsed || null;
|
||||||
|
setToast("版本日志已恢复");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLegacy(name: LegacyName) {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`);
|
||||||
|
legacyDocuments[name] = data.document;
|
||||||
|
legacyDrafts[name].raw = data.document.raw || "";
|
||||||
|
legacyDrafts[name].preview = data.document.parsed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateLegacy(name: LegacyName) {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ raw: legacyDrafts[name].raw }),
|
||||||
|
});
|
||||||
|
legacyDrafts[name].raw = data.document.raw;
|
||||||
|
legacyDrafts[name].preview = data.document.parsed;
|
||||||
|
setToast("兼容 JSON 校验通过");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLegacy(name: LegacyName) {
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }),
|
||||||
|
});
|
||||||
|
legacyDocuments[name] = data.document;
|
||||||
|
legacyDrafts[name].raw = data.document.raw;
|
||||||
|
legacyDrafts[name].preview = data.document.parsed;
|
||||||
|
legacyDrafts[name].note = "";
|
||||||
|
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLegacy(name: LegacyName, revisionId: number) {
|
||||||
|
await guarded(async () => {
|
||||||
|
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ revisionId }),
|
||||||
|
});
|
||||||
|
legacyDocuments[name] = data.document;
|
||||||
|
legacyDrafts[name].raw = data.document.raw;
|
||||||
|
legacyDrafts[name].preview = data.document.parsed;
|
||||||
|
setToast("兼容 JSON 已恢复");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
const data = await api<{ catalog: any }>("/api/admin/sources");
|
||||||
|
sources.value = data.catalog || { categories: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSource() {
|
||||||
|
await guarded(async () => {
|
||||||
|
await api("/api/admin/sources", { method: "POST", body: JSON.stringify(sourceDraft) });
|
||||||
|
setToast("接口源已保存");
|
||||||
|
await Promise.all([loadSources(), loadEndpoints()]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSources() {
|
||||||
|
await guarded(async () => {
|
||||||
|
await api("/api/admin/sources/check", { method: "POST", body: "{}" });
|
||||||
|
setToast("接口心跳检测已进入队列");
|
||||||
|
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||||
|
if (currentPath.value === "/admin/sources") await loadSources();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEndpoints() {
|
||||||
|
const data = await api<{ items: any[] }>("/api/admin/endpoints");
|
||||||
|
endpoints.value = data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyEndpointToSource(item: any) {
|
||||||
|
Object.assign(sourceDraft, {
|
||||||
|
sourceId: item.id || item.sourceId,
|
||||||
|
categoryId: item.category || item.categoryId || "custom",
|
||||||
|
categoryName: item.category || item.categoryName || "自定义接口",
|
||||||
|
name: item.name,
|
||||||
|
method: item.method || "GET",
|
||||||
|
apiUrl: item.urlTemplate || item.apiUrl || "",
|
||||||
|
urlTemplate: item.urlTemplate || item.apiUrl || "",
|
||||||
|
proxyMode: item.proxyMode || "client_direct",
|
||||||
|
enabled: item.enabled,
|
||||||
|
clientVisible: item.clientVisible,
|
||||||
|
cacheSeconds: item.cacheSeconds || 300,
|
||||||
|
checkIntervalSec: item.checkIntervalSec || item.cacheSeconds || 300,
|
||||||
|
supportedFormats: JSON.stringify(item.supportedFormats || ["json"]),
|
||||||
|
});
|
||||||
|
navigate("/admin/sources");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDatabase() {
|
||||||
|
const data = await api<{ database: any }>("/api/admin/database/status");
|
||||||
|
database.value = data.database;
|
||||||
|
databaseForm.provider = data.database?.configProvider || "sqlite";
|
||||||
|
await previewLegacySync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDatabase() {
|
||||||
|
await guarded(async () => {
|
||||||
|
await api("/api/admin/database/test", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }),
|
||||||
|
});
|
||||||
|
setToast("数据库连接测试通过");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncDatabase(direction: "import" | "sync") {
|
||||||
|
await guarded(async () => {
|
||||||
|
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
||||||
|
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||||
|
await loadDatabase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewLegacySync() {
|
||||||
|
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLegacySync() {
|
||||||
|
await guarded(async () => {
|
||||||
|
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||||
|
setToast("旧项目同步已完成");
|
||||||
|
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
healthSnapshot.value = await api("/api/admin/system/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
const data = await api<{ items: any[] }>("/api/admin/system/audit");
|
||||||
|
auditLogs.value = data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
await guarded(async () => {
|
||||||
|
await api("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
|
||||||
|
passwordForm.currentPassword = "";
|
||||||
|
passwordForm.newPassword = "";
|
||||||
|
setToast("后台密码已修改,登录页将不再提示默认密码");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointStatus(item: any) {
|
||||||
|
return item.health?.status || item.lastStatus || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string) {
|
||||||
|
const value = String(status || "").toLowerCase();
|
||||||
|
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
|
||||||
|
if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||||
|
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectEntries(value: Record<string, number>) {
|
||||||
|
return Object.entries(value || {}).map(([name, item]) => ({ name: labelStatus(name), value: item || 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelStatus(value: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ok: "正常",
|
||||||
|
error: "错误",
|
||||||
|
degraded: "降级",
|
||||||
|
unknown: "未知",
|
||||||
|
new: "新建",
|
||||||
|
processing: "处理中",
|
||||||
|
closed: "已关闭",
|
||||||
|
failed: "失败",
|
||||||
|
};
|
||||||
|
return labels[value] || value || "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let next = value;
|
||||||
|
let index = 0;
|
||||||
|
while (next >= 1024 && index < units.length - 1) {
|
||||||
|
next /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(value: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return value.length > 10 ? value.slice(11, 19) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(value: any) {
|
||||||
|
return JSON.stringify(value || {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
refreshTimer = window.setInterval(() => {
|
||||||
|
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main v-if="currentPath === '/admin/login'" class="login-shell">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">YMhut Unified Management</p>
|
||||||
|
<h1>后台登录</h1>
|
||||||
|
<p class="muted">验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
|
||||||
|
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
|
||||||
|
</p>
|
||||||
|
<form class="form-stack" @submit.prevent="login">
|
||||||
|
<label>账号<input v-model="loginForm.username" autocomplete="username" /></label>
|
||||||
|
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" /></label>
|
||||||
|
<label>
|
||||||
|
验证码
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input v-model="loginForm.captcha" />
|
||||||
|
<button class="captcha-button" type="button" title="刷新验证码" @click="loadCaptcha">
|
||||||
|
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
||||||
|
<span v-else>刷新</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button class="btn primary full" type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark"><ShieldCheck :size="22" /></span>
|
||||||
|
<div><strong>YMhut</strong><small>统一管理台</small></div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-groups">
|
||||||
|
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||||
|
<p>{{ group.label }}</p>
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }"
|
||||||
|
@click="navigate(item.path)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" :size="17" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</nav>
|
||||||
|
<button class="logout" @click="logout"><LogOut :size="16" />退出</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">update.ymhut.cn</p>
|
||||||
|
<h1>{{ pageMeta.label }}</h1>
|
||||||
|
<p class="muted">{{ pageMeta.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span v-if="loading" class="badge warn">加载中</span>
|
||||||
|
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||||
|
|
||||||
|
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
|
||||||
|
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
|
||||||
|
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
|
||||||
|
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
||||||
|
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
||||||
|
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
|
||||||
|
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" />
|
||||||
|
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
|
||||||
|
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
|
||||||
|
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ tone?: "default" | "warning" | "danger" }>(), {
|
||||||
|
tone: "default"
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['ui-alert', `ui-alert--${tone}`]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ variant?: "default" | "secondary" | "warning" }>(), {
|
||||||
|
variant: "default"
|
||||||
|
});
|
||||||
|
|
||||||
|
const classes = computed(() => cn("ui-badge", `ui-badge--${props.variant}`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="classes"><slot /></span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
variant?: "default" | "primary" | "ghost" | "outline";
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
}>(), {
|
||||||
|
variant: "default",
|
||||||
|
type: "button"
|
||||||
|
});
|
||||||
|
|
||||||
|
const classes = computed(() => cn("ui-button", `ui-button--${props.variant}`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button :type="type" :class="classes">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<section class="ui-card">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ui-table-wrap">
|
||||||
|
<table class="ui-table">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const RoutePlaceholder = { template: "<span />" };
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
"/admin/login",
|
||||||
|
"/admin/dashboard",
|
||||||
|
"/admin/feedbacks",
|
||||||
|
"/admin/releases",
|
||||||
|
"/admin/legacy/update-info",
|
||||||
|
"/admin/legacy/media-types",
|
||||||
|
"/admin/sources",
|
||||||
|
"/admin/endpoints",
|
||||||
|
"/admin/database",
|
||||||
|
"/admin/health",
|
||||||
|
"/admin/settings",
|
||||||
|
"/admin/audit",
|
||||||
|
].map((path) => ({ path, component: RoutePlaceholder }));
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
...routes,
|
||||||
|
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||||
|
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #111827;
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-soft: #f8fafc;
|
||||||
|
--line: #dfe5ee;
|
||||||
|
--line-strong: #c6d1de;
|
||||||
|
--ink: #111827;
|
||||||
|
--muted: #5f6b7a;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-dark: #1d4ed8;
|
||||||
|
--primary-soft: #e8f0ff;
|
||||||
|
--good: #047857;
|
||||||
|
--good-bg: #e8f7ef;
|
||||||
|
--warn: #b45309;
|
||||||
|
--warn-bg: #fff7e6;
|
||||||
|
--bad: #b42318;
|
||||||
|
--bad-bg: #fff0ed;
|
||||||
|
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; }
|
||||||
|
body { margin: 0; background: var(--bg); }
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: 0.65; }
|
||||||
|
a { color: inherit; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 4px; font-size: 28px; line-height: 1.15; letter-spacing: 0; }
|
||||||
|
h2 { margin-bottom: 12px; font-size: 18px; line-height: 1.25; }
|
||||||
|
h3 { margin-bottom: 8px; font-size: 15px; }
|
||||||
|
.muted { margin-bottom: 0; color: var(--muted); line-height: 1.65; }
|
||||||
|
.mono, .hash, pre, .code-editor { font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; }
|
||||||
|
.hash { max-width: 380px; overflow-wrap: anywhere; font-size: 12px; }
|
||||||
|
.eyebrow { margin: 0 0 6px; color: var(--primary); text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; font-weight: 800; }
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(37, 99, 235, 0.12), transparent 40%),
|
||||||
|
radial-gradient(circle at 82% 12%, rgba(4, 120, 87, 0.10), transparent 34%),
|
||||||
|
#f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-stack, .page-stack, .editor-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
label { display: flex; flex-direction: column; gap: 6px; color: #374151; font-weight: 700; font-size: 13px; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; line-height: 1.55; }
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; }
|
||||||
|
.captcha-button {
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.captcha-button img { width: 100%; height: 42px; object-fit: cover; display: block; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--line-strong); background: #f9fafb; }
|
||||||
|
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
||||||
|
.btn.full { width: 100%; }
|
||||||
|
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.alert-line, .notice {
|
||||||
|
border: 1px solid #f0c36a;
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: #7a3b00;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||||
|
.brand-mark { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||||
|
.brand strong { display: block; }
|
||||||
|
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||||
|
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||||
|
.nav-group { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.nav-group p {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.nav-group button, .logout {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #526070;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); }
|
||||||
|
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||||
|
.logout { color: #7f1d1d; }
|
||||||
|
|
||||||
|
.workspace { min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.topbar, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
|
||||||
|
.topbar { min-height: 72px; }
|
||||||
|
.section-head h2 { margin: 0; }
|
||||||
|
.section-head a { color: var(--primary); font-weight: 800; text-decoration: none; }
|
||||||
|
|
||||||
|
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.metric, .panel {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
}
|
||||||
|
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||||
|
.metric span, .metric small { color: var(--muted); }
|
||||||
|
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
|
||||||
|
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||||
|
.quick-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.quick-grid button {
|
||||||
|
min-height: 112px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.quick-grid button:hover { border-color: var(--primary); background: #f8fbff; }
|
||||||
|
.quick-grid svg { color: var(--primary); }
|
||||||
|
.quick-grid span { color: var(--muted); line-height: 1.45; font-size: 13px; }
|
||||||
|
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||||
|
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
min-width: min(420px, 100%);
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.search-box input { border: 0; box-shadow: none; }
|
||||||
|
.search-box input:focus { box-shadow: none; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
tr.clickable { cursor: pointer; }
|
||||||
|
tr.clickable:hover td { background: #f8fbff; }
|
||||||
|
tr.selected td { background: #eef4ff; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.good { background: var(--good-bg); color: var(--good); border-color: #b7e4ca; }
|
||||||
|
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: #f4d38c; }
|
||||||
|
.badge.bad { background: var(--bad-bg); color: var(--bad); border-color: #f0b8b1; }
|
||||||
|
.badge.neutral { background: #f3f4f6; color: #4b5563; border-color: var(--line); }
|
||||||
|
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.checkbox { flex-direction: row; align-items: center; font-weight: 700; color: var(--muted); }
|
||||||
|
.checkbox input { width: auto; min-height: auto; }
|
||||||
|
.detail-panel { position: sticky; top: 18px; max-height: calc(100dvh - 36px); overflow: auto; }
|
||||||
|
hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0; }
|
||||||
|
.comment-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.comment { border: 1px solid var(--line); border-radius: 6px; padding: 9px; background: var(--panel-soft); }
|
||||||
|
.comment p { margin: 4px 0 0; color: var(--muted); }
|
||||||
|
.empty-state { min-height: 220px; display: grid; place-items: center; align-content: center; gap: 10px; color: var(--muted); text-align: center; }
|
||||||
|
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
|
||||||
|
.source-group { margin-top: 12px; }
|
||||||
|
.source-group h3 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
|
||||||
|
.compact-editor { min-height: 260px; }
|
||||||
|
details {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||||
|
.json-preview {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.json-preview.small { max-height: 260px; }
|
||||||
|
.json-preview.tall { max-height: 70dvh; }
|
||||||
|
.revision-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.revision-list button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.revision-list button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
|
||||||
|
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
|
||||||
|
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
|
||||||
|
.kv-grid span { color: var(--muted); }
|
||||||
|
.kv-grid strong { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
|
||||||
|
.detail-panel { position: static; max-height: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.app-shell { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { position: static; height: auto; }
|
||||||
|
.nav-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.workspace { padding: 16px; }
|
||||||
|
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||||
|
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||||
|
.quick-grid { grid-template-columns: 1fr; }
|
||||||
|
.captcha-row { grid-template-columns: 1fr; }
|
||||||
|
table { min-width: 720px; }
|
||||||
|
.panel { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||||
|
<td>{{ item.type }}</td>
|
||||||
|
<td>{{ item.target }}</td>
|
||||||
|
<td>{{ item.message }}</td>
|
||||||
|
<td>{{ item.ip || "-" }}</td>
|
||||||
|
<td>{{ item.createdAt }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import VChart from "vue-echarts";
|
||||||
|
import { use } from "echarts/core";
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
import { BarChart, GaugeChart, LineChart, PieChart } from "echarts/charts";
|
||||||
|
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||||
|
import { Activity, PauseCircle, PlayCircle } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
|
||||||
|
use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack">
|
||||||
|
<div class="metric-grid">
|
||||||
|
<article class="metric"><span>反馈总数</span><strong>{{ ctx.kpis.feedbackTotal || 0 }}</strong><small>今日新增 {{ ctx.kpis.feedbackToday || 0 }}</small></article>
|
||||||
|
<article class="metric"><span>可见接口</span><strong>{{ ctx.kpis.sourceVisible || 0 }}</strong><small>接口总数 {{ ctx.kpis.sourceTotal || 0 }}</small></article>
|
||||||
|
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice?.version || "暂无最新版本" }}</small></article>
|
||||||
|
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
|
||||||
|
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
|
||||||
|
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||||
|
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||||
|
</button>
|
||||||
|
<span class="muted">每 15 秒自动刷新仪表盘数据。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel quick-panel">
|
||||||
|
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
|
||||||
|
<div class="quick-grid">
|
||||||
|
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)">
|
||||||
|
<component :is="item.icon" :size="18" />
|
||||||
|
<strong>{{ item.label }}</strong>
|
||||||
|
<span>{{ item.description }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="chart-grid">
|
||||||
|
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
|
||||||
|
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||||
|
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
|
||||||
|
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||||
|
<td>{{ item.name || item.sourceId }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||||
|
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||||
|
<td class="hash">{{ item.error || "-" }}</td>
|
||||||
|
<td>{{ item.checkedAt || "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录,点击“立即心跳检测”后会刷新。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head"><h2>客户端调用上报</h2><span class="badge">{{ ctx.clientCalls.length }} 条</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||||
|
<td>{{ item.sourceId }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||||
|
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||||
|
<td class="hash">{{ item.client || "-" }}</td>
|
||||||
|
<td>{{ item.createdAt || "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split">
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head"><h2>数据库运行状态</h2><span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span></div>
|
||||||
|
<div class="kv-grid">
|
||||||
|
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||||
|
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||||
|
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||||
|
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||||
|
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="section-head"><h2>旧项目同步</h2><button class="btn ghost" @click="ctx.previewLegacySync">预览</button></div>
|
||||||
|
<pre class="json-preview small">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||||
|
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||||
|
</section>
|
||||||
|
<aside class="panel editor-panel">
|
||||||
|
<h2>连接与同步</h2>
|
||||||
|
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||||
|
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||||
|
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||||
|
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||||
|
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||||
|
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||||
|
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||||
|
<td>{{ item.category || item.categoryId }}</td>
|
||||||
|
<td>{{ item.proxyMode }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.endpointStatus(item) }}</span></td>
|
||||||
|
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||||
|
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
||||||
|
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split">
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
|
||||||
|
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
|
||||||
|
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||||
|
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
|
||||||
|
<td class="mono">{{ item.code }}</td>
|
||||||
|
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
|
||||||
|
<td>{{ item.priority || "-" }}</td>
|
||||||
|
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<aside class="panel detail-panel">
|
||||||
|
<template v-if="ctx.selectedFeedback">
|
||||||
|
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||||
|
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||||
|
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
|
||||||
|
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||||
|
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
|
||||||
|
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
|
||||||
|
<hr />
|
||||||
|
<h3>评论</h3>
|
||||||
|
<div class="comment-list">
|
||||||
|
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
|
||||||
|
</div>
|
||||||
|
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
||||||
|
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
||||||
|
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
||||||
|
<details>
|
||||||
|
<summary>旧反馈事件 / 邮件记录</summary>
|
||||||
|
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<h2>健康快照</h2>
|
||||||
|
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split wide-split">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||||
|
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
|
||||||
|
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
|
||||||
|
</section>
|
||||||
|
<aside class="panel page-stack">
|
||||||
|
<h2>预览与历史</h2>
|
||||||
|
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||||
|
<div class="revision-list">
|
||||||
|
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||||
|
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split wide-split">
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>发布包</h2>
|
||||||
|
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="pkg in ctx.releasePackages" :key="pkg.fileName || pkg.url">
|
||||||
|
<td>{{ pkg.fileName || pkg.name }}</td>
|
||||||
|
<td>{{ pkg.version || ctx.releases?.app_version || "-" }}</td>
|
||||||
|
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
|
||||||
|
<td>{{ ctx.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||||
|
<td class="hash">{{ pkg.sha256 || "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.releasePackages.length === 0"><td colspan="5">暂无可见发布包。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<aside class="panel editor-panel">
|
||||||
|
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
|
||||||
|
<div class="revision-list">
|
||||||
|
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
|
||||||
|
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
|
||||||
|
</button>
|
||||||
|
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可先执行导入。</div>
|
||||||
|
</div>
|
||||||
|
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
|
||||||
|
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
|
||||||
|
<label>备注<input v-model="ctx.noticeDraft.note" /></label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn ghost" @click="ctx.validateNotice"><CheckCircle2 :size="16" />校验</button>
|
||||||
|
<button class="btn primary" @click="ctx.saveNotice"><Save :size="16" />保存日志</button>
|
||||||
|
</div>
|
||||||
|
<details v-if="ctx.selectedNotice?.revisions?.length">
|
||||||
|
<summary>历史版本</summary>
|
||||||
|
<div class="revision-list">
|
||||||
|
<button v-for="revision in ctx.selectedNotice.revisions" :key="revision.id" @click="ctx.restoreNotice(revision.id)">#{{ revision.id }} {{ revision.createdAt }}</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyRound } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<h2>修改后台密码</h2>
|
||||||
|
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" /></label>
|
||||||
|
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" /></label>
|
||||||
|
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||||
|
</section>
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head"><h2>旧项目同步预览</h2><button class="btn ghost" @click="ctx.previewLegacySync">刷新预览</button></div>
|
||||||
|
<pre class="json-preview">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||||
|
<button class="btn primary" @click="ctx.runLegacySync">执行同步</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ ctx: any }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split">
|
||||||
|
<section class="panel page-stack">
|
||||||
|
<div class="section-head"><h2>媒体/数据源</h2><button class="btn primary" @click="ctx.checkSources">批量检测</button></div>
|
||||||
|
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
||||||
|
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>名称</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
||||||
|
<td>{{ src.name }}</td>
|
||||||
|
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
||||||
|
<td>{{ src.health?.latency_ms || src.lastLatencyMs || 0 }}ms</td>
|
||||||
|
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="ctx.sourceCategories.length === 0" class="empty-state">暂无接口源,可从旧 media-types.json 导入或手动添加。</div>
|
||||||
|
</section>
|
||||||
|
<aside class="panel editor-panel">
|
||||||
|
<h2>添加/覆盖接口</h2>
|
||||||
|
<label>ID<input v-model="ctx.sourceDraft.sourceId" /></label>
|
||||||
|
<label>名称<input v-model="ctx.sourceDraft.name" /></label>
|
||||||
|
<label>分类 ID<input v-model="ctx.sourceDraft.categoryId" /></label>
|
||||||
|
<label>分类名称<input v-model="ctx.sourceDraft.categoryName" /></label>
|
||||||
|
<label>URL<input v-model="ctx.sourceDraft.apiUrl" /></label>
|
||||||
|
<label>代理模式<select v-model="ctx.sourceDraft.proxyMode"><option>client_direct</option><option>server_proxy</option><option>disabled</option></select></label>
|
||||||
|
<div class="two-col">
|
||||||
|
<label>缓存秒数<input v-model.number="ctx.sourceDraft.cacheSeconds" type="number" /></label>
|
||||||
|
<label>检测间隔<input v-model.number="ctx.sourceDraft.checkIntervalSec" type="number" /></label>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox"><input v-model="ctx.sourceDraft.enabled" type="checkbox" />启用</label>
|
||||||
|
<label class="checkbox"><input v-model="ctx.sourceDraft.clientVisible" type="checkbox" />客户端可见</label>
|
||||||
|
<button class="btn primary" @click="ctx.saveSource">保存接口</button>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/admin/",
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:33550"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build embed_web
|
||||||
|
|
||||||
|
package webassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed admin/dist portal/dist setup/dist
|
||||||
|
var FS embed.FS
|
||||||
|
|
||||||
|
const Embedded = true
|
||||||
|
|
||||||
|
func ReadFile(name string) ([]byte, error) {
|
||||||
|
return FS.ReadFile(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
return FS.ReadDir(name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build !embed_web
|
||||||
|
|
||||||
|
package webassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Embedded = false
|
||||||
|
|
||||||
|
func ReadFile(name string) ([]byte, error) {
|
||||||
|
return nil, errors.New("web assets were not embedded; build with -tags embed_web")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
return nil, errors.New("web assets were not embedded; build with -tags embed_web")
|
||||||
|
}
|
||||||
@@ -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 Service Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "ymhut-unified-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"lucide-vue-next": "^0.468.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue": "^3.5.16",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||||
|
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network, ShieldCheck } from "lucide-vue-next";
|
||||||
|
import { usePortalState } from "./state";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const state = usePortalState();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/", label: "状态总览", icon: Home },
|
||||||
|
{ path: "/releases", label: "发布版本", icon: ArrowDownToLine },
|
||||||
|
{ path: "/sources", label: "接口源", icon: Network },
|
||||||
|
{ path: "/feedback", label: "反馈查询", icon: MessageSquareText },
|
||||||
|
{ path: "/compatibility", label: "兼容说明", icon: FileJson },
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(() => state.load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="portal-shell">
|
||||||
|
<nav class="topnav">
|
||||||
|
<RouterLink class="brand" to="/">
|
||||||
|
<span><ShieldCheck :size="22" /></span>
|
||||||
|
<strong>YMhut Box</strong>
|
||||||
|
</RouterLink>
|
||||||
|
<div class="nav-links">
|
||||||
|
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
||||||
|
<component :is="item.icon" :size="15" />{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<a class="admin-link" href="/admin/login">控制台</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<p v-if="state.error.value" class="state-banner error">部分状态读取失败:{{ state.error.value }}</p>
|
||||||
|
<p v-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取服务状态...</p>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import OverviewPage from "./pages/OverviewPage.vue";
|
||||||
|
import ReleasesPage from "./pages/ReleasesPage.vue";
|
||||||
|
import SourcesPage from "./pages/SourcesPage.vue";
|
||||||
|
import FeedbackPage from "./pages/FeedbackPage.vue";
|
||||||
|
import CompatibilityPage from "./pages/CompatibilityPage.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", component: OverviewPage },
|
||||||
|
{ path: "/releases", component: ReleasesPage },
|
||||||
|
{ path: "/sources", component: SourcesPage },
|
||||||
|
{ path: "/feedback", component: FeedbackPage },
|
||||||
|
{ path: "/compatibility", component: CompatibilityPage },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const routes = [
|
||||||
|
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" },
|
||||||
|
{ path: "/api/client/releases", label: "新版发布信息" },
|
||||||
|
{ path: "/api/client/sources", label: "新版接口源目录" },
|
||||||
|
{ path: "/update-info.json", label: "旧版更新 JSON" },
|
||||||
|
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
|
||||||
|
{ path: "/tool-status.json", label: "旧版工具状态" },
|
||||||
|
{ path: "/modules.json", label: "旧版模块清单" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-heading">
|
||||||
|
<p class="eyebrow">Compatibility</p>
|
||||||
|
<h1>兼容说明</h1>
|
||||||
|
<p>新旧客户端共用 update.ymhut.cn,旧路径和旧 JSON 字段继续保留。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel wide">
|
||||||
|
<h2>公开路径</h2>
|
||||||
|
<div class="route-list">
|
||||||
|
<a v-for="item in routes" :key="item.path" :href="item.path">
|
||||||
|
<strong>{{ item.path }}</strong>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { MessageSquareText } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const feedbackCode = ref("");
|
||||||
|
const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=${encodeURIComponent(feedbackCode.value.trim())}` : "/?api=status&code=");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-heading">
|
||||||
|
<p class="eyebrow">Feedback</p>
|
||||||
|
<h1>反馈查询</h1>
|
||||||
|
<p>旧客户端继续向根路径提交反馈。已有反馈可通过反馈码查询处理状态。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel feedback-panel">
|
||||||
|
<h2>查询反馈状态</h2>
|
||||||
|
<div class="feedback-box">
|
||||||
|
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||||
|
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted">反馈提交接口保持旧版兼容:客户端仍可 POST 到服务根路径。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Activity, ArrowDownToLine, Database, ExternalLink, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
|
||||||
|
import { usePortalState } from "../state";
|
||||||
|
|
||||||
|
const state = usePortalState();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">update.ymhut.cn</p>
|
||||||
|
<h1>统一发布、反馈与接口源状态门户</h1>
|
||||||
|
<p>
|
||||||
|
新版客户端通过 bootstrap 动态获取发布信息、版本日志、媒体/数据源目录和接口健康状态。旧客户端仍可继续访问
|
||||||
|
update-info.json、media-types.json、下载路径和反馈根路径。
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
|
||||||
|
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a>
|
||||||
|
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="hero-tags">
|
||||||
|
<span>Legacy JSON 兼容</span>
|
||||||
|
<span>接口健康检测</span>
|
||||||
|
<span>反馈状态追踪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="release-card">
|
||||||
|
<span class="live-dot">服务在线</span>
|
||||||
|
<span>当前版本</span>
|
||||||
|
<strong>{{ state.appVersion.value }}</strong>
|
||||||
|
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
|
||||||
|
<div class="release-meta">
|
||||||
|
<span>{{ state.packages.value.length }} 个发布包</span>
|
||||||
|
<span>{{ state.sourceCount.value }} 个接口源</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metric-grid">
|
||||||
|
<article class="metric"><Database :size="20" /><span>数据库</span><strong>{{ state.databaseStatus.value }}</strong></article>
|
||||||
|
<article class="metric"><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></article>
|
||||||
|
<article class="metric"><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></article>
|
||||||
|
<article class="metric"><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="section-head"><h2>服务入口</h2><a href="/api/client/bootstrap">Bootstrap <ExternalLink :size="14" /></a></div>
|
||||||
|
<div class="route-list">
|
||||||
|
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包、版本公告和 update-notice 日志</span></RouterLink>
|
||||||
|
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源、数据源和动态客户端接口状态</span></RouterLink>
|
||||||
|
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="panel">
|
||||||
|
<div class="section-head"><h2>最新版本日志</h2><RouterLink to="/releases">查看全部</RouterLink></div>
|
||||||
|
<div v-if="state.latestNotice.value" class="notice-card">
|
||||||
|
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
|
||||||
|
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty">暂无远程版本日志。可在后台“发布与日志”中导入 update-notice。</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { BookOpenText, ExternalLink } from "lucide-vue-next";
|
||||||
|
import { usePortalState } from "../state";
|
||||||
|
|
||||||
|
const state = usePortalState();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-heading">
|
||||||
|
<p class="eyebrow">Releases</p>
|
||||||
|
<h1>发布版本</h1>
|
||||||
|
<p>展示发布包、下载入口和 update-notice 版本日志。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<div class="section-head"><h2>发布包</h2><a href="/update-info.json">旧版 update-info.json <ExternalLink :size="14" /></a></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="pkg in state.packages.value" :key="pkg.fileName || pkg.url">
|
||||||
|
<td>{{ pkg.fileName || pkg.name || "-" }}</td>
|
||||||
|
<td>{{ pkg.version || state.appVersion.value }}</td>
|
||||||
|
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
|
||||||
|
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||||
|
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包,旧客户端接口仍保持可用。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel wide">
|
||||||
|
<div class="section-head"><h2>版本日志</h2><a href="/api/client/notices">Notices API <ExternalLink :size="14" /></a></div>
|
||||||
|
<div class="notice-list">
|
||||||
|
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
|
||||||
|
<BookOpenText :size="22" />
|
||||||
|
<div>
|
||||||
|
<strong>{{ notice.title || notice.version }}</strong>
|
||||||
|
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
|
||||||
|
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle2, ExternalLink } from "lucide-vue-next";
|
||||||
|
import { usePortalState } from "../state";
|
||||||
|
|
||||||
|
const state = usePortalState();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-heading">
|
||||||
|
<p class="eyebrow">Sources</p>
|
||||||
|
<h1>接口源健康</h1>
|
||||||
|
<p>媒体源、数据源和客户端动态接口目录的可用性汇总。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel wide">
|
||||||
|
<div class="section-head"><h2>接口源可用性</h2><a href="/api/client/sources">Sources API <ExternalLink :size="14" /></a></div>
|
||||||
|
<div v-if="state.categories.value.length" class="source-board">
|
||||||
|
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
|
||||||
|
<div>
|
||||||
|
<h3>{{ cat.name || cat.id }}</h3>
|
||||||
|
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
|
||||||
|
</div>
|
||||||
|
<div class="source-list">
|
||||||
|
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
|
||||||
|
<CheckCircle2 :size="13" />{{ src.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty">暂无接口源数据。后台同步旧 media-types.json 或手动添加后会显示在这里。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const bootstrap = ref<any>(null);
|
||||||
|
const releases = ref<any>(null);
|
||||||
|
const sources = ref<any>(null);
|
||||||
|
const notices = ref<any[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
async function fetchJSON(path: string) {
|
||||||
|
const res = await fetch(path);
|
||||||
|
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortalState() {
|
||||||
|
const packages = computed(() => releases.value?.packages || bootstrap.value?.release?.packages || []);
|
||||||
|
const categories = computed(() => sources.value?.categories || bootstrap.value?.sources?.categories || []);
|
||||||
|
const latestNotice = computed(() => notices.value[0] || releases.value?.latest_notice || bootstrap.value?.release?.latest_notice || null);
|
||||||
|
const sourceCount = computed(() => categories.value.reduce((total: number, cat: any) => total + (cat.subcategories?.length || 0), 0));
|
||||||
|
const healthyCount = computed(() => categories.value.reduce((total: number, cat: any) => {
|
||||||
|
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
|
||||||
|
}, 0));
|
||||||
|
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
|
||||||
|
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || "/update-info.json");
|
||||||
|
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||||
|
const databaseStatus = computed(() => bootstrap.value?.health?.database?.activeProvider || bootstrap.value?.health?.database?.configProvider || "-");
|
||||||
|
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||||
|
|
||||||
|
async function load(force = false) {
|
||||||
|
if (loaded && !force) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
|
||||||
|
fetchJSON("/api/client/bootstrap"),
|
||||||
|
fetchJSON("/api/client/releases"),
|
||||||
|
fetchJSON("/api/client/sources"),
|
||||||
|
fetchJSON("/api/client/notices"),
|
||||||
|
]);
|
||||||
|
if (bootstrapData.status === "fulfilled") bootstrap.value = bootstrapData.value;
|
||||||
|
if (releaseData.status === "fulfilled") releases.value = releaseData.value;
|
||||||
|
if (sourceData.status === "fulfilled") sources.value = sourceData.value;
|
||||||
|
if (noticeData.status === "fulfilled") notices.value = noticeData.value.items || [];
|
||||||
|
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
|
||||||
|
if (firstFailure && !bootstrap.value) error.value = firstFailure.reason?.message || String(firstFailure.reason);
|
||||||
|
loaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bootstrap,
|
||||||
|
releases,
|
||||||
|
sources,
|
||||||
|
notices,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
packages,
|
||||||
|
categories,
|
||||||
|
latestNotice,
|
||||||
|
sourceCount,
|
||||||
|
healthyCount,
|
||||||
|
availability,
|
||||||
|
downloadUrl,
|
||||||
|
appVersion,
|
||||||
|
databaseStatus,
|
||||||
|
serviceVersion,
|
||||||
|
load,
|
||||||
|
sourceStatus,
|
||||||
|
statusTone,
|
||||||
|
formatBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceStatus(item: any) {
|
||||||
|
return item.health?.status || item.lastStatus || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusTone(status: string) {
|
||||||
|
const value = String(status || "").toLowerCase();
|
||||||
|
if (["ok", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
|
||||||
|
if (["degraded", "pending", "missing"].includes(value)) return "warn";
|
||||||
|
if (["error", "offline", "failed"].includes(value)) return "bad";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let next = value;
|
||||||
|
let index = 0;
|
||||||
|
while (next >= 1024 && index < units.length - 1) {
|
||||||
|
next /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||||
|
color: #172033;
|
||||||
|
background: #f7f9ff;
|
||||||
|
--ink: #172033;
|
||||||
|
--muted: #63718a;
|
||||||
|
--soft: #f7f9ff;
|
||||||
|
--panel: rgba(255, 255, 255, 0.82);
|
||||||
|
--panel-strong: #ffffff;
|
||||||
|
--line: rgba(112, 132, 170, 0.18);
|
||||||
|
--line-strong: rgba(94, 114, 158, 0.28);
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-dark: #2563eb;
|
||||||
|
--cyan: #06b6d4;
|
||||||
|
--violet: #8b5cf6;
|
||||||
|
--pink: #f472b6;
|
||||||
|
--good: #059669;
|
||||||
|
--warn: #b7791f;
|
||||||
|
--bad: #dc2626;
|
||||||
|
--shadow: 0 22px 65px rgba(65, 88, 140, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 6%, rgba(96, 165, 250, 0.30), transparent 28%),
|
||||||
|
radial-gradient(circle at 88% 8%, rgba(244, 114, 182, 0.20), transparent 30%),
|
||||||
|
linear-gradient(180deg, #eef6ff 0%, #f8fbff 42%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 42px 42px;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 70%);
|
||||||
|
}
|
||||||
|
a { color: inherit; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
|
||||||
|
.portal-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 18px clamp(14px, 2.4vw, 28px) 48px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 20;
|
||||||
|
top: 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
box-shadow: 0 16px 42px rgba(62, 87, 130, 0.12);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 5px 12px 5px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brand span {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--cyan));
|
||||||
|
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26);
|
||||||
|
}
|
||||||
|
.brand strong { letter-spacing: 0; }
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.nav-links a, .admin-link {
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #53627d;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.nav-links a:hover, .nav-links a.active {
|
||||||
|
color: var(--primary-dark);
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.admin-link {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.24);
|
||||||
|
}
|
||||||
|
.admin-link:hover { box-shadow: 0 16px 36px rgba(59, 130, 246, 0.32); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
min-height: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
gap: 22px;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.70);
|
||||||
|
border-radius: 32px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)),
|
||||||
|
radial-gradient(circle at 88% 18%, rgba(14, 165, 233, 0.26), transparent 34%),
|
||||||
|
radial-gradient(circle at 18% 82%, rgba(139, 92, 246, 0.18), transparent 30%);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: clamp(28px, 5vw, 58px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -80px;
|
||||||
|
bottom: -120px;
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.20), transparent 68%);
|
||||||
|
}
|
||||||
|
.hero-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 780px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
max-width: 900px;
|
||||||
|
color: #12213a;
|
||||||
|
font-size: clamp(36px, 6vw, 68px);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
h2, h3, p { margin-top: 0; }
|
||||||
|
p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.85;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.hero-copy p { max-width: 690px; font-size: 17px; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.hero-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
.hero-tags span {
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
color: #355075;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
min-height: 46px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid rgba(118, 137, 178, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
color: #263856;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 16px 36px rgba(65, 88, 140, 0.16);
|
||||||
|
}
|
||||||
|
.button.primary {
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #06b6d4);
|
||||||
|
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card, .panel, .metric {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.release-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
align-self: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.release-card span { color: var(--muted); font-weight: 800; }
|
||||||
|
.release-card .live-dot {
|
||||||
|
width: fit-content;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--good);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
background: rgba(209, 250, 229, 0.66);
|
||||||
|
}
|
||||||
|
.release-card .live-dot::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.13);
|
||||||
|
}
|
||||||
|
.release-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1.05;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.release-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.release-meta span, .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #44536e;
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 18px auto 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
min-height: 132px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.metric svg { color: var(--primary); }
|
||||||
|
.metric span { color: var(--muted); font-weight: 800; }
|
||||||
|
.metric strong { color: #15233b; font-size: 30px; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 18px auto 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.12fr 0.88fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.panel.wide { grid-column: 1 / -1; }
|
||||||
|
.page-heading {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.68));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: clamp(24px, 4vw, 42px);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.page-heading h1 { font-size: clamp(32px, 4.6vw, 52px); }
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-head h2 { margin: 0; color: #14223a; }
|
||||||
|
.section-head a {
|
||||||
|
color: var(--primary-dark);
|
||||||
|
font-weight: 900;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid rgba(112, 132, 170, 0.18);
|
||||||
|
padding: 11px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.muted, .empty { color: var(--muted); }
|
||||||
|
.notice-list { display: grid; gap: 12px; }
|
||||||
|
.notice-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid rgba(112, 132, 170, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.notice-card svg { color: var(--primary); }
|
||||||
|
.notice-card strong { display: block; margin-bottom: 6px; overflow-wrap: anywhere; }
|
||||||
|
.notice-card p { margin-bottom: 8px; font-size: 14px; line-height: 1.65; }
|
||||||
|
.notice-card span { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.feedback-panel { max-width: 780px; margin: 0 auto; }
|
||||||
|
.feedback-box {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border: 1px solid rgba(112, 132, 170, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
color: #172033;
|
||||||
|
padding: 10px 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.65);
|
||||||
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||||
|
}
|
||||||
|
.source-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.source-group {
|
||||||
|
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
.source-group h3 { margin-bottom: 2px; color: #14223a; }
|
||||||
|
.source-group p { margin-bottom: 10px; font-size: 14px; }
|
||||||
|
.source-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge.good { color: var(--good); background: rgba(209, 250, 229, 0.78); border-color: rgba(16, 185, 129, 0.28); }
|
||||||
|
.badge.warn { color: var(--warn); background: rgba(254, 243, 199, 0.82); border-color: rgba(245, 158, 11, 0.28); }
|
||||||
|
.badge.bad { color: var(--bad); background: rgba(254, 226, 226, 0.82); border-color: rgba(239, 68, 68, 0.26); }
|
||||||
|
.route-list { display: grid; gap: 10px; }
|
||||||
|
.route-list a {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.70);
|
||||||
|
padding: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 900;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.route-list a:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(59, 130, 246, 0.36);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
|
||||||
|
.state-banner {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 12px auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
box-shadow: 0 12px 30px rgba(65, 88, 140, 0.10);
|
||||||
|
}
|
||||||
|
.error { color: var(--bad); }
|
||||||
|
.loading { color: var(--muted); }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.topnav {
|
||||||
|
position: static;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
.nav-links { justify-content: flex-start; }
|
||||||
|
.hero, .content-grid, .metric-grid, .source-board { grid-template-columns: 1fr; }
|
||||||
|
.hero { min-height: auto; }
|
||||||
|
.feedback-box { grid-template-columns: 1fr; }
|
||||||
|
table { min-width: 680px; }
|
||||||
|
.panel { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.portal-shell { padding-inline: 10px; }
|
||||||
|
.hero, .page-heading { border-radius: 24px; padding: 24px; }
|
||||||
|
h1 { font-size: 36px; }
|
||||||
|
.actions .button { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:33550",
|
||||||
|
"/update-info.json": "http://127.0.0.1:33550",
|
||||||
|
"/downloads": "http://127.0.0.1:33550"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 Unified Setup</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "ymhut-unified-setup",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"lucide-vue-next": "^0.468.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue": "^3.5.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
FolderCog,
|
||||||
|
LoaderCircle,
|
||||||
|
LockKeyhole,
|
||||||
|
ServerCog,
|
||||||
|
ShieldCheck,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
|
||||||
|
type SetupStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
baseDir: string;
|
||||||
|
configPath: string;
|
||||||
|
defaults?: {
|
||||||
|
provider?: string;
|
||||||
|
sqlitePath?: string;
|
||||||
|
mysqlDsn?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestResult = {
|
||||||
|
ok: boolean;
|
||||||
|
provider: string;
|
||||||
|
normalized?: Record<string, unknown>;
|
||||||
|
maskedDsn?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ title: "环境确认", description: "确认服务基准目录和公开地址", icon: ServerCog },
|
||||||
|
{ title: "数据库选择", description: "选择 SQLite 或 MySQL", icon: Database },
|
||||||
|
{ title: "连接配置", description: "填写对应数据库参数", icon: FolderCog },
|
||||||
|
{ title: "连接测试", description: "由后端真实测试连接", icon: ShieldCheck },
|
||||||
|
{ title: "完成确认", description: "写入配置并创建默认账号", icon: LockKeyhole },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStep = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const status = ref<SetupStatus | null>(null);
|
||||||
|
const testResult = ref<TestResult | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
const completed = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: "https://update.ymhut.cn",
|
||||||
|
provider: "sqlite",
|
||||||
|
sqliteDir: "storage",
|
||||||
|
sqliteFile: "unified.sqlite",
|
||||||
|
mysql: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 3306,
|
||||||
|
database: "ymhut_unified",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
charset: "utf8mb4",
|
||||||
|
parseTime: true,
|
||||||
|
tls: "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sqlitePath = computed(() => {
|
||||||
|
const dir = form.sqliteDir.trim().replace(/[\\/]+$/g, "") || "storage";
|
||||||
|
const file = form.sqliteFile.trim() || "unified.sqlite";
|
||||||
|
return `${dir}/${file}`.replace(/\\/g, "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
const canContinue = computed(() => {
|
||||||
|
if (currentStep.value === 0) return form.baseUrl.trim().length > 0;
|
||||||
|
if (currentStep.value === 1) return form.provider === "sqlite" || form.provider === "mysql";
|
||||||
|
if (currentStep.value === 2) {
|
||||||
|
if (form.provider === "sqlite") return form.sqliteDir.trim() && form.sqliteFile.trim();
|
||||||
|
return form.mysql.host.trim() && form.mysql.port > 0 && form.mysql.database.trim() && form.mysql.username.trim();
|
||||||
|
}
|
||||||
|
if (currentStep.value === 3) return testResult.value?.ok === true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = computed(() => ({
|
||||||
|
provider: form.provider,
|
||||||
|
baseUrl: form.baseUrl.trim(),
|
||||||
|
sqlitePath: sqlitePath.value,
|
||||||
|
mysql: {
|
||||||
|
host: form.mysql.host.trim(),
|
||||||
|
port: Number(form.mysql.port || 3306),
|
||||||
|
database: form.mysql.database.trim(),
|
||||||
|
username: form.mysql.username.trim(),
|
||||||
|
password: form.mysql.password,
|
||||||
|
charset: form.mysql.charset.trim() || "utf8mb4",
|
||||||
|
parseTime: form.mysql.parseTime,
|
||||||
|
tls: form.mysql.tls.trim() || "false",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||||
|
const res = await fetch(target, { ...init, headers });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
status.value = await api<SetupStatus>("/api/setup/status");
|
||||||
|
form.baseUrl = status.value.defaults?.baseUrl || form.baseUrl;
|
||||||
|
form.provider = status.value.defaults?.provider || "sqlite";
|
||||||
|
const defaultSQLite = status.value.defaults?.sqlitePath || "storage/unified.sqlite";
|
||||||
|
const normalized = defaultSQLite.replace(/\\/g, "/");
|
||||||
|
const index = normalized.lastIndexOf("/");
|
||||||
|
if (index > -1) {
|
||||||
|
form.sqliteDir = normalized.slice(0, index) || "storage";
|
||||||
|
form.sqliteFile = normalized.slice(index + 1) || "unified.sqlite";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
if (!canContinue.value) return;
|
||||||
|
if (currentStep.value < steps.length - 1) currentStep.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousStep() {
|
||||||
|
if (currentStep.value > 0) currentStep.value -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDatabase() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
testResult.value = null;
|
||||||
|
try {
|
||||||
|
testResult.value = await api<TestResult>("/api/setup/database/test", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload.value),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSetup() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await api("/api/setup/complete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload.value),
|
||||||
|
});
|
||||||
|
completed.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadStatus);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="setup-shell">
|
||||||
|
<section class="setup-card">
|
||||||
|
<aside class="setup-aside">
|
||||||
|
<p class="eyebrow">update.ymhut.cn</p>
|
||||||
|
<h1>初始化统一管理服务端</h1>
|
||||||
|
<p>使用分步向导确认运行目录、数据库连接和默认账号。所有连接测试都由服务端完成。</p>
|
||||||
|
|
||||||
|
<ol class="step-list">
|
||||||
|
<li v-for="(step, index) in steps" :key="step.title" :class="{ active: currentStep === index, done: currentStep > index || completed }">
|
||||||
|
<span><component :is="step.icon" :size="18" /></span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ step.title }}</strong>
|
||||||
|
<small>{{ step.description }}</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="setup-main">
|
||||||
|
<header class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Step {{ currentStep + 1 }} / {{ steps.length }}</p>
|
||||||
|
<h2>{{ steps[currentStep].title }}</h2>
|
||||||
|
</div>
|
||||||
|
<span v-if="loading" class="badge"><LoaderCircle :size="15" class="spin" />处理中</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="error" class="alert bad"><TriangleAlert :size="17" />{{ error }}</p>
|
||||||
|
|
||||||
|
<section v-if="completed" class="complete-panel">
|
||||||
|
<CheckCircle2 :size="42" />
|
||||||
|
<h2>初始化完成</h2>
|
||||||
|
<p>配置已写入服务基准目录。请重启服务后打开 <code>/admin/login</code>,使用默认账号 <strong>admin/admin</strong> 登录并立即修改密码。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="currentStep === 0" class="form-grid">
|
||||||
|
<label class="wide">服务基准目录<input :value="status?.baseDir || '-'" readonly /></label>
|
||||||
|
<label class="wide">配置文件路径<input :value="status?.configPath || '-'" readonly /></label>
|
||||||
|
<label class="wide">规范服务地址<input v-model.trim="form.baseUrl" placeholder="https://update.ymhut.cn" /></label>
|
||||||
|
<p class="hint wide">相对路径会以服务基准目录为准。生产环境可继续通过反向代理把其他域名重定向到该地址。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentStep === 1" class="choice-grid">
|
||||||
|
<button :class="{ selected: form.provider === 'sqlite' }" @click="form.provider = 'sqlite'; testResult = null">
|
||||||
|
<Database :size="24" />
|
||||||
|
<strong>SQLite</strong>
|
||||||
|
<span>推荐单机或轻量部署。默认写入 storage/unified.sqlite。</span>
|
||||||
|
</button>
|
||||||
|
<button :class="{ selected: form.provider === 'mysql' }" @click="form.provider = 'mysql'; testResult = null">
|
||||||
|
<ServerCog :size="24" />
|
||||||
|
<strong>MySQL</strong>
|
||||||
|
<span>适合远端热同步、迁移和多实例备份场景。</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentStep === 2 && form.provider === 'sqlite'" class="form-grid">
|
||||||
|
<label>SQLite 目录<input v-model.trim="form.sqliteDir" placeholder="storage" /></label>
|
||||||
|
<label>SQLite 文件名<input v-model.trim="form.sqliteFile" placeholder="unified.sqlite" /></label>
|
||||||
|
<label class="wide">最终路径<input :value="sqlitePath" readonly /></label>
|
||||||
|
<p class="hint wide">保存时仍会由后端校验并创建目录,不允许路径逃逸。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentStep === 2 && form.provider === 'mysql'" class="form-grid">
|
||||||
|
<label>Host<input v-model.trim="form.mysql.host" placeholder="127.0.0.1" /></label>
|
||||||
|
<label>Port<input v-model.number="form.mysql.port" type="number" min="1" /></label>
|
||||||
|
<label>Database<input v-model.trim="form.mysql.database" placeholder="ymhut_unified" /></label>
|
||||||
|
<label>Username<input v-model.trim="form.mysql.username" autocomplete="username" /></label>
|
||||||
|
<label>Password<input v-model="form.mysql.password" type="password" autocomplete="new-password" /></label>
|
||||||
|
<label>Charset<input v-model.trim="form.mysql.charset" placeholder="utf8mb4" /></label>
|
||||||
|
<label>parseTime<select v-model="form.mysql.parseTime"><option :value="true">true</option><option :value="false">false</option></select></label>
|
||||||
|
<label>TLS<select v-model="form.mysql.tls"><option>false</option><option>true</option><option>skip-verify</option><option>preferred</option></select></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentStep === 3" class="test-panel">
|
||||||
|
<div class="summary-box">
|
||||||
|
<span>数据库类型</span><strong>{{ form.provider }}</strong>
|
||||||
|
<span>SQLite 路径</span><strong>{{ form.provider === "sqlite" ? sqlitePath : "-" }}</strong>
|
||||||
|
<span>MySQL 地址</span><strong>{{ form.provider === "mysql" ? `${form.mysql.host}:${form.mysql.port}/${form.mysql.database}` : "-" }}</strong>
|
||||||
|
</div>
|
||||||
|
<button class="btn primary" data-testid="setup-test-database" @click="testDatabase"><ShieldCheck :size="17" />执行连接测试</button>
|
||||||
|
<div v-if="testResult" class="result good">
|
||||||
|
<CheckCircle2 :size="20" />
|
||||||
|
<div>
|
||||||
|
<strong>连接测试通过</strong>
|
||||||
|
<p>{{ testResult.provider }},耗时 {{ testResult.latencyMs || 0 }}ms</p>
|
||||||
|
<code v-if="testResult.maskedDsn">{{ testResult.maskedDsn }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentStep === 4" class="confirm-panel">
|
||||||
|
<div class="summary-box">
|
||||||
|
<span>服务地址</span><strong>{{ form.baseUrl }}</strong>
|
||||||
|
<span>数据库</span><strong>{{ form.provider }}</strong>
|
||||||
|
<span>连接</span><strong>{{ form.provider === "sqlite" ? sqlitePath : `${form.mysql.host}:${form.mysql.port}/${form.mysql.database}` }}</strong>
|
||||||
|
<span>默认账号</span><strong>admin / admin</strong>
|
||||||
|
</div>
|
||||||
|
<p class="alert warn"><TriangleAlert :size="17" />初始化不会在此处修改默认密码。进入后台后登录页会提示默认密码,请立即修改。</p>
|
||||||
|
<button class="btn primary" data-testid="setup-complete" @click="completeSetup"><CheckCircle2 :size="17" />写入配置并完成初始化</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="setup-actions">
|
||||||
|
<button class="btn ghost" data-testid="setup-prev" :disabled="currentStep === 0" @click="previousStep"><ChevronLeft :size="17" />上一步</button>
|
||||||
|
<button v-if="currentStep < steps.length - 1" class="btn primary" data-testid="setup-next" :disabled="!canContinue" @click="nextStep">下一步<ChevronRight :size="17" /></button>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user