@@ -30,7 +30,7 @@ func Run() {
|
||||
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)) {
|
||||
for _, line := range config.FormatPreflight(cfg, config.Preflight(cfg)) {
|
||||
log.Print(line)
|
||||
}
|
||||
if !cfg.Initialized && os.Getenv("YMHUT_SKIP_SETUP") != "1" {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# YMhut Unified Management Backend Architecture Review
|
||||
|
||||
## 结论
|
||||
|
||||
`server/unified-management` 目前已经具备统一服务的基本形态:一个 Go 后端同时承接旧版更新 JSON、下载、媒体源、反馈提交、新客户端 bootstrap、管理后台、SQLite/MySQL 存储和旧项目同步。现有 `go test ./...` 全部通过,说明核心兼容路由和主要模块有基本回归保障。
|
||||
|
||||
但当前实现仍属于“快速整合型单体”:`internal/web/router.go` 和 `internal/db/store.go` 承担了过多职责,后台能力已经铺开,但业务边界、数据访问边界、兼容适配边界还不够清晰。继续扩展前,应先把后台架构拆成稳定的分层单体,保留旧访问方式和旧反馈协议作为独立兼容层。
|
||||
|
||||
## 当前主要问题
|
||||
|
||||
1. 路由层过重
|
||||
|
||||
`internal/web/router.go` 同时处理公共门户、旧版路由、客户端 API、后台 API、静态资源、错误本地化、SSE、CSV 导出和文件下载。后续新增后台功能时,容易出现路径匹配顺序冲突、权限遗漏和响应结构不一致。
|
||||
|
||||
2. 数据层过重
|
||||
|
||||
`internal/db/store.go` 同时包含表结构迁移、实体定义、Repository、密码哈希、业务默认值、统计聚合、SQLite/MySQL failover、全表同步和旧原型 JSON 导入。它已经变成“全系统共享内核”,任何改动都容易影响多个业务域。
|
||||
|
||||
3. 兼容逻辑与新业务逻辑混杂
|
||||
|
||||
旧版 `update-info.json`、`media-types.json`、`tool-status.json`、`modules.json`、`/downloads/*` 和旧反馈 `POST /` 都是必须保留的外部契约,但现在它们和新 API 共享服务/存储细节,长期看会让新后台被旧 JSON 格式牵着走。
|
||||
|
||||
4. 安全策略需要后台化
|
||||
|
||||
当前有验证码、HttpOnly session cookie、CSRF、上传包校验和路径逃逸防护,这是好基础。但后台还需要更明确的密码策略、登录失败限流、会话持久化/吊销、审计事件规范、管理员角色模型。当前密码哈希是静态 SHA-256,建议迁移到 Argon2id 或 bcrypt。
|
||||
|
||||
5. 数据库双写/同步语义不清
|
||||
|
||||
配置里有 failover 和 hot sync 字段,但当前更接近手动全表复制和运行时 MySQL 失败回退 SQLite。需要明确“单主数据库、备份数据库、灾备同步”的关系,否则后台管理员很难判断哪些操作会覆盖数据。
|
||||
|
||||
6. 前端后台状态集中
|
||||
|
||||
`web/admin/src/App.vue` 集中了 API client、页面路由、全局状态、业务动作、表单转换、错误翻译和 SSE 连接。后台页面已经很多,建议拆成 `api/`、`stores/`、`features/`,否则维护体验会快速变差。
|
||||
|
||||
## 推荐后台架构
|
||||
|
||||
保持 Go 单体部署,但按边界拆成分层模块:
|
||||
|
||||
```text
|
||||
cmd/unified-management
|
||||
app/ # 进程启动、依赖组装、优雅退出
|
||||
|
||||
internal/http
|
||||
middleware/ # auth、csrf、security headers、request id、rate limit
|
||||
legacy/ # 旧访问方式适配器
|
||||
clientapi/ # 新客户端 API
|
||||
adminapi/ # 后台管理 API
|
||||
setupapi/ # 首次初始化 API
|
||||
static/ # admin/portal/setup 前端资源
|
||||
|
||||
internal/domain
|
||||
feedback/ # 反馈工单聚合、状态流转、附件规则
|
||||
releases/ # 发布包、版本公告、manifest
|
||||
sources/ # 数据源目录、健康检查、调用日志
|
||||
compatibility/ # 旧 JSON 文档模型和转换
|
||||
audit/ # 审计事件
|
||||
admin/ # 管理员、角色、会话
|
||||
system/ # 健康检查、配置、数据库状态
|
||||
|
||||
internal/storage
|
||||
migrations/ # schema 和版本迁移
|
||||
repos/ # 按领域拆分 Repository
|
||||
sqlstore/ # SQLite/MySQL 方言、连接、事务
|
||||
sync/ # SQLite/MySQL 同步策略
|
||||
|
||||
internal/jobs
|
||||
sourcecheck/ # 数据源健康检测
|
||||
legacysync/ # 旧项目导入/同步
|
||||
cleanup/ # 过期会话、旧日志、临时文件清理
|
||||
|
||||
internal/contracts
|
||||
legacy/ # 旧客户端响应 DTO
|
||||
client/ # 新客户端响应 DTO
|
||||
admin/ # 后台响应 DTO
|
||||
```
|
||||
|
||||
核心原则:
|
||||
|
||||
- `legacy` 只负责旧协议输入输出,不直接写业务表细节。
|
||||
- `domain` 负责业务规则,不依赖 HTTP request/response。
|
||||
- `storage` 负责持久化和事务,不包含业务默认文案、状态流转和 UI 语义。
|
||||
- `adminapi` 是后台编排层,只调用 domain service,不直接拼 SQL。
|
||||
- 所有对外 JSON 响应都定义 DTO,避免直接暴露数据库结构。
|
||||
|
||||
## 必须保留的旧版访问契约
|
||||
|
||||
这些路径应作为长期兼容 API,不随后台重构而改变:
|
||||
|
||||
| 旧版入口 | 当前用途 | 建议归属 |
|
||||
| --- | --- | --- |
|
||||
| `GET /update-info.json`、`GET /update-info` | 旧客户端更新信息 | `internal/http/legacy/update.go` |
|
||||
| `GET /tool-status.json`、`GET /tool-status` | 旧工具状态 | `internal/http/legacy/static_json.go` |
|
||||
| `GET /modules.json`、`GET /modules`、`GET /api/modules` | 旧模块配置 | `internal/http/legacy/static_json.go` |
|
||||
| `GET /media-types.json`、`GET /media-types` | 旧媒体源目录 | `internal/http/legacy/media_types.go` |
|
||||
| `GET /downloads/:filename` | 旧下载包 | `internal/http/legacy/downloads.go` |
|
||||
| `POST /` | 旧反馈提交 | `internal/http/legacy/feedback.go` |
|
||||
| `GET /?api=status&code=:code` | 旧反馈状态查询 | `internal/http/legacy/feedback.go` |
|
||||
|
||||
兼容层的响应字段应保持“只增不删、不改名、不改含义”。新后台可以增加内部字段,但旧接口输出必须通过 legacy DTO 过滤,避免把后台工单详情、内部备注、附件路径等泄漏给旧客户端。
|
||||
|
||||
## 旧版反馈兼容设计
|
||||
|
||||
旧反馈应当分为三个入口模型:
|
||||
|
||||
1. 简单 JSON 表单
|
||||
|
||||
兼容旧客户端或网页直接提交 `title/type/severity/contact/body/message`。服务端生成反馈码,返回旧状态结构。
|
||||
|
||||
2. 普通 multipart 表单
|
||||
|
||||
兼容旧表单字段 `title/subject/category/priority/message/description/email`。如果没有签名字段,按简单反馈处理。
|
||||
|
||||
3. 签名加密包 multipart
|
||||
|
||||
继续保留 `payload/timestamp/nonce/packageSha256/signature/package`。校验顺序固定为:请求大小 -> 时间窗 -> SHA256 格式 -> HMAC 签名 -> 加密包 magic -> 包哈希 -> 解密 -> zip 安全检查 -> 写入附件 -> 创建工单。
|
||||
|
||||
后台内部统一落到 `feedback_tickets` 聚合:
|
||||
|
||||
```text
|
||||
legacy feedback request
|
||||
-> LegacyFeedbackAdapter
|
||||
-> FeedbackCommand(CreateTicket)
|
||||
-> FeedbackService
|
||||
-> FeedbackRepository + AttachmentStorage
|
||||
-> LegacyFeedbackStatusDTO
|
||||
```
|
||||
|
||||
旧状态查询只返回:
|
||||
|
||||
- `ok`
|
||||
- `code`
|
||||
- `status`
|
||||
- `statusLabel`
|
||||
- `statusDetail`
|
||||
- `category`
|
||||
- `priority`
|
||||
- `hasReply`
|
||||
- `reply`
|
||||
- `receivedAt`
|
||||
- `updatedAt`
|
||||
- `mailSent`
|
||||
- `duplicate`
|
||||
|
||||
不返回内部 `note`、`assignee`、`handledBy`、本地文件路径、审计日志、mail record。
|
||||
|
||||
## 后台管理能力设计
|
||||
|
||||
后台建议分为以下域:
|
||||
|
||||
1. 仪表盘
|
||||
|
||||
提供反馈数量、今日反馈、数据源总数、可见源数量、发布版本、数据库状态、最近心跳、最近客户端调用。只读,适合高频刷新。
|
||||
|
||||
2. 反馈工单
|
||||
|
||||
支持分页、筛选、搜索、状态流转、公开回复、内部备注、评论、标签、分派、优先级、SLA、附件查看、CSV 导出。状态流转应集中在 `FeedbackService`,不要由 Store 直接决定。
|
||||
|
||||
3. 发布管理
|
||||
|
||||
管理发布包上传、版本号、平台/架构、SHA256、manifest 生成、`update-info.json` 同步、版本公告保存、公告历史恢复。
|
||||
|
||||
4. 兼容 JSON
|
||||
|
||||
管理 `update-info.json` 和 `media-types.json`,提供验证、预览、保存、修订历史、恢复。该模块属于兼容层后台,不应成为新客户端的唯一数据来源。
|
||||
|
||||
5. 数据源目录
|
||||
|
||||
管理媒体/数据源、健康检测、客户端可见性、缓存时间、代理策略、调用日志。健康检测任务应进入 jobs 模块,并持久化任务结果。
|
||||
|
||||
6. 数据库与同步
|
||||
|
||||
明确 SQLite/MySQL 角色:推荐 SQLite 单机默认,MySQL 生产主库,SQLite 作为本地备份。后台按钮应显示方向、影响范围、覆盖风险和最后同步结果。
|
||||
|
||||
7. 系统设置
|
||||
|
||||
管理管理员密码、会话、旧项目路径、BaseURL、上传限制、签名密钥轮换、服务健康。
|
||||
|
||||
8. 审计日志
|
||||
|
||||
所有后台写操作、旧项目同步、发布包上传、JSON 恢复、密码修改都写入审计。审计事件统一字段:`actor/type/target/message/ip/userAgent/createdAt`。
|
||||
|
||||
## 数据模型建议
|
||||
|
||||
将数据库实体按业务域拆分:
|
||||
|
||||
- `admin_users`
|
||||
- `admin_sessions`
|
||||
- `feedback_tickets`
|
||||
- `feedback_comments`
|
||||
- `feedback_attachments`
|
||||
- `feedback_events`
|
||||
- `release_packages`
|
||||
- `release_notices`
|
||||
- `release_notice_revisions`
|
||||
- `legacy_json_revisions`
|
||||
- `source_categories`
|
||||
- `source_endpoints`
|
||||
- `endpoint_health_checks`
|
||||
- `endpoint_call_logs`
|
||||
- `audit_logs`
|
||||
- `database_sync_jobs`
|
||||
- `legacy_sync_jobs`
|
||||
|
||||
建议新增:
|
||||
|
||||
- `schema_migrations`:记录数据库迁移版本。
|
||||
- `admin_roles`、`admin_user_roles`:为后续多管理员预留。
|
||||
- `settings`:保存可后台修改的配置项,和 `config.json` 做边界区分。
|
||||
- `background_jobs`:统一记录旧同步、源检查、清理任务。
|
||||
- `api_tokens`:为未来自动发布、CI 上传包、客户端管理接口预留。
|
||||
|
||||
## 改造优先级
|
||||
|
||||
第一阶段:稳住兼容契约
|
||||
|
||||
- 为旧版路径建立专门测试:`/update-info.json`、`/tool-status.json`、`/modules.json`、`/media-types.json`、`/downloads/*`、`POST /`、`/?api=status`。
|
||||
- 把旧响应结构固定为 DTO 测试快照。
|
||||
- 给旧反馈三种提交方式分别补测试。
|
||||
|
||||
第二阶段:拆 HTTP 层
|
||||
|
||||
- 把 `router.go` 拆成 `legacy_routes.go`、`client_routes.go`、`admin_feedback_routes.go`、`admin_release_routes.go`、`admin_source_routes.go`、`admin_system_routes.go`、`static_routes.go`。
|
||||
- 引入统一 `RouteGroup` 或 `http.ServeMux` 包装,避免一个巨大 switch 继续增长。
|
||||
|
||||
第三阶段:拆 Store
|
||||
|
||||
- 将实体移动到 `internal/domain/*` 或 `internal/contracts/*`。
|
||||
- 将 SQL 拆成 `FeedbackRepository`、`ReleaseRepository`、`SourceRepository`、`AuditRepository`、`AdminRepository`。
|
||||
- 将迁移、连接、failover、sync 从业务 repo 中拆出。
|
||||
|
||||
第四阶段:完善后台安全
|
||||
|
||||
- 密码哈希迁移到 Argon2id/bcrypt,并保留旧 SHA-256 登录后自动升级。
|
||||
- 登录失败限流和验证码刷新频率限制。
|
||||
- 会话持久化、会话吊销、Secure cookie 配置。
|
||||
- 敏感配置和密钥不在 bootstrap 或日志中明文输出。
|
||||
|
||||
第五阶段:前端后台模块化
|
||||
|
||||
- `web/admin/src/api/*`:封装后台 API。
|
||||
- `web/admin/src/stores/*`:按域拆状态。
|
||||
- `web/admin/src/features/*`:按页面拆业务动作。
|
||||
- `App.vue` 只保留壳层、导航、路由出口和全局 toast。
|
||||
|
||||
## 验证清单
|
||||
|
||||
当前已通过:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
后续每次重构必须至少验证:
|
||||
|
||||
- 旧版更新 JSON 路由仍返回 200 且字段不减少。
|
||||
- 旧版反馈 `POST /` 能创建工单,重复反馈码返回 duplicate。
|
||||
- 旧版反馈状态查询不泄漏内部字段。
|
||||
- 管理后台写操作未登录返回 401,未带 CSRF 返回 403。
|
||||
- 发布包上传拒绝路径逃逸和不支持扩展名。
|
||||
- 数据源健康检测拒绝非 HTTP/HTTPS 重定向。
|
||||
- SQLite 默认启动正常,MySQL 配置失败时 failover 行为明确。
|
||||
- 旧项目同步 dry-run 不写数据,run 前有备份。
|
||||
|
||||
@@ -24,13 +24,17 @@ const (
|
||||
SessionCookie = "ymhut_unified_session"
|
||||
captchaTTL = 5 * time.Minute
|
||||
sessionTTL = 12 * time.Hour
|
||||
loginWindow = 5 * time.Minute
|
||||
loginLockTTL = 5 * time.Minute
|
||||
loginMaxFails = 5
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *db.Store
|
||||
mu sync.Mutex
|
||||
captchas map[string]captchaEntry
|
||||
sessions map[string]sessionEntry
|
||||
store *db.Store
|
||||
mu sync.Mutex
|
||||
captchas map[string]captchaEntry
|
||||
sessions map[string]sessionEntry
|
||||
loginAttempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
type captchaEntry struct {
|
||||
@@ -44,6 +48,12 @@ type sessionEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lastFailure time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
ID string `json:"captchaId"`
|
||||
Image string `json:"image"`
|
||||
@@ -51,9 +61,10 @@ type Captcha struct {
|
||||
|
||||
func NewService(store *db.Store) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
captchas: map[string]captchaEntry{},
|
||||
sessions: map[string]sessionEntry{},
|
||||
store: store,
|
||||
captchas: map[string]captchaEntry{},
|
||||
sessions: map[string]sessionEntry{},
|
||||
loginAttempts: map[string]loginAttempt{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +102,18 @@ func (s *Service) NewCaptcha() (Captcha, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string) (string, string, bool, error) {
|
||||
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, bool, error) {
|
||||
attemptKey := loginAttemptKey(username, clientKeys...)
|
||||
if s.loginLocked(attemptKey) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if !s.consumeCaptcha(captchaID, captcha) {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
return "", "", false, nil
|
||||
}
|
||||
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
||||
if err != nil || !ok {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
return "", "", false, err
|
||||
}
|
||||
sessionID := randomToken(32)
|
||||
@@ -104,6 +121,7 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked()
|
||||
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
|
||||
delete(s.loginAttempts, attemptKey)
|
||||
s.mu.Unlock()
|
||||
return sessionID, csrf, true, nil
|
||||
}
|
||||
@@ -136,13 +154,13 @@ 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"})
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "UNAUTHORIZED", "message": "需要登录后继续操作"})
|
||||
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"})
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "CSRF_INVALID", "message": "页面安全令牌无效,请刷新后重试"})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -151,6 +169,14 @@ func (s *Service) Require(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
setSessionCookie(w, sessionID, false)
|
||||
}
|
||||
|
||||
func SetSessionCookieForRequest(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
setSessionCookie(w, sessionID, requestIsHTTPS(r))
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, sessionID string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: sessionID,
|
||||
@@ -158,6 +184,7 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: secure,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +192,16 @@ func clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
|
||||
func requestIsHTTPS(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
func (s *Service) consumeCaptcha(id, answer string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
answer = strings.TrimSpace(answer)
|
||||
@@ -193,6 +230,50 @@ func (s *Service) cleanupLocked() {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
for key, attempt := range s.loginAttempts {
|
||||
if attempt.lockedUntil.IsZero() && now.Sub(attempt.lastFailure) > loginWindow {
|
||||
delete(s.loginAttempts, key)
|
||||
continue
|
||||
}
|
||||
if !attempt.lockedUntil.IsZero() && now.After(attempt.lockedUntil) && now.Sub(attempt.lastFailure) > loginWindow {
|
||||
delete(s.loginAttempts, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loginLocked(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked()
|
||||
attempt := s.loginAttempts[key]
|
||||
return !attempt.lockedUntil.IsZero() && time.Now().Before(attempt.lockedUntil)
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
attempt := s.loginAttempts[key]
|
||||
if now.Sub(attempt.lastFailure) > loginWindow {
|
||||
attempt.failures = 0
|
||||
}
|
||||
attempt.failures++
|
||||
attempt.lastFailure = now
|
||||
if attempt.failures >= loginMaxFails {
|
||||
attempt.lockedUntil = now.Add(loginLockTTL)
|
||||
}
|
||||
s.loginAttempts[key] = attempt
|
||||
}
|
||||
|
||||
func loginAttemptKey(username string, clientKeys ...string) string {
|
||||
parts := []string{strings.ToLower(strings.TrimSpace(username))}
|
||||
for _, value := range clientKeys {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func randomDigits(count int) string {
|
||||
|
||||
@@ -2,6 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -35,7 +38,7 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
|
||||
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 {
|
||||
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err = service.Bootstrap(context.Background())
|
||||
@@ -46,3 +49,91 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
|
||||
t.Fatalf("default password leaked after change: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordPersistsAfterReopen(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dbPath := filepath.Join(root, "test.sqlite")
|
||||
cfg := &config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: dbPath,
|
||||
FailoverEnabled: 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)
|
||||
}
|
||||
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "persisted-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = store.Close()
|
||||
|
||||
reopened, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if _, ok, err := reopened.VerifyAdminPassword(context.Background(), "admin", "persisted-password"); err != nil || !ok {
|
||||
t.Fatalf("new password did not persist, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := reopened.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok {
|
||||
t.Fatalf("old password still works, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLocksAfterRepeatedFailures(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(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)
|
||||
for i := 0; i < loginMaxFails; i++ {
|
||||
if _, _, ok, err := service.Login(context.Background(), "admin", "wrong", "bad-captcha", "00000", "127.0.0.1"); err != nil || ok {
|
||||
t.Fatalf("failed login %d returned ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
}
|
||||
captcha, err := service.NewCaptcha()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.mu.Lock()
|
||||
answer := service.captchas[captcha.ID].answer
|
||||
service.mu.Unlock()
|
||||
if _, _, ok, err := service.Login(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || ok {
|
||||
t.Fatalf("locked login should fail without error, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieUsesSecureForForwardedHTTPS(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
res := httptest.NewRecorder()
|
||||
SetSessionCookieForRequest(res, req, "session-id")
|
||||
cookies := res.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected one cookie, got %d", len(cookies))
|
||||
}
|
||||
if !cookies[0].Secure {
|
||||
t.Fatalf("expected secure cookie for forwarded https: %#v", cookies[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -67,16 +68,28 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg := defaults(root)
|
||||
path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json"))
|
||||
var rawConfig []byte
|
||||
loaded := false
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Initialized = true
|
||||
rawConfig = data
|
||||
loaded = true
|
||||
}
|
||||
cfg.BaseDir = root
|
||||
cfg.ConfigPath = path
|
||||
if loaded {
|
||||
sanitizeNonPortablePaths(cfg)
|
||||
}
|
||||
applyEnv(cfg)
|
||||
normalize(root, cfg)
|
||||
if loaded && shouldRewriteRelativeConfig(rawConfig) {
|
||||
if err := Save(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -333,13 +346,110 @@ func Save(cfg *Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
persisted := cfg.relativeCopy()
|
||||
data, err := json.MarshalIndent(persisted, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.ConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *Config) relativeCopy() Config {
|
||||
next := *cfg
|
||||
base := cfg.BaseDir
|
||||
next.BaseDir = "."
|
||||
next.StorageDir = relativePath(base, cfg.StorageDir)
|
||||
next.DataDir = relativePath(base, cfg.DataDir)
|
||||
next.UpdatePublicDir = relativePath(base, cfg.UpdatePublicDir)
|
||||
next.UpdateNoticeDir = relativePath(base, cfg.UpdateNoticeDir)
|
||||
next.DownloadsDir = relativePath(base, cfg.DownloadsDir)
|
||||
next.AdminWebDir = relativePath(base, cfg.AdminWebDir)
|
||||
next.PortalWebDir = relativePath(base, cfg.PortalWebDir)
|
||||
next.SetupWebDir = relativePath(base, cfg.SetupWebDir)
|
||||
next.LegacyUpdateDir = relativePath(base, cfg.LegacyUpdateDir)
|
||||
next.LegacyFeedbackDir = relativePath(base, cfg.LegacyFeedbackDir)
|
||||
next.LegacyUpdateNoticeDir = relativePath(base, cfg.LegacyUpdateNoticeDir)
|
||||
next.Database.SQLitePath = relativePath(base, cfg.Database.SQLitePath)
|
||||
return next
|
||||
}
|
||||
|
||||
func relativePath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return value
|
||||
}
|
||||
rel, err := filepath.Rel(base, value)
|
||||
if err != nil || rel == "" {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func sanitizeNonPortablePaths(cfg *Config) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
for _, target := range []*string{
|
||||
&cfg.StorageDir,
|
||||
&cfg.DataDir,
|
||||
&cfg.UpdatePublicDir,
|
||||
&cfg.UpdateNoticeDir,
|
||||
&cfg.DownloadsDir,
|
||||
&cfg.AdminWebDir,
|
||||
&cfg.PortalWebDir,
|
||||
&cfg.SetupWebDir,
|
||||
&cfg.LegacyUpdateDir,
|
||||
&cfg.LegacyFeedbackDir,
|
||||
&cfg.LegacyUpdateNoticeDir,
|
||||
&cfg.Database.SQLitePath,
|
||||
} {
|
||||
if isWindowsAbsolutePath(*target) {
|
||||
*target = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRewriteRelativeConfig(data []byte) bool {
|
||||
var payload any
|
||||
if len(data) == 0 || json.Unmarshal(data, &payload) != nil {
|
||||
return false
|
||||
}
|
||||
return containsAbsolutePath(payload)
|
||||
}
|
||||
|
||||
func containsAbsolutePath(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return filepath.IsAbs(typed) || isWindowsAbsolutePath(typed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWindowsAbsolutePath(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 3 {
|
||||
drive := value[0]
|
||||
if ((drive >= 'A' && drive <= 'Z') || (drive >= 'a' && drive <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, `\\`)
|
||||
}
|
||||
|
||||
func absPath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavePersistsRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
cfg.Initialized = true
|
||||
cfg.ConfigPath = filepath.Join(root, "config.json")
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved Config
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.BaseDir != "." {
|
||||
t.Fatalf("BaseDir = %q, want relative dot", saved.BaseDir)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"storage_dir": saved.StorageDir,
|
||||
"data_dir": saved.DataDir,
|
||||
"update_public_dir": saved.UpdatePublicDir,
|
||||
"update_notice_dir": saved.UpdateNoticeDir,
|
||||
"downloads_dir": saved.DownloadsDir,
|
||||
"sqlite_path": saved.Database.SQLitePath,
|
||||
} {
|
||||
if filepath.IsAbs(value) || strings.Contains(value, root) {
|
||||
t.Fatalf("%s saved as absolute path %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightCreatesRuntimeDirectoriesAndNoticeIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
checks := Preflight(cfg)
|
||||
for _, path := range []string{
|
||||
cfg.UpdatePublicDir,
|
||||
cfg.UpdateNoticeDir,
|
||||
cfg.DownloadsDir,
|
||||
filepath.Join(cfg.UpdatePublicDir, "update-info.json"),
|
||||
filepath.Join(cfg.UpdatePublicDir, "media-types.json"),
|
||||
filepath.Join(cfg.UpdateNoticeDir, "total.json"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected preflight to create %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, line := range FormatPreflight(cfg, checks) {
|
||||
if strings.Contains(line, root) {
|
||||
t.Fatalf("preflight line leaked absolute base path: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRewritesAbsoluteConfigPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("YMHUT_BASE_DIR", root)
|
||||
configPath := filepath.Join(root, "config.json")
|
||||
payload := map[string]any{
|
||||
"initialized": true,
|
||||
"listen": ":33550",
|
||||
"storage_dir": filepath.Join(root, "storage"),
|
||||
"data_dir": filepath.Join(root, "data"),
|
||||
"update_public_dir": filepath.Join(root, "data", "update", "public"),
|
||||
"update_notice_dir": filepath.Join(root, "data", "update-notice"),
|
||||
"downloads_dir": filepath.Join(root, "data", "update", "public", "downloads"),
|
||||
"database": map[string]any{
|
||||
"provider": "sqlite",
|
||||
"sqlite_path": filepath.Join(root, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewritten, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(rewritten), root) {
|
||||
t.Fatalf("config still contains absolute base path: %s", string(rewritten))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import (
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
const defaultUpdateInfoJSON = `{
|
||||
"app_version": "0.0.0",
|
||||
"download_url": "",
|
||||
"update_notes": {},
|
||||
"last_update_notes": {},
|
||||
"release_notes": "",
|
||||
"release_notes_md": "",
|
||||
"last_updated": ""
|
||||
}
|
||||
`
|
||||
|
||||
const defaultMediaTypesJSON = `{
|
||||
"layout_version": "1.0.0",
|
||||
"last_updated": "",
|
||||
"ui_config": {},
|
||||
"categories": []
|
||||
}
|
||||
`
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
@@ -19,12 +38,12 @@ 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),
|
||||
checkDir("update public", cfg.UpdatePublicDir, true),
|
||||
checkDir("update notice", cfg.UpdateNoticeDir, true),
|
||||
checkDir("downloads", cfg.DownloadsDir, true),
|
||||
checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
|
||||
checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
|
||||
checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
|
||||
checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
|
||||
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
|
||||
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
|
||||
@@ -48,6 +67,37 @@ func checkDir(name, path string, create bool) Check {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
}
|
||||
|
||||
func checkNoticeIndex(name, path string) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
data := []byte("{\n \"schema_version\": 1,\n \"product\": \"YMhut Box\",\n \"versions\": []\n}\n")
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created empty notice index"}
|
||||
}
|
||||
|
||||
func checkSeedFile(name, path string, data []byte) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created default compatibility JSON"}
|
||||
}
|
||||
|
||||
func checkParent(name, path string) Check {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
@@ -116,12 +166,12 @@ func embeddedWebBuildOK(embedRoot string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func FormatPreflight(checks []Check) []string {
|
||||
func FormatPreflight(cfg *Config, 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
|
||||
line += " -> " + relativePath(cfg.BaseDir, check.Path)
|
||||
}
|
||||
if check.Message != "" {
|
||||
line += " (" + check.Message + ")"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Store) EnsureDefaultAdmin(ctx context.Context) error {
|
||||
if err := s.ensureDefaultAdminOn(s.localDB, s.localDialect); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
if err := s.ensureDefaultAdminOn(remote, remoteDialect); err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureDefaultAdminOn(conn *sql.DB, d dialect) error {
|
||||
if conn == nil {
|
||||
return errors.New("database is not available")
|
||||
}
|
||||
var count int
|
||||
if err := conn.QueryRow(d.rebind(`SELECT COUNT(*) FROM admin_users WHERE username = ?`), "admin").Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
now := Now()
|
||||
_, err := conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 0, ?, ?)`),
|
||||
"admin", passwordHash("admin"), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) VerifyAdminPassword(ctx context.Context, username, password string) (AdminUser, bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
user, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, password)
|
||||
if err == nil && (ok || user.Username != "") {
|
||||
return user, ok, nil
|
||||
}
|
||||
if err != nil {
|
||||
return user, ok, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
user, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return user, ok, err
|
||||
}
|
||||
return user, ok, nil
|
||||
}
|
||||
|
||||
func (s *Store) verifyAdminPasswordOn(conn *sql.DB, d dialect, username, password string) (AdminUser, bool, error) {
|
||||
if conn == nil {
|
||||
return AdminUser{}, false, errors.New("database is not available")
|
||||
}
|
||||
var row adminRow
|
||||
var changed int
|
||||
err := conn.QueryRow(d.rebind(`SELECT id, username, password_hash, password_changed, created_at, updated_at FROM admin_users WHERE username = ?`), username).
|
||||
Scan(&row.ID, &row.Username, &row.PasswordHash, &changed, &row.CreatedAt, &row.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AdminUser{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AdminUser{}, false, err
|
||||
}
|
||||
row.PasswordChanged = changed == 1
|
||||
user := AdminUser{ID: row.ID, Username: row.Username, PasswordChanged: row.PasswordChanged, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
return user, subtleConstantCompare(row.PasswordHash, password), nil
|
||||
}
|
||||
|
||||
func (s *Store) IsDefaultAdminPassword(ctx context.Context) (bool, error) {
|
||||
user, ok, err := s.VerifyAdminPassword(ctx, "admin", "admin")
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
return !user.PasswordChanged, nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangeAdminPassword(ctx context.Context, username, current, next string) error {
|
||||
_, err := s.ChangeAdminPasswordWithWarning(ctx, username, current, next)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, current, next string) (string, error) {
|
||||
next = strings.TrimSpace(next)
|
||||
if err := validateAdminPasswordChange(current, next); err != nil {
|
||||
return "", err
|
||||
}
|
||||
username = firstNonEmpty(strings.TrimSpace(username), "admin")
|
||||
_, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
remoteOK, remoteErr := s.verifyRemoteAdminPassword(username, current)
|
||||
if remoteErr != nil {
|
||||
s.markFailover(remoteErr)
|
||||
}
|
||||
if !remoteOK {
|
||||
return "", errors.New("当前密码不正确")
|
||||
}
|
||||
}
|
||||
hash := passwordHash(next)
|
||||
now := Now()
|
||||
if err := s.changeAdminPasswordOn(s.localDB, s.localDialect, username, hash, now, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
if err := s.changeAdminPasswordOn(remote, remoteDialect, username, hash, now, false); err != nil {
|
||||
s.markFailover(err)
|
||||
return "远端 MySQL 同步失败,密码已持久化到本地 SQLite", nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func validateAdminPasswordChange(current, next string) error {
|
||||
if next == "" {
|
||||
return errors.New("new password is required")
|
||||
}
|
||||
if len([]rune(next)) < 8 {
|
||||
return errors.New("new password must be at least 8 characters")
|
||||
}
|
||||
if strings.EqualFold(next, "admin") {
|
||||
return errors.New("new password cannot be admin")
|
||||
}
|
||||
if strings.TrimSpace(current) != "" && subtleConstantCompare(passwordHash(current), next) {
|
||||
return errors.New("new password must be different from current password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) verifyRemoteAdminPassword(username, password string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil || remote == s.localDB {
|
||||
return false, nil
|
||||
}
|
||||
_, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (s *Store) changeAdminPasswordOn(conn *sql.DB, d dialect, username, hash, updatedAt string, insertIfMissing bool) error {
|
||||
if conn == nil {
|
||||
return errors.New("database is not available")
|
||||
}
|
||||
result, err := conn.Exec(d.rebind(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`), hash, updatedAt, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows > 0 {
|
||||
return nil
|
||||
}
|
||||
if !insertIfMissing {
|
||||
return errors.New("admin user not found")
|
||||
}
|
||||
_, err = conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`), username, hash, updatedAt, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func passwordHash(password string) string {
|
||||
sum := sha256.Sum256([]byte("ymhut-unified|" + strings.TrimSpace(password)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func subtleConstantCompare(hash, password string) bool {
|
||||
expected := passwordHash(password)
|
||||
return subtleConstantTimeCompare([]byte(hash), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func subtleConstantTimeCompare(a, b []byte) int {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var v byte
|
||||
for i := range a {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 80
|
||||
}
|
||||
feedbackTotal, _ := s.countTable("feedback_tickets")
|
||||
feedbackToday, _ := s.countWhere("feedback_tickets", "created_at LIKE ?", time.Now().UTC().Format("2006-01-02")+"%")
|
||||
sourceTotal, _ := s.countTable("source_endpoints")
|
||||
sourceVisible, _ := s.countWhere("source_endpoints", "enabled = 1 AND client_visible = 1")
|
||||
releaseTotal, _ := s.countTable("release_notices")
|
||||
mailFailed, _ := s.countWhere("mail_records", "status = ?", "failed")
|
||||
statusCounts, _ := s.groupCounts("feedback_tickets", "status")
|
||||
healthCounts, _ := s.groupCounts("source_endpoints", "last_status")
|
||||
recentChecks, _ := s.RecentSourceChecks(limit)
|
||||
recentCalls, _ := s.RecentSourceCalls(limit)
|
||||
audit, _ := s.ListAuditLogs(10)
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"kpis": map[string]any{
|
||||
"feedbackTotal": feedbackTotal,
|
||||
"feedbackToday": feedbackToday,
|
||||
"sourceTotal": sourceTotal,
|
||||
"sourceVisible": sourceVisible,
|
||||
"releaseNotices": releaseTotal,
|
||||
"mailFailed": mailFailed,
|
||||
},
|
||||
"feedbackStatus": statusCounts,
|
||||
"sourceHealth": healthCounts,
|
||||
"heartbeats": recentChecks,
|
||||
"clientCalls": recentCalls,
|
||||
"database": s.Status(),
|
||||
"audit": audit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
|
||||
rows, err := s.query(`SELECT h.id, e.source_id, e.name, h.status, h.latency_ms, h.error, h.checked_at
|
||||
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
|
||||
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var sourceID, name, status, message, checkedAt string
|
||||
var latency int
|
||||
if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
|
||||
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var sourceID, status, message, client, createdAt string
|
||||
var latency int
|
||||
if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "status": status, "latencyMs": latency, "error": message, "client": client, "createdAt": createdAt})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertAudit(log AuditLog) error {
|
||||
if log.CreatedAt == "" {
|
||||
log.CreatedAt = Now()
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO audit_logs (actor, type, target, message, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sanitize(log.Actor), sanitize(log.Type), sanitize(log.Target), sanitize(log.Message), sanitize(log.IP), sanitize(log.UserAgent), log.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAuditRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs WHERE target = ? ORDER BY id DESC LIMIT ?`, target, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAuditRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) countTable(table string) (int, error) {
|
||||
if !validStatsTable(table) {
|
||||
return 0, fmt.Errorf("invalid table %q", table)
|
||||
}
|
||||
var total int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM ` + table).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Store) countWhere(table, where string, args ...any) (int, error) {
|
||||
if !validStatsTable(table) {
|
||||
return 0, fmt.Errorf("invalid table %q", table)
|
||||
}
|
||||
var total int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM `+table+` WHERE `+where, args...).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Store) groupCounts(table, column string) (map[string]int, error) {
|
||||
if !validStatsColumn(table, column) {
|
||||
return nil, fmt.Errorf("invalid group %s.%s", table, column)
|
||||
}
|
||||
rows, err := s.query(`SELECT ` + column + `, COUNT(*) FROM ` + table + ` GROUP BY ` + column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var count int
|
||||
if err := rows.Scan(&key, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
out[key] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func validStatsTable(table string) bool {
|
||||
switch table {
|
||||
case "feedback_tickets", "source_endpoints", "release_notices", "mail_records":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validStatsColumn(table, column string) bool {
|
||||
switch table + "." + column {
|
||||
case "feedback_tickets.status", "source_endpoints.last_status":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) CopySQLiteToRemote() (string, error) {
|
||||
result, err := s.ImportSQLiteToRemote()
|
||||
return result.FinishedAt, err
|
||||
}
|
||||
|
||||
func (s *Store) CopyRemoteToSQLite() (string, error) {
|
||||
result, err := s.SyncNow()
|
||||
return result.FinishedAt, err
|
||||
}
|
||||
|
||||
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 {
|
||||
err := errors.New("remote database is not configured")
|
||||
s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err)
|
||||
return SyncResult{}, err
|
||||
}
|
||||
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
||||
s.setSyncStatus(result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Store) SyncNow() (SyncResult, error) {
|
||||
s.mu.RLock()
|
||||
remote := s.remoteDB
|
||||
remoteDialect := s.remoteDialect
|
||||
local := s.localDB
|
||||
localDialect := s.localDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil {
|
||||
result := SyncResult{Direction: "remote_to_sqlite", Tables: map[string]int{}, FinishedAt: Now()}
|
||||
s.setSyncStatus(result, nil)
|
||||
return result, 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.LastSyncAt = result.FinishedAt
|
||||
s.status.LastSyncError = err.Error()
|
||||
return
|
||||
}
|
||||
s.status.LastSyncAt = result.FinishedAt
|
||||
s.status.LastSyncError = ""
|
||||
}
|
||||
|
||||
type tableSpec struct {
|
||||
Name string
|
||||
Columns []string
|
||||
Conflict []string
|
||||
}
|
||||
|
||||
var syncTables = []tableSpec{
|
||||
{"schema_migrations", []string{"version", "applied_at", "description"}, []string{"version"}},
|
||||
{"admin_users", []string{"id", "username", "password_hash", "password_changed", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_packages", []string{"id", "product", "version", "platform", "arch", "file_name", "url", "sha256", "size_bytes", "enabled", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_notices", []string{"id", "version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_notice_revisions", []string{"id", "version", "raw_json", "note", "created_by", "created_at"}, []string{"id"}},
|
||||
{"feedback_tickets", []string{"code", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "public_reply", "note", "assignee", "handled_by", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "attachment", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "summary_text", "included_files", "mail_sent", "remote_addr", "tags", "created_at", "updated_at", "last_activity_at"}, []string{"code"}},
|
||||
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
|
||||
{"feedback_attachments", []string{"id", "feedback_code", "kind", "path", "file_name", "sha256", "size_bytes", "created_at"}, []string{"id"}},
|
||||
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
|
||||
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
|
||||
{"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"}},
|
||||
{"source_categories", []string{"id", "category_id", "name", "enabled", "ui_config", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
|
||||
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
|
||||
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
||||
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "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"}},
|
||||
{"legacy_sync_jobs", []string{"id", "status", "summary", "stats_json", "started_at", "finished_at"}, []string{"id"}},
|
||||
}
|
||||
|
||||
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 {
|
||||
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Tables[table.Name] = count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
||||
rows, err := src.Query(srcDialect.rebind("SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name))
|
||||
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()
|
||||
}
|
||||
|
||||
func readPrototypeState(path string) (*state, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return nil, nil
|
||||
}
|
||||
var prototype state
|
||||
if err := json.Unmarshal(data, &prototype); err != nil {
|
||||
return nil, fmt.Errorf("existing sqlite path is not a valid sqlite database or JSON prototype: %w", err)
|
||||
}
|
||||
return &prototype, nil
|
||||
}
|
||||
|
||||
func backupPrototypeFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(data)), "{") {
|
||||
return nil
|
||||
}
|
||||
backup := path + ".json-prototype-" + time.Now().UTC().Format("20060102-150405") + ".bak"
|
||||
if err := os.WriteFile(backup, data, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) importPrototype(prototype state) error {
|
||||
for _, admin := range prototype.Admins {
|
||||
if admin.CreatedAt == "" {
|
||||
admin.CreatedAt = Now()
|
||||
}
|
||||
if admin.UpdatedAt == "" {
|
||||
admin.UpdatedAt = admin.CreatedAt
|
||||
}
|
||||
_, _ = s.exec(`INSERT INTO admin_users (id, username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
admin.ID, admin.Username, admin.PasswordHash, boolInt(admin.PasswordChanged), admin.CreatedAt, admin.UpdatedAt)
|
||||
}
|
||||
for _, item := range prototype.Feedbacks {
|
||||
_ = s.InsertFeedback(item)
|
||||
}
|
||||
for _, item := range prototype.Sources {
|
||||
_, _ = s.UpsertSource(item)
|
||||
}
|
||||
for _, item := range prototype.SourceChecks {
|
||||
_ = s.RecordSourceCheck(item.SourceID, item.Status, item.LatencyMS, item.Error)
|
||||
}
|
||||
for _, item := range prototype.SourceCalls {
|
||||
_ = s.RecordSourceCall(item)
|
||||
}
|
||||
for _, item := range prototype.AuditLogs {
|
||||
_ = s.InsertAudit(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (s *Store) InsertFeedback(item Feedback) error {
|
||||
now := Now()
|
||||
if item.Code == "" {
|
||||
item.Code = NewFeedbackCode()
|
||||
}
|
||||
if item.Status == "" {
|
||||
item.Status = "new"
|
||||
}
|
||||
if item.Category == "" {
|
||||
item.Category = normalizeCategory(item.Type)
|
||||
}
|
||||
if item.Priority == "" {
|
||||
item.Priority = normalizePriority(item.Severity)
|
||||
}
|
||||
if item.SLALevel == "" {
|
||||
item.SLALevel = defaultSLA(item.Priority)
|
||||
}
|
||||
if item.SourceChannel == "" {
|
||||
item.SourceChannel = "winui"
|
||||
}
|
||||
if item.RiskScore == 0 {
|
||||
item.RiskScore = defaultRisk(item.Priority)
|
||||
}
|
||||
if item.StatusDetail == "" {
|
||||
item.StatusDetail = "反馈已接收,等待后台处理。"
|
||||
}
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
item.LastActivityAt = now
|
||||
tagsJSON, _ := json.Marshal(normalizeTags(item.Tags))
|
||||
_, err := s.exec(`INSERT INTO feedback_tickets (
|
||||
code, title, type, severity, category, priority, contact, body, status, status_detail,
|
||||
public_reply, note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level,
|
||||
source_channel, risk_score, resolution, attachment, package_path, encrypted_package_path,
|
||||
package_sha256, plain_package_sha256, summary_text, included_files, mail_sent, remote_addr,
|
||||
tags, created_at, updated_at, last_activity_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.Code, sanitize(item.Title), sanitize(item.Type), sanitize(item.Severity), item.Category, item.Priority,
|
||||
sanitize(item.Contact), sanitizeLong(item.Body, 5000), item.Status, sanitize(item.StatusDetail),
|
||||
sanitizeLong(item.PublicReply, 3000), sanitizeLong(item.Note, 3000), sanitize(item.Assignee), sanitize(item.HandledBy),
|
||||
item.DueAt, item.ResolvedAt, item.ArchivedAt, item.SLALevel, item.SourceChannel, item.RiskScore,
|
||||
sanitizeLong(item.Resolution, 3000), item.Attachment, item.PackagePath, item.EncryptedPackagePath,
|
||||
item.PackageSha256, item.PlainPackageSha256, sanitizeLong(item.SummaryText, 6000), item.IncludedFiles,
|
||||
boolInt(item.MailSent), sanitize(item.RemoteAddr), string(tagsJSON), item.CreatedAt, item.UpdatedAt, item.LastActivityAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.PackagePath != "" {
|
||||
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "package", Path: item.PackagePath, FileName: filepath.Base(item.PackagePath), SHA256: item.PlainPackageSha256})
|
||||
}
|
||||
if item.EncryptedPackagePath != "" {
|
||||
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "encrypted_package", Path: item.EncryptedPackagePath, FileName: filepath.Base(item.EncryptedPackagePath), SHA256: item.PackageSha256})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFeedback(code string) (Feedback, error) {
|
||||
item, err := s.scanFeedbackRow(s.queryRow(feedbackSelectSQL()+` WHERE code = ?`, code))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Feedback{}, errors.New("feedback not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) GetFeedbackDetail(code string) (*FeedbackDetail, error) {
|
||||
item, err := s.GetFeedback(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments, err := s.ListFeedbackComments(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, err := s.ListFeedbackAttachments(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, _ := s.ListAuditLogsForTarget(code, 100)
|
||||
legacyEvents, _ := s.ListFeedbackEvents(code, 100)
|
||||
mailRecords, _ := s.ListMailRecords(code, 100)
|
||||
return &FeedbackDetail{Feedback: item, Comments: comments, Attachments: attachments, Events: events, LegacyEvents: legacyEvents, MailRecords: mailRecords}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbacks(limit int) ([]Feedback, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.query(feedbackSelectSQL()+` ORDER BY last_activity_at DESC, created_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFeedbackRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) {
|
||||
page, perPage = normalizePage(page, perPage)
|
||||
where, args := feedbackWhere(filters)
|
||||
var total int
|
||||
if err := s.queryRow(`SELECT COUNT(*) FROM feedback_tickets`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := ` ORDER BY last_activity_at DESC, created_at DESC`
|
||||
if filters.Sort == "oldest" {
|
||||
order = ` ORDER BY created_at ASC`
|
||||
}
|
||||
args = append(args, perPage, (page-1)*perPage)
|
||||
rows, err := s.query(feedbackSelectSQL()+where+order+` LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := scanFeedbackRows(rows)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFeedback(code, status, detail, reply string) error {
|
||||
update := FeedbackUpdate{Status: status, StatusDetail: detail, PublicReply: reply, Actor: "admin"}
|
||||
return s.UpdateFeedbackTicket(code, update)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFeedbackTicket(code string, update FeedbackUpdate) error {
|
||||
current, err := s.GetFeedback(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if update.Status == "" {
|
||||
update.Status = current.Status
|
||||
}
|
||||
if update.Category == "" {
|
||||
update.Category = current.Category
|
||||
}
|
||||
if update.Priority == "" {
|
||||
update.Priority = current.Priority
|
||||
}
|
||||
if update.SLALevel == "" {
|
||||
update.SLALevel = current.SLALevel
|
||||
}
|
||||
if update.StatusDetail == "" {
|
||||
update.StatusDetail = current.StatusDetail
|
||||
}
|
||||
if update.PublicReply == "" {
|
||||
update.PublicReply = current.PublicReply
|
||||
}
|
||||
if update.Note == "" {
|
||||
update.Note = current.Note
|
||||
}
|
||||
if update.Assignee == "" {
|
||||
update.Assignee = current.Assignee
|
||||
}
|
||||
if update.HandledBy == "" {
|
||||
update.HandledBy = current.HandledBy
|
||||
}
|
||||
if update.DueAt == "" {
|
||||
update.DueAt = current.DueAt
|
||||
}
|
||||
if update.Resolution == "" {
|
||||
update.Resolution = current.Resolution
|
||||
}
|
||||
tags := current.Tags
|
||||
if len(update.Tags) > 0 {
|
||||
tags = update.Tags
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(normalizeTags(tags))
|
||||
now := Now()
|
||||
_, err = s.exec(`UPDATE feedback_tickets SET status = ?, category = ?, priority = ?, status_detail = ?, public_reply = ?,
|
||||
note = ?, assignee = ?, handled_by = ?, due_at = ?, sla_level = ?, resolution = ?, tags = ?, updated_at = ?, last_activity_at = ?
|
||||
WHERE code = ?`,
|
||||
update.Status, update.Category, update.Priority, sanitize(update.StatusDetail), sanitizeLong(update.PublicReply, 3000),
|
||||
sanitizeLong(update.Note, 3000), sanitize(update.Assignee), sanitize(update.HandledBy), update.DueAt, update.SLALevel,
|
||||
sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code)
|
||||
if err == nil {
|
||||
_ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "反馈工单已更新"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) BulkUpdateFeedback(codes []string, update FeedbackUpdate) error {
|
||||
for _, code := range codes {
|
||||
if err := s.UpdateFeedbackTicket(code, update); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertFeedbackComment(comment FeedbackComment) (FeedbackComment, error) {
|
||||
if comment.CreatedAt == "" {
|
||||
comment.CreatedAt = Now()
|
||||
}
|
||||
id, err := s.insertID(`INSERT INTO feedback_comments (feedback_code, author, body, internal, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
comment.Code, sanitize(comment.Author), sanitizeLong(comment.Body, 3000), boolInt(comment.Internal), comment.CreatedAt)
|
||||
if err != nil {
|
||||
return FeedbackComment{}, err
|
||||
}
|
||||
comment.ID = id
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackComments(code string) ([]FeedbackComment, error) {
|
||||
rows, err := s.query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FeedbackComment{}
|
||||
for rows.Next() {
|
||||
var item FeedbackComment
|
||||
var internal int
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Author, &item.Body, &internal, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Internal = internal == 1
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertFeedbackAttachment(item FeedbackAttachment) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
if item.FileName == "" {
|
||||
item.FileName = filepath.Base(item.Path)
|
||||
}
|
||||
if item.SizeBytes == 0 {
|
||||
if info, err := os.Stat(item.Path); err == nil {
|
||||
item.SizeBytes = info.Size()
|
||||
}
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO feedback_attachments (feedback_code, kind, path, file_name, sha256, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.FeedbackCode, item.Kind, item.Path, item.FileName, item.SHA256, item.SizeBytes, item.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackAttachments(code string) ([]FeedbackAttachment, error) {
|
||||
rows, err := s.query(`SELECT id, feedback_code, kind, path, file_name, sha256, size_bytes, created_at FROM feedback_attachments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FeedbackAttachment{}
|
||||
for rows.Next() {
|
||||
var item FeedbackAttachment
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Path, &item.FileName, &item.SHA256, &item.SizeBytes, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertFeedbackEvent(item LegacyFeedbackEvent) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("feedback_events", columns, []string{"id"})),
|
||||
item.ID, sanitize(item.FeedbackCode), sanitize(item.EventType), sanitize(item.Actor), sanitize(item.FromValue), sanitize(item.ToValue), sanitizeLong(item.Message, 1000), item.CreatedAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertFeedbackTag(code, tag, createdAt string) error {
|
||||
if createdAt == "" {
|
||||
createdAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"feedback_code", "tag", "created_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("feedback_tags", columns, []string{"feedback_code", "tag"})), sanitize(code), sanitize(tag), createdAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertMailRecord(item LegacyMailRecord) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("mail_records", columns, []string{"id"})),
|
||||
item.ID, sanitize(item.FeedbackCode), sanitize(item.Kind), sanitize(item.Status), sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000),
|
||||
"", "", item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyFeedbackEvent{}
|
||||
for rows.Next() {
|
||||
var item LegacyFeedbackEvent
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.EventType, &item.Actor, &item.FromValue, &item.ToValue, &item.Message, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListMailRecords(code string, limit int) ([]LegacyMailRecord, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyMailRecord{}
|
||||
for rows.Next() {
|
||||
var item 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 {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) SaveLegacyRevision(name, raw, note, actor string) (LegacyJsonRevision, error) {
|
||||
item := LegacyJsonRevision{Name: name, Raw: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
||||
id, err := s.insertID(`INSERT INTO legacy_json_revisions (name, raw, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
item.Name, item.Raw, item.Note, item.CreatedBy, item.CreatedAt)
|
||||
if err != nil {
|
||||
return LegacyJsonRevision{}, err
|
||||
}
|
||||
item.ID = id
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLegacyRevisions(name string, limit int) ([]LegacyJsonRevision, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.query(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? ORDER BY id DESC LIMIT ?`, name, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyJsonRevision{}
|
||||
for rows.Next() {
|
||||
var item LegacyJsonRevision
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLegacyRevision(name string, id int64) (LegacyJsonRevision, error) {
|
||||
var item LegacyJsonRevision
|
||||
err := s.queryRow(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? AND id = ?`, name, id).
|
||||
Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LegacyJsonRevision{}, errors.New("revision not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package db
|
||||
|
||||
type state struct {
|
||||
Admins []adminRow `json:"admins"`
|
||||
Feedbacks []Feedback `json:"feedbacks"`
|
||||
Sources []Source `json:"sources"`
|
||||
SourceChecks []SourceCheck `json:"sourceChecks"`
|
||||
SourceCalls []SourceCall `json:"sourceCalls"`
|
||||
AuditLogs []AuditLog `json:"auditLogs"`
|
||||
NextID map[string]int64 `json:"nextId"`
|
||||
}
|
||||
|
||||
type adminRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
PasswordChanged bool `json:"passwordChanged"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DatabaseStatus struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
ConfigProvider string `json:"configProvider"`
|
||||
SQLiteReady bool `json:"sqliteReady"`
|
||||
RemoteReady bool `json:"remoteReady"`
|
||||
FailoverActive bool `json:"failoverActive"`
|
||||
LastError string `json:"lastError"`
|
||||
LastFailoverAt string `json:"lastFailoverAt"`
|
||||
LastRecoveredAt string `json:"lastRecoveredAt"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
LastSyncError string `json:"lastSyncError"`
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Direction string `json:"direction"`
|
||||
Tables map[string]int `json:"tables"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordChanged bool `json:"passwordChanged"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
Contact string `json:"contact"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Note string `json:"note"`
|
||||
Assignee string `json:"assignee"`
|
||||
HandledBy string `json:"handledBy"`
|
||||
DueAt string `json:"dueAt"`
|
||||
ResolvedAt string `json:"resolvedAt"`
|
||||
ArchivedAt string `json:"archivedAt"`
|
||||
SLALevel string `json:"slaLevel"`
|
||||
SourceChannel string `json:"sourceChannel"`
|
||||
RiskScore int `json:"riskScore"`
|
||||
Resolution string `json:"resolution"`
|
||||
Attachment string `json:"attachment"`
|
||||
PackagePath string `json:"packagePath"`
|
||||
EncryptedPackagePath string `json:"encryptedPackagePath"`
|
||||
PackageSha256 string `json:"packageSha256"`
|
||||
PlainPackageSha256 string `json:"plainPackageSha256"`
|
||||
SummaryText string `json:"summaryText"`
|
||||
IncludedFiles string `json:"includedFiles"`
|
||||
MailSent bool `json:"mailSent"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastActivityAt string `json:"lastActivityAt"`
|
||||
}
|
||||
|
||||
type FeedbackComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Internal bool `json:"internal"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type FeedbackAttachment struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
FileName string `json:"fileName"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LegacyFeedbackEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
EventType string `json:"eventType"`
|
||||
Actor string `json:"actor"`
|
||||
FromValue string `json:"fromValue"`
|
||||
ToValue string `json:"toValue"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LegacyMailRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
ToAddress string `json:"toAddress"`
|
||||
Subject string `json:"subject"`
|
||||
AttachmentPath string `json:"attachmentPath"`
|
||||
AttachmentName string `json:"attachmentName"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
SentAt string `json:"sentAt"`
|
||||
}
|
||||
|
||||
type LegacySyncJob struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
StatsJSON string `json:"statsJson"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type FeedbackDetail struct {
|
||||
Feedback
|
||||
Comments []FeedbackComment `json:"comments"`
|
||||
Attachments []FeedbackAttachment `json:"attachments"`
|
||||
Events []AuditLog `json:"events"`
|
||||
LegacyEvents []LegacyFeedbackEvent `json:"legacyEvents"`
|
||||
MailRecords []LegacyMailRecord `json:"mailRecords"`
|
||||
}
|
||||
|
||||
type FeedbackFilters struct {
|
||||
Status string
|
||||
Category string
|
||||
Priority string
|
||||
Query string
|
||||
Assignee string
|
||||
Tag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type FeedbackUpdate struct {
|
||||
Status string `json:"status"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
HandledBy string `json:"handledBy"`
|
||||
Assignee string `json:"assignee"`
|
||||
DueAt string `json:"dueAt"`
|
||||
SLALevel string `json:"slaLevel"`
|
||||
Resolution string `json:"resolution"`
|
||||
Note string `json:"note"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Actor string `json:"actor"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type ReleasePackage struct {
|
||||
ID int64 `json:"id"`
|
||||
Product string `json:"product"`
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
FileName string `json:"fileName"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ReleaseNotice struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
Channel string `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
MessageMD string `json:"messageMd"`
|
||||
ReleaseNotesMD string `json:"releaseNotesMd"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
NoticeFile string `json:"noticeFile"`
|
||||
RawJSON string `json:"rawJson"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ReleaseNoticeRevision struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
RawJSON string `json:"rawJson"`
|
||||
Note string `json:"note"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID string `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
SourceID string `json:"sourceId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Method string `json:"method"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
URLTemplate string `json:"urlTemplate"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
ProxyMode string `json:"proxyMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
CacheSeconds int `json:"cacheSeconds"`
|
||||
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientVisible bool `json:"clientVisible"`
|
||||
SupportedFormats string `json:"supportedFormats"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastLatencyMS int `json:"lastLatencyMs"`
|
||||
LastCheckedAt string `json:"lastCheckedAt"`
|
||||
LastError string `json:"lastError"`
|
||||
ConsecutiveFailure int `json:"consecutiveFailure"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SourceCheck struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID int64 `json:"sourceDbId"`
|
||||
Status string `json:"status"`
|
||||
LatencyMS int `json:"latencyMs"`
|
||||
Error string `json:"error"`
|
||||
CheckedAt string `json:"checkedAt"`
|
||||
}
|
||||
|
||||
type SourceCall struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID string `json:"sourceId"`
|
||||
Status string `json:"status"`
|
||||
LatencyMS int `json:"latencyMs"`
|
||||
Error string `json:"error"`
|
||||
Client string `json:"client"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LegacyJsonRevision struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Raw string `json:"raw"`
|
||||
Note string `json:"note"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertReleaseNotice(item ReleaseNotice) (ReleaseNotice, error) {
|
||||
now := Now()
|
||||
item.Version = strings.TrimSpace(item.Version)
|
||||
if item.Version == "" {
|
||||
return ReleaseNotice{}, errors.New("version is required")
|
||||
}
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if item.Channel == "" {
|
||||
item.Channel = "stable"
|
||||
}
|
||||
if item.NoticeFile == "" {
|
||||
item.NoticeFile = item.Version + ".json"
|
||||
}
|
||||
columns := []string{"version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}
|
||||
conn, d := s.active()
|
||||
_, err := conn.Exec(d.rebind(d.upsert("release_notices", columns, []string{"version"})),
|
||||
sanitize(item.Version), sanitize(item.Build), sanitize(item.Channel), sanitizeLong(item.Title, 500), sanitizeLong(item.Message, 4000),
|
||||
sanitizeLong(item.ReleaseNotes, 12000), sanitizeLong(item.MessageMD, 12000), sanitizeLong(item.ReleaseNotesMD, 20000),
|
||||
sanitizeLong(item.DownloadURL, 1200), sanitize(item.NoticeFile), item.RawJSON, sanitize(item.PublishedAt), item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
return ReleaseNotice{}, err
|
||||
}
|
||||
return s.GetReleaseNotice(item.Version)
|
||||
}
|
||||
|
||||
func (s *Store) GetReleaseNotice(version string) (ReleaseNotice, error) {
|
||||
item, err := scanReleaseNotice(s.queryRow(releaseNoticeSelectSQL()+` WHERE version = ?`, strings.TrimSpace(version)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ReleaseNotice{}, errors.New("release notice not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ListReleaseNotices(limit int) ([]ReleaseNotice, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(releaseNoticeSelectSQL()+` ORDER BY published_at DESC, version DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ReleaseNotice{}
|
||||
for rows.Next() {
|
||||
item, err := scanReleaseNotice(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveReleaseNoticeRevision(version, raw, note, actor string) (ReleaseNoticeRevision, error) {
|
||||
item := ReleaseNoticeRevision{Version: sanitize(version), RawJSON: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
||||
id, err := s.insertID(`INSERT INTO release_notice_revisions (version, raw_json, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
item.Version, item.RawJSON, item.Note, item.CreatedBy, item.CreatedAt)
|
||||
if err != nil {
|
||||
return ReleaseNoticeRevision{}, err
|
||||
}
|
||||
item.ID = id
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListReleaseNoticeRevisions(version string, limit int) ([]ReleaseNoticeRevision, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.query(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? ORDER BY id DESC LIMIT ?`, strings.TrimSpace(version), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ReleaseNoticeRevision{}
|
||||
for rows.Next() {
|
||||
var item ReleaseNoticeRevision
|
||||
if err := rows.Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetReleaseNoticeRevision(version string, id int64) (ReleaseNoticeRevision, error) {
|
||||
var item ReleaseNoticeRevision
|
||||
err := s.queryRow(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? AND id = ?`, strings.TrimSpace(version), id).
|
||||
Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ReleaseNoticeRevision{}, errors.New("release notice revision not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) scanFeedbackRow(scanner feedbackScanner) (Feedback, error) {
|
||||
return scanFeedback(scanner)
|
||||
}
|
||||
|
||||
func feedbackSelectSQL() string {
|
||||
return `SELECT code, title, type, severity, category, priority, contact, body, status, status_detail, public_reply,
|
||||
note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level, source_channel, risk_score, resolution,
|
||||
attachment, package_path, encrypted_package_path, package_sha256, plain_package_sha256, summary_text, included_files,
|
||||
mail_sent, remote_addr, tags, created_at, updated_at, last_activity_at FROM feedback_tickets`
|
||||
}
|
||||
|
||||
func releaseNoticeSelectSQL() string {
|
||||
return `SELECT id, version, build, channel, title, message, release_notes, message_md, release_notes_md,
|
||||
download_url, notice_file, raw_json, published_at, created_at, updated_at FROM release_notices`
|
||||
}
|
||||
|
||||
type feedbackScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanReleaseNotice(scanner interface{ Scan(dest ...any) error }) (ReleaseNotice, error) {
|
||||
var item ReleaseNotice
|
||||
err := scanner.Scan(&item.ID, &item.Version, &item.Build, &item.Channel, &item.Title, &item.Message,
|
||||
&item.ReleaseNotes, &item.MessageMD, &item.ReleaseNotesMD, &item.DownloadURL, &item.NoticeFile,
|
||||
&item.RawJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanFeedback(scanner feedbackScanner) (Feedback, error) {
|
||||
var item Feedback
|
||||
var mailSent int
|
||||
var tags string
|
||||
err := scanner.Scan(&item.Code, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact,
|
||||
&item.Body, &item.Status, &item.StatusDetail, &item.PublicReply, &item.Note, &item.Assignee, &item.HandledBy,
|
||||
&item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore,
|
||||
&item.Resolution, &item.Attachment, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256,
|
||||
&item.PlainPackageSha256, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.RemoteAddr, &tags,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.LastActivityAt)
|
||||
item.MailSent = mailSent == 1
|
||||
_ = json.Unmarshal([]byte(tags), &item.Tags)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanFeedbackRows(rows *sql.Rows) ([]Feedback, error) {
|
||||
items := []Feedback{}
|
||||
for rows.Next() {
|
||||
item, err := scanFeedback(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func sourceSelectSQL() string {
|
||||
return `SELECT id, category_id, category_name, source_id, name, description, method, api_url, url_template, thumbnail_url,
|
||||
proxy_mode, timeout_ms, retry_count, cache_seconds, check_interval_sec, enabled, client_visible, supported_formats,
|
||||
last_status, last_latency_ms, last_checked_at, last_error, consecutive_failure, created_at, updated_at FROM source_endpoints`
|
||||
}
|
||||
|
||||
func scanSourceRow(scanner sourceScanner) (Source, error) {
|
||||
return scanSource(scanner)
|
||||
}
|
||||
|
||||
type sourceScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSourceRowsCurrent(scanner sourceScanner) (Source, error) {
|
||||
return scanSource(scanner)
|
||||
}
|
||||
|
||||
func scanSource(scanner sourceScanner) (Source, error) {
|
||||
var item Source
|
||||
var enabled, visible int
|
||||
err := scanner.Scan(&item.ID, &item.CategoryID, &item.CategoryName, &item.SourceID, &item.Name, &item.Description,
|
||||
&item.Method, &item.APIURL, &item.URLTemplate, &item.ThumbnailURL, &item.ProxyMode, &item.TimeoutMS, &item.RetryCount,
|
||||
&item.CacheSeconds, &item.CheckIntervalSec, &enabled, &visible, &item.SupportedFormats, &item.LastStatus,
|
||||
&item.LastLatencyMS, &item.LastCheckedAt, &item.LastError, &item.ConsecutiveFailure, &item.CreatedAt, &item.UpdatedAt)
|
||||
item.Enabled = enabled == 1
|
||||
item.ClientVisible = visible == 1
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanAuditRows(rows *sql.Rows) ([]AuditLog, error) {
|
||||
items := []AuditLog{}
|
||||
for rows.Next() {
|
||||
var item AuditLog
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func feedbackWhere(filters FeedbackFilters) (string, []any) {
|
||||
clauses := []string{}
|
||||
args := []any{}
|
||||
if filters.Status != "" {
|
||||
clauses = append(clauses, "status = ?")
|
||||
args = append(args, filters.Status)
|
||||
}
|
||||
if filters.Category != "" {
|
||||
clauses = append(clauses, "category = ?")
|
||||
args = append(args, filters.Category)
|
||||
}
|
||||
if filters.Priority != "" {
|
||||
clauses = append(clauses, "priority = ?")
|
||||
args = append(args, filters.Priority)
|
||||
}
|
||||
if filters.Assignee != "" {
|
||||
clauses = append(clauses, "assignee = ?")
|
||||
args = append(args, filters.Assignee)
|
||||
}
|
||||
if filters.Query != "" {
|
||||
like := "%" + filters.Query + "%"
|
||||
clauses = append(clauses, "(code LIKE ? OR title LIKE ? OR contact LIKE ? OR body LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func normalizePage(page, perPage int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
|
||||
func NewFeedbackCode() string {
|
||||
var data [3]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return "FB-" + time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
return "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(data[:]))
|
||||
}
|
||||
|
||||
func Now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func sanitize(value string) string {
|
||||
return sanitizeLong(value, 1000)
|
||||
}
|
||||
|
||||
func sanitizeLong(value string, max int) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return r
|
||||
}
|
||||
if r < 32 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
runes := []rune(value)
|
||||
if max > 0 && len(runes) > max {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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 normalizeTags(tags []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, tag := range tags {
|
||||
tag = strings.ToLower(strings.Trim(strings.TrimSpace(tag), ",;#"))
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
runes := []rune(tag)
|
||||
if len(runes) > 32 {
|
||||
tag = string(runes[:32])
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProxyMode(value, category, name, url string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "server_proxy", "proxy":
|
||||
return "server_proxy"
|
||||
case "disabled":
|
||||
return "disabled"
|
||||
case "client_direct", "direct":
|
||||
return "client_direct"
|
||||
}
|
||||
haystack := strings.ToLower(category + " " + name + " " + url)
|
||||
for _, token := range []string{"ip", "weather", "location", "定位", "天气"} {
|
||||
if strings.Contains(haystack, token) {
|
||||
return "client_direct"
|
||||
}
|
||||
}
|
||||
return "client_direct"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion = "2026-06-compat-baseline"
|
||||
|
||||
func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
||||
statements := []string{}
|
||||
if d.name == "sqlite" {
|
||||
statements = append(statements,
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
)
|
||||
}
|
||||
statements = append(statements, schemaStatements(d)...)
|
||||
for _, statement := range statements {
|
||||
if _, err := conn.Exec(d.rebind(statement)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.recordSchemaVersion(conn, d)
|
||||
}
|
||||
|
||||
func schemaStatements(d dialect) []string {
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL,
|
||||
description VARCHAR(255) NOT NULL DEFAULT ''
|
||||
)`,
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id %s,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_changed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id %s,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL,
|
||||
csrf TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
|
||||
id %s,
|
||||
product TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
|
||||
id %s,
|
||||
version TEXT NOT NULL UNIQUE,
|
||||
build TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL DEFAULT 'stable',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
release_notes TEXT NOT NULL DEFAULT '',
|
||||
message_md TEXT NOT NULL DEFAULT '',
|
||||
release_notes_md TEXT NOT NULL DEFAULT '',
|
||||
download_url TEXT NOT NULL DEFAULT '',
|
||||
notice_file TEXT NOT NULL DEFAULT '',
|
||||
raw_json TEXT NOT NULL,
|
||||
published_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
|
||||
id %s,
|
||||
version TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
|
||||
code TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT '',
|
||||
contact TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
status_detail TEXT NOT NULL DEFAULT '',
|
||||
public_reply TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
assignee TEXT NOT NULL DEFAULT '',
|
||||
handled_by TEXT NOT NULL DEFAULT '',
|
||||
due_at TEXT NOT NULL DEFAULT '',
|
||||
resolved_at TEXT NOT NULL DEFAULT '',
|
||||
archived_at TEXT NOT NULL DEFAULT '',
|
||||
sla_level TEXT NOT NULL DEFAULT '',
|
||||
source_channel TEXT NOT NULL DEFAULT '',
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
resolution TEXT NOT NULL DEFAULT '',
|
||||
attachment TEXT NOT NULL DEFAULT '',
|
||||
package_path TEXT NOT NULL DEFAULT '',
|
||||
encrypted_package_path TEXT NOT NULL DEFAULT '',
|
||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
plain_package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
summary_text TEXT NOT NULL DEFAULT '',
|
||||
included_files TEXT NOT NULL DEFAULT '',
|
||||
mail_sent INTEGER NOT NULL DEFAULT 0,
|
||||
remote_addr TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_activity_at TEXT NOT NULL
|
||||
)`),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
|
||||
id %s,
|
||||
feedback_code TEXT NOT NULL,
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
internal INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
|
||||
id %s,
|
||||
feedback_code TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
|
||||
id %s,
|
||||
feedback_code TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
from_value TEXT NOT NULL DEFAULT '',
|
||||
to_value TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
`CREATE TABLE IF NOT EXISTS feedback_tags (
|
||||
feedback_code TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (feedback_code, tag)
|
||||
)`,
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
|
||||
id %s,
|
||||
feedback_code TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
to_address TEXT NOT NULL DEFAULT '',
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
plain_body TEXT NOT NULL DEFAULT '',
|
||||
html_body TEXT NOT NULL DEFAULT '',
|
||||
attachment_path TEXT NOT NULL DEFAULT '',
|
||||
attachment_name TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT NOT NULL DEFAULT ''
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
|
||||
id %s,
|
||||
category_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
ui_config TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
|
||||
id %s,
|
||||
category_id TEXT NOT NULL,
|
||||
category_name TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
method TEXT NOT NULL DEFAULT 'GET',
|
||||
api_url TEXT NOT NULL DEFAULT '',
|
||||
url_template TEXT NOT NULL DEFAULT '',
|
||||
thumbnail_url TEXT NOT NULL DEFAULT '',
|
||||
proxy_mode TEXT NOT NULL DEFAULT 'client_direct',
|
||||
timeout_ms INTEGER NOT NULL DEFAULT 8000,
|
||||
retry_count INTEGER NOT NULL DEFAULT 1,
|
||||
cache_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
check_interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
client_visible INTEGER NOT NULL DEFAULT 1,
|
||||
supported_formats TEXT NOT NULL DEFAULT '[]',
|
||||
last_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
consecutive_failure INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
|
||||
id %s,
|
||||
source_db_id BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
checked_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
|
||||
id %s,
|
||||
source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
client TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
|
||||
id %s,
|
||||
direction TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
tables_json TEXT NOT NULL DEFAULT '{}',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
|
||||
id %s,
|
||||
status TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
stats_json TEXT NOT NULL DEFAULT '{}',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id %s,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
|
||||
id %s,
|
||||
name TEXT NOT NULL,
|
||||
raw TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`, d.idType()),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id %s,
|
||||
webhook_name TEXT NOT NULL DEFAULT '',
|
||||
event TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
response_code INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
payload_sha256 TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
)`, d.idType()),
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
||||
columns := []string{"version", "applied_at", "description"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
||||
CurrentSchemaVersion,
|
||||
Now(),
|
||||
"unified-management layered monolith baseline",
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertSource(item Source) (Source, error) {
|
||||
now := Now()
|
||||
if item.SourceID == "" {
|
||||
item.SourceID = item.CategoryID + "-" + item.Name
|
||||
}
|
||||
if item.Method == "" {
|
||||
item.Method = "GET"
|
||||
}
|
||||
item.ProxyMode = normalizeProxyMode(firstNonEmpty(item.ProxyMode, "client_direct"), item.CategoryID, item.Name, item.APIURL)
|
||||
if item.URLTemplate == "" {
|
||||
item.URLTemplate = item.APIURL
|
||||
}
|
||||
if item.TimeoutMS <= 0 {
|
||||
item.TimeoutMS = 8000
|
||||
}
|
||||
if item.RetryCount <= 0 {
|
||||
item.RetryCount = 1
|
||||
}
|
||||
if item.CacheSeconds <= 0 {
|
||||
item.CacheSeconds = item.CheckIntervalSec
|
||||
}
|
||||
if item.CacheSeconds <= 0 {
|
||||
item.CacheSeconds = 300
|
||||
}
|
||||
if item.CheckIntervalSec <= 0 {
|
||||
item.CheckIntervalSec = item.CacheSeconds
|
||||
}
|
||||
if item.SupportedFormats == "" {
|
||||
item.SupportedFormats = "[]"
|
||||
}
|
||||
if item.LastStatus == "" {
|
||||
item.LastStatus = "unknown"
|
||||
}
|
||||
if item.CategoryID == "" {
|
||||
item.CategoryID = "custom"
|
||||
}
|
||||
if item.CategoryName == "" {
|
||||
item.CategoryName = item.CategoryID
|
||||
}
|
||||
_, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at)
|
||||
VALUES (?, ?, 1, '{}', ?, ?)
|
||||
ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`,
|
||||
item.CategoryID, item.CategoryName, now, now)
|
||||
conn, d := s.active()
|
||||
query := d.upsert("source_endpoints",
|
||||
[]string{"category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"},
|
||||
[]string{"source_id"})
|
||||
if _, err := conn.Exec(d.rebind(query), item.CategoryID, item.CategoryName, item.SourceID, item.Name, item.Description, item.Method, item.APIURL, item.URLTemplate, item.ThumbnailURL,
|
||||
item.ProxyMode, item.TimeoutMS, item.RetryCount, item.CacheSeconds, item.CheckIntervalSec, boolInt(item.Enabled), boolInt(item.ClientVisible), item.SupportedFormats,
|
||||
item.LastStatus, item.LastLatencyMS, item.LastCheckedAt, item.LastError, item.ConsecutiveFailure, firstNonEmpty(item.CreatedAt, now), now); err != nil {
|
||||
s.markFailover(err)
|
||||
return Source{}, err
|
||||
}
|
||||
return s.GetSourceBySourceID(item.SourceID)
|
||||
}
|
||||
|
||||
func (s *Store) GetSourceBySourceID(sourceID string) (Source, error) {
|
||||
item, err := scanSourceRow(s.queryRow(sourceSelectSQL()+` WHERE source_id = ?`, sourceID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Source{}, errors.New("source not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ListSources(includeHidden bool) ([]Source, error) {
|
||||
where := ""
|
||||
args := []any{}
|
||||
if !includeHidden {
|
||||
where = " WHERE enabled = 1 AND client_visible = 1"
|
||||
}
|
||||
rows, err := s.query(sourceSelectSQL()+where+` ORDER BY category_id ASC, name ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Source{}
|
||||
for rows.Next() {
|
||||
item, err := scanSourceRowsCurrent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CountSources() (int, error) {
|
||||
var count int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSource(sourceID string) error {
|
||||
_, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, message string) error {
|
||||
now := Now()
|
||||
_, err := s.exec(`INSERT INTO endpoint_health_checks (source_db_id, status, latency_ms, error, checked_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
sourceDBID, status, latency, sanitize(message), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "ok" {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = '', consecutive_failure = 0, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, now, sourceDBID)
|
||||
} else if status == "redirected" {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, sanitize(message), now, sourceDBID)
|
||||
} else {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = consecutive_failure + 1, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, sanitize(message), now, sourceDBID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSourceCall(call SourceCall) error {
|
||||
if call.CreatedAt == "" {
|
||||
call.CreatedAt = Now()
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO endpoint_call_logs (source_id, status, latency_ms, error, client, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
sanitize(call.SourceID), sanitize(call.Status), call.LatencyMS, sanitize(call.Error), sanitize(call.Client), call.CreatedAt)
|
||||
return err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -63,3 +64,212 @@ func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) {
|
||||
t.Fatalf("expected prototype backup, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = remote.Close()
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = dialectFor("sqlite")
|
||||
store.db = remote
|
||||
store.dialect = store.remoteDialect
|
||||
store.status.ActiveProvider = "mysql"
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("VerifyAdminPassword local priority failed, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
var description string
|
||||
if err := store.localDB.QueryRow(`SELECT description FROM schema_migrations WHERE version = ?`, CurrentSchemaVersion).Scan(&description); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if description == "" {
|
||||
t.Fatal("schema version description is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote-password.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = remote.Close()
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = dialectFor("sqlite")
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
warning, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", "new-local-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if warning == "" {
|
||||
t.Fatal("expected remote sync warning")
|
||||
}
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "new-local-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not persisted locally, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok {
|
||||
t.Fatalf("old password still works, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordAcceptsRemoteCurrentPasswordAndPersistsLocal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "remote-password.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteDialect := dialectFor("sqlite")
|
||||
if err := store.migrate(remote, remoteDialect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ensureDefaultAdminOn(remote, remoteDialect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.changeAdminPasswordOn(remote, remoteDialect, "admin", passwordHash("remote-current-password"), Now(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer remote.Close()
|
||||
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = remoteDialect
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "remote-current-password", "merged-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := store.verifyAdminPasswordOn(store.localDB, store.localDialect, "admin", "merged-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not persisted to local sqlite, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.verifyAdminPasswordOn(remote, remoteDialect, "admin", "merged-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not synced to remote, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, next := range []string{"", "short", "admin"} {
|
||||
if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", next); err == nil {
|
||||
t.Fatalf("expected password %q to be rejected", next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package feedback
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
@@ -26,8 +27,25 @@ import (
|
||||
|
||||
const PackageMagic = "YMHUTFB1"
|
||||
|
||||
const (
|
||||
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"
|
||||
ErrorServerConfig = "SERVER_CONFIG"
|
||||
)
|
||||
|
||||
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
|
||||
|
||||
type requestContextKey string
|
||||
|
||||
const duplicateContextKey requestContextKey = "ymhut.feedback.duplicate"
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
@@ -64,7 +82,7 @@ func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
if item, err := s.submitMultipart(r); err == nil {
|
||||
return item, nil
|
||||
} else if hasSignedFields(r) {
|
||||
} else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
}
|
||||
@@ -151,6 +169,7 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||
code = db.NewFeedbackCode()
|
||||
}
|
||||
if existing, err := s.store.GetFeedback(code); err == nil {
|
||||
setDuplicateSubmission(r, true)
|
||||
return existing, nil
|
||||
}
|
||||
file, _, err := r.FormFile("package")
|
||||
@@ -197,9 +216,48 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr)
|
||||
setDuplicateSubmission(r, false)
|
||||
return item, s.store.InsertFeedback(item)
|
||||
}
|
||||
|
||||
func DuplicateSubmission(r *http.Request) bool {
|
||||
duplicate, _ := r.Context().Value(duplicateContextKey).(bool)
|
||||
return duplicate
|
||||
}
|
||||
|
||||
func setDuplicateSubmission(r *http.Request, duplicate bool) {
|
||||
*r = *r.WithContext(context.WithValue(r.Context(), duplicateContextKey, duplicate))
|
||||
}
|
||||
|
||||
func LegacyError(err error) (string, int) {
|
||||
if err == nil {
|
||||
return "", http.StatusOK
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(lower, "too large"):
|
||||
return ErrorTooLarge, http.StatusRequestEntityTooLarge
|
||||
case strings.Contains(lower, "signed multipart fields") || strings.Contains(lower, "missing package"):
|
||||
return ErrorMissingField, http.StatusBadRequest
|
||||
case strings.Contains(lower, "timestamp outside"):
|
||||
return ErrorInvalidTimestamp, http.StatusBadRequest
|
||||
case strings.Contains(lower, "invalid request signature"):
|
||||
return ErrorInvalidSignature, http.StatusUnauthorized
|
||||
case strings.Contains(lower, "hash mismatch") || strings.Contains(lower, "invalid package hash"):
|
||||
return ErrorHashMismatch, http.StatusBadRequest
|
||||
case strings.Contains(lower, "encrypted package format") || strings.Contains(lower, "encrypted package is required"):
|
||||
return ErrorInvalidEncryptedPackage, http.StatusBadRequest
|
||||
case strings.Contains(lower, "message authentication failed") || strings.Contains(lower, "decrypt"):
|
||||
return ErrorDecryptFailed, http.StatusBadRequest
|
||||
case strings.Contains(lower, "payload") || strings.Contains(lower, "json"):
|
||||
return ErrorInvalidPayload, http.StatusBadRequest
|
||||
case strings.Contains(lower, "zip") || strings.Contains(lower, "package"):
|
||||
return ErrorInvalidPackage, http.StatusBadRequest
|
||||
default:
|
||||
return ErrorServerConfig, http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func hasSignedFields(r *http.Request) bool {
|
||||
if r.MultipartForm == nil {
|
||||
return false
|
||||
|
||||
@@ -18,14 +18,14 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
jobs map[string]CheckJob
|
||||
events chan Event
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
jobs map[string]CheckJob
|
||||
subscribers map[chan Event]struct{}
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -68,12 +68,12 @@ type legacySubcategory struct {
|
||||
|
||||
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{}),
|
||||
jobs: map[string]CheckJob{},
|
||||
events: make(chan Event, 32),
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
stop: make(chan struct{}),
|
||||
jobs: map[string]CheckJob{},
|
||||
subscribers: map[chan Event]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +296,20 @@ func (s *Service) CheckJob(id string) (CheckJob, bool) {
|
||||
return item, ok
|
||||
}
|
||||
|
||||
func (s *Service) Events() <-chan Event {
|
||||
return s.events
|
||||
func (s *Service) SubscribeEvents() (<-chan Event, func()) {
|
||||
ch := make(chan Event, 16)
|
||||
s.mu.Lock()
|
||||
s.subscribers[ch] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
unsubscribe := func() {
|
||||
s.mu.Lock()
|
||||
if _, ok := s.subscribers[ch]; ok {
|
||||
delete(s.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
func (s *Service) runCheckJob(ctx context.Context, id string, items []db.Source) {
|
||||
@@ -445,9 +457,13 @@ func (s *Service) updateJob(id string, mutate func(*CheckJob)) {
|
||||
|
||||
func (s *Service) emit(kind string, data map[string]any) {
|
||||
event := Event{Type: kind, Data: data}
|
||||
select {
|
||||
case s.events <- event:
|
||||
default:
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for ch := range s.subscribers {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
@@ -57,6 +58,67 @@ func TestCheckOneTreatsRedirectToOKAsRedirected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueCheckAllUsesBackgroundContext(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
if _, err := store.UpsertSource(db.Source{
|
||||
CategoryID: "test",
|
||||
CategoryName: "Test",
|
||||
SourceID: "slow-ok",
|
||||
Name: "Slow OK",
|
||||
Method: "GET",
|
||||
APIURL: server.URL,
|
||||
TimeoutMS: 1000,
|
||||
CheckIntervalSec: 300,
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := service.QueueCheckAll()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
current, ok := service.CheckJob(job.ID)
|
||||
if ok && current.Status == "completed" {
|
||||
if current.Stats["ok"] != 1 {
|
||||
t.Fatalf("stats = %#v, want one ok", current.Stats)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job did not complete: %#v", job)
|
||||
}
|
||||
|
||||
func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) {
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
eventsA, unsubscribeA := service.SubscribeEvents()
|
||||
defer unsubscribeA()
|
||||
eventsB, unsubscribeB := service.SubscribeEvents()
|
||||
defer unsubscribeB()
|
||||
|
||||
service.emit("source_check.completed", map[string]any{"jobId": "demo"})
|
||||
|
||||
assertEvent := func(name string, events <-chan Event) {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "source_check.completed" || event.Data["jobId"] != "demo" {
|
||||
t.Fatalf("%s received unexpected event: %#v", name, event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("%s did not receive broadcast event", name)
|
||||
}
|
||||
}
|
||||
assertEvent("subscriber A", eventsA)
|
||||
assertEvent("subscriber B", eventsB)
|
||||
}
|
||||
|
||||
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -52,11 +52,11 @@ func (s *Service) run(ctx context.Context, dryRun bool) 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,
|
||||
"legacyUpdateDir": s.displayPath(s.cfg.LegacyUpdateDir),
|
||||
"legacyFeedbackDir": s.displayPath(s.cfg.LegacyFeedbackDir),
|
||||
"legacyUpdateNoticeDir": s.displayPath(s.cfg.LegacyUpdateNoticeDir),
|
||||
"updatePublicDir": s.displayPath(s.cfg.UpdatePublicDir),
|
||||
"updateNoticeDir": s.displayPath(s.cfg.UpdateNoticeDir),
|
||||
},
|
||||
Stats: map[string]int{},
|
||||
Started: db.Now(),
|
||||
@@ -84,6 +84,17 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) displayPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
rel, err := filepath.Rel(s.cfg.BaseDir, path)
|
||||
if err != nil || rel == "" {
|
||||
return path
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func (s *Service) previewPath(result *Result, key, path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/legacy"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
"ymhut-box/server/unified-management/internal/releases"
|
||||
)
|
||||
|
||||
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/packages":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(256 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
|
||||
FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
|
||||
Version: req.FormValue("version"),
|
||||
Platform: req.FormValue("platform"),
|
||||
Arch: req.FormValue("arch"),
|
||||
Channel: req.FormValue("channel"),
|
||||
Notes: req.FormValue("notes"),
|
||||
UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
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":
|
||||
job := r.sources.QueueCheckAll()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
|
||||
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
|
||||
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
|
||||
if job, ok := r.sources.CheckJob(jobID); ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
)
|
||||
|
||||
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) handleAdminEvents(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
events, unsubscribe := r.sources.SubscribeEvents()
|
||||
defer unsubscribe()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeSSE(w, event.Type, event.Data)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result, "finishedAt": result.FinishedAt})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
)
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
type legacyFeedbackStatusDTO 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"`
|
||||
}
|
||||
|
||||
func legacyFeedbackStatus(item db.Feedback, duplicate bool) legacyFeedbackStatusDTO {
|
||||
reply := strings.TrimSpace(item.PublicReply)
|
||||
return legacyFeedbackStatusDTO{
|
||||
OK: true,
|
||||
Code: item.Code,
|
||||
Status: firstNonEmpty(item.Status, "new"),
|
||||
StatusLabel: feedbackStatusLabel(item.Status),
|
||||
StatusDetail: item.StatusDetail,
|
||||
Category: item.Category,
|
||||
Priority: item.Priority,
|
||||
HasReply: reply != "",
|
||||
Reply: reply,
|
||||
ReceivedAt: item.CreatedAt,
|
||||
UpdatedAt: firstNonEmpty(item.LastActivityAt, item.UpdatedAt, item.CreatedAt),
|
||||
MailSent: item.MailSent,
|
||||
Duplicate: duplicate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/feedback"
|
||||
)
|
||||
|
||||
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 {
|
||||
code, status := feedback.LegacyError(err)
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.rejected", Target: "feedback", Message: "旧反馈提交失败:" + localizedErrorMessage(code, err.Error()), IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeError(w, status, code, 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, legacyFeedbackStatus(item, feedback.DuplicateSubmission(req)))
|
||||
}
|
||||
|
||||
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
|
||||
code := feedback.NormalizeCode(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, legacyFeedbackStatus(item, false))
|
||||
}
|
||||
|
||||
func feedbackStatusLabel(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "processing", "in_progress":
|
||||
return "处理中"
|
||||
case "closed", "resolved", "done":
|
||||
return "已关闭"
|
||||
case "rejected":
|
||||
return "已驳回"
|
||||
default:
|
||||
return "已接收"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 writeSSE(w http.ResponseWriter, event string, payload any) {
|
||||
data, _ := json.Marshal(payload)
|
||||
_, _ = w.Write([]byte("event: " + event + "\n"))
|
||||
_, _ = w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
}
|
||||
|
||||
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": localizedErrorMessage(code, message)})
|
||||
}
|
||||
|
||||
func localizedErrorMessage(code, message string) string {
|
||||
raw := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(raw)
|
||||
exact := map[string]string{
|
||||
"current password is invalid": "当前密码不正确",
|
||||
"new password is required": "新密码不能为空",
|
||||
"new password must be at least 8 characters": "新密码至少需要 8 位",
|
||||
"new password cannot be admin": "新密码不能为 admin",
|
||||
"new password must be different from current password": "新密码不能与当前密码相同",
|
||||
"invalid password or captcha": "密码或验证码不正确",
|
||||
"login required": "需要登录后继续操作",
|
||||
"csrf token required": "页面安全令牌已失效,请刷新后重试",
|
||||
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
|
||||
"code is required": "缺少反馈编号",
|
||||
"revisionid is required": "请选择要恢复的历史版本",
|
||||
"post required": "该操作需要使用 POST 请求",
|
||||
"get required": "该操作需要使用 GET 请求",
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到心跳检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
"source api_url is empty": "接口地址不能为空",
|
||||
"database is not available": "数据库当前不可用",
|
||||
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
|
||||
"mysql connection is required": "请填写 MySQL 连接信息",
|
||||
"sqlite path is required": "请填写 SQLite 路径",
|
||||
"mysql_dsn is required": "请填写 MySQL DSN",
|
||||
"release notices are not configured": "版本日志功能尚未配置",
|
||||
"legacy sync service is not configured": "旧项目同步服务尚未配置",
|
||||
}
|
||||
if translated, ok := exact[lower]; ok {
|
||||
return translated
|
||||
}
|
||||
byCode := map[string]string{
|
||||
"UNAUTHORIZED": "需要登录后继续操作",
|
||||
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||
"DATABASE_TEST_FAILED": "数据库连接测试失败",
|
||||
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
|
||||
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
|
||||
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
|
||||
"LEGACY_VALIDATE_FAILED": "兼容 JSON 校验失败",
|
||||
"LEGACY_RESTORE_FAILED": "兼容 JSON 恢复失败",
|
||||
"NOTICE_SAVE_FAILED": "版本日志保存失败",
|
||||
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
|
||||
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
|
||||
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
|
||||
"SOURCE_SAVE_FAILED": "接口源保存失败",
|
||||
"CHECK_FAILED": "接口健康检测失败",
|
||||
"SYNC_FAILED": "同步操作失败",
|
||||
"FORBIDDEN": "没有权限执行该操作",
|
||||
"METHOD_NOT_ALLOWED": "请求方法不正确",
|
||||
"FILE_REQUIRED": "请选择要上传的文件",
|
||||
"CHECK_JOB_NOT_FOUND": "未找到心跳检测任务",
|
||||
"SSE_UNSUPPORTED": "当前运行环境不支持实时事件流",
|
||||
"SOURCES_FAILED": "接口源数据加载失败",
|
||||
"ENDPOINTS_FAILED": "客户端接口数据加载失败",
|
||||
"DASHBOARD_FAILED": "仪表盘数据加载失败",
|
||||
"AUDIT_FAILED": "审计日志加载失败",
|
||||
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
|
||||
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
|
||||
"NOTICE_NOT_FOUND": "未找到版本日志",
|
||||
"NOTICES_FAILED": "版本日志加载失败",
|
||||
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
|
||||
"SOURCE_CALL_FAILED": "接口调用状态上报失败",
|
||||
"IMPORT_FAILED": "导入失败",
|
||||
"PATH_FAILED": "路径解析失败",
|
||||
"INVALID_UPLOAD": "上传内容不正确",
|
||||
"BOOTSTRAP_FAILED": "后台初始化信息加载失败",
|
||||
"CAPTCHA_FAILED": "验证码加载失败",
|
||||
"TOO_LARGE": "反馈包过大",
|
||||
"MISSING_FIELD": "缺少旧反馈提交字段",
|
||||
"INVALID_TIMESTAMP": "反馈提交时间已过期",
|
||||
"INVALID_SIGNATURE": "反馈签名校验失败",
|
||||
"INVALID_PACKAGE": "反馈包格式不正确",
|
||||
"INVALID_ENCRYPTED_PACKAGE": "反馈加密包格式不正确",
|
||||
"DECRYPT_FAILED": "反馈包解密失败",
|
||||
"HASH_MISMATCH": "反馈包哈希校验失败",
|
||||
"SERVER_CONFIG": "反馈服务配置异常",
|
||||
}
|
||||
if translated, ok := byCode[code]; ok {
|
||||
if raw == "" || strings.EqualFold(raw, code) {
|
||||
return translated
|
||||
}
|
||||
return translated + ":" + raw
|
||||
}
|
||||
if raw == "" {
|
||||
return "操作失败"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
@@ -1,29 +1,20 @@
|
||||
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 {
|
||||
@@ -169,16 +160,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
if body.Username == "" {
|
||||
body.Username = "admin"
|
||||
}
|
||||
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha)
|
||||
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
|
||||
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"))
|
||||
writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(w, sessionID)
|
||||
auth.SetSessionCookieForRequest(w, req, sessionID)
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
||||
}
|
||||
@@ -209,854 +200,3 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
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) handleAdminEvents(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
events := r.sources.Events()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case event := <-events:
|
||||
writeSSE(w, event.Type, event.Data)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/packages":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(256 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
|
||||
FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
|
||||
Version: req.FormValue("version"),
|
||||
Platform: req.FormValue("platform"),
|
||||
Arch: req.FormValue("arch"),
|
||||
Channel: req.FormValue("channel"),
|
||||
Notes: req.FormValue("notes"),
|
||||
UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
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":
|
||||
job := r.sources.QueueCheckAll()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
|
||||
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
|
||||
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
|
||||
if job, ok := r.sources.CheckJob(jobID); ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
|
||||
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 writeSSE(w http.ResponseWriter, event string, payload any) {
|
||||
data, _ := json.Marshal(payload)
|
||||
_, _ = w.Write([]byte("event: " + event + "\n"))
|
||||
_, _ = w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/auth"
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -25,7 +38,7 @@ 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"} {
|
||||
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/update-info", "/tool-status.json", "/tool-status", "/media-types.json", "/media-types", "/modules.json", "/modules", "/api/modules"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
@@ -39,6 +52,63 @@ func TestCompatibilityRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPublicContractFields(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
cases := []struct {
|
||||
Path string
|
||||
RequiredKeys []string
|
||||
}{
|
||||
{Path: "/update-info.json", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
|
||||
{Path: "/update-info", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
|
||||
{Path: "/tool-status.json", RequiredKeys: []string{"ok"}},
|
||||
{Path: "/tool-status", RequiredKeys: []string{"ok"}},
|
||||
{Path: "/modules.json", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/modules", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/api/modules", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/media-types.json", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
|
||||
{Path: "/media-types", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.Path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", tc.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", tc.Path, err)
|
||||
}
|
||||
assertJSONKeys(t, tc.Path, payload, tc.RequiredKeys)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/downloads/fixture.txt", nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("download returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(res.Body.String()) != "download fixture" {
|
||||
t.Fatalf("unexpected download body: %q", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRejectsPathEscape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/downloads/../update-info.json", "/downloads/%2e%2e/update-info.json", "/downloads/foo\\bar.exe"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden && res.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s returned %d, want forbidden or not found: %s", path, res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -66,6 +136,193 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackStatusDTOContract(t *testing.T) {
|
||||
payload := legacyFeedbackStatus(db.Feedback{
|
||||
Code: "FB-20260626-ABCDEF",
|
||||
Status: "processing",
|
||||
StatusDetail: "公开进度",
|
||||
Category: "issue",
|
||||
Priority: "normal",
|
||||
PublicReply: "公开回复",
|
||||
Note: "内部备注",
|
||||
Assignee: "owner",
|
||||
HandledBy: "admin",
|
||||
Attachment: "private.zip",
|
||||
PackagePath: "storage/feedback/private.zip",
|
||||
EncryptedPackagePath: "storage/feedback/private.ymfb",
|
||||
MailSent: true,
|
||||
CreatedAt: "2026-06-26T00:00:00Z",
|
||||
UpdatedAt: "2026-06-26T00:10:00Z",
|
||||
LastActivityAt: "2026-06-26T00:20:00Z",
|
||||
}, true)
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONKeys(t, "legacy feedback status", out, []string{"ok", "code", "status", "statusLabel", "statusDetail", "category", "priority", "hasReply", "reply", "receivedAt", "updatedAt", "mailSent", "duplicate"})
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "legacyEvents", "mailRecords", "path", "attachment", "packagePath", "encryptedPackagePath", "packageSha256", "plainPackageSha256"} {
|
||||
if _, ok := out[privateKey]; ok {
|
||||
t.Fatalf("legacy DTO leaked private key %q: %#v", privateKey, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackPublicStatusShape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"title":"旧版反馈","type":"issue","severity":"normal","body":"客户端反馈内容"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var submitted map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ := submitted["code"].(string)
|
||||
if code == "" || submitted["statusLabel"] == nil || submitted["feedback"] != nil {
|
||||
t.Fatalf("unexpected submit payload: %#v", submitted)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/?api=status&code="+code, nil)
|
||||
res = httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["code"] != code || status["statusLabel"] == nil || status["feedback"] != nil {
|
||||
t.Fatalf("unexpected status payload: %#v", status)
|
||||
}
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachment", "attachments", "path", "packagePath", "encryptedPackagePath", "events", "legacyEvents", "mailRecords", "packageSha256", "plainPackageSha256"} {
|
||||
if _, ok := status[privateKey]; ok {
|
||||
t.Fatalf("status leaked private key %q: %#v", privateKey, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackMultipartFallback(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("subject", "Multipart legacy feedback")
|
||||
_ = writer.WriteField("category", "issue")
|
||||
_ = writer.WriteField("priority", "normal")
|
||||
_ = writer.WriteField("email", "user@example.com")
|
||||
_ = writer.WriteField("message", "Submitted by an old multipart client.")
|
||||
if part, err := writer.CreateFormFile("ignored", "note.txt"); err == nil {
|
||||
_, _ = io.WriteString(part, "not signed, should fall back")
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("multipart submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["code"] == "" || payload["feedback"] != nil {
|
||||
t.Fatalf("unexpected multipart payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackSignedEncryptedMultipartRoute(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
plain := routeZipBytes(t, map[string]string{
|
||||
"feedback.json": `{"request":{"title":"Signed route feedback","type":"issue","severity":"major","contact":"user@example.com","body":"Signed package body."}}`,
|
||||
"summary.txt": "signed route summary",
|
||||
})
|
||||
encrypted := routeEncryptPackage(t, plain, "ymhut-box-feedback-package-v1")
|
||||
encryptedHash := routeSHA256Hex(encrypted)
|
||||
plainHash := routeSHA256Hex(plain)
|
||||
payloadData, err := json.Marshal(map[string]any{
|
||||
"feedbackCode": "FB-20260626-ABC123",
|
||||
"title": "Signed route feedback",
|
||||
"type": "issue",
|
||||
"severity": "major",
|
||||
"contact": "user@example.com",
|
||||
"bodyLength": 20,
|
||||
"packageEncrypted": true,
|
||||
"encryption": feedback.PackageMagic,
|
||||
"packageBytes": len(encrypted),
|
||||
"packageSha256": encryptedHash,
|
||||
"plainPackageBytes": len(plain),
|
||||
"plainPackageSha256": plainHash,
|
||||
"createdAt": "2026-06-26T00:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(payloadData)
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("payload", payload)
|
||||
_ = writer.WriteField("timestamp", timestamp)
|
||||
_ = writer.WriteField("nonce", "route-test")
|
||||
_ = writer.WriteField("packageSha256", encryptedHash)
|
||||
_ = writer.WriteField("signature", feedback.SignWithKey("ymhut-box-feedback-client-v1", timestamp, "route-test", 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(http.MethodPost, "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("signed multipart submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var submitted map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submitted["code"] != "FB-20260626-ABC123" || submitted["duplicate"] != nil {
|
||||
t.Fatalf("unexpected signed submit payload: %#v", submitted)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/?api=status&code=FB-20260626-ABC123", nil)
|
||||
res = httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("signed status returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["code"] != "FB-20260626-ABC123" || status["statusLabel"] == nil || status["reply"] == nil {
|
||||
t.Fatalf("unexpected signed status payload: %#v", status)
|
||||
}
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "mailRecords", "packagePath", "encryptedPackagePath", "path"} {
|
||||
if _, ok := status[privateKey]; ok {
|
||||
t.Fatalf("signed status leaked private key %q: %#v", privateKey, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -91,6 +348,23 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/admin/system", "/admin/database", "/admin/health", "/admin/settings", "/admin/audit"} {
|
||||
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())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
|
||||
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles []string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
@@ -100,6 +374,15 @@ func containsAny(value string, needles []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func assertJSONKeys(t *testing.T, label string, payload map[string]any, keys []string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
if _, ok := payload[key]; !ok {
|
||||
t.Fatalf("%s missing key %q: %#v", label, key, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNoticesRoutes(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -136,6 +419,129 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWriteRequiresCSRF(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
session, _, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/sources/check", bytes.NewBufferString(`{}`))
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden without csrf, got %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func loginForTest(handler http.Handler) (string, string, error) {
|
||||
captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil)
|
||||
captchaRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(captchaRes, captchaReq)
|
||||
if captchaRes.Code != http.StatusOK {
|
||||
return "", "", errors.New(captchaRes.Body.String())
|
||||
}
|
||||
var captchaPayload struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.Unmarshal(captchaRes.Body.Bytes(), &captchaPayload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
answer, err := readTestCaptcha(captchaPayload.Image)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"captchaId": captchaPayload.CaptchaID,
|
||||
"captcha": answer,
|
||||
})
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(loginRes, loginReq)
|
||||
if loginRes.Code != http.StatusOK {
|
||||
return "", "", errors.New(loginRes.Body.String())
|
||||
}
|
||||
var loginPayload struct {
|
||||
OK bool `json:"ok"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRes.Body.Bytes(), &loginPayload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !loginPayload.OK {
|
||||
return "", "", errors.New(loginPayload.Message)
|
||||
}
|
||||
for _, cookie := range loginRes.Result().Cookies() {
|
||||
if cookie.Name == auth.SessionCookie {
|
||||
return cookie.Value, loginPayload.CSRFToken, nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.New("session cookie not set")
|
||||
}
|
||||
|
||||
func readTestCaptcha(dataURL string) (string, error) {
|
||||
const prefix = "data:image/png;base64,"
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var builder strings.Builder
|
||||
for index := 0; index < 5; index++ {
|
||||
x := 18 + index*32
|
||||
y := 13
|
||||
mask := [7]bool{
|
||||
isCaptchaInk(img.At(x+11, y+2)),
|
||||
isCaptchaInk(img.At(x+20, y+12)),
|
||||
isCaptchaInk(img.At(x+20, y+28)),
|
||||
isCaptchaInk(img.At(x+11, y+34)),
|
||||
isCaptchaInk(img.At(x+2, y+28)),
|
||||
isCaptchaInk(img.At(x+2, y+12)),
|
||||
isCaptchaInk(img.At(x+11, y+18)),
|
||||
}
|
||||
digit := -1
|
||||
for candidate, segments := range testCaptchaSegments {
|
||||
if segments == mask {
|
||||
digit = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if digit < 0 {
|
||||
return "", errors.New("captcha digit could not be read")
|
||||
}
|
||||
builder.WriteByte(byte('0' + digit))
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func isCaptchaInk(colorValue color.Color) bool {
|
||||
r, g, b, _ := colorValue.RGBA()
|
||||
return r>>8 < 80 && g>>8 < 100 && b>>8 < 130
|
||||
}
|
||||
|
||||
var testCaptchaSegments = [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 testRouter(t *testing.T) (http.Handler, func()) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
@@ -181,6 +587,9 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}},
|
||||
}},
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(public, "downloads", "fixture.txt"), []byte("download fixture\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
|
||||
"schema_version": 1,
|
||||
"latest_version": "2.0.0",
|
||||
@@ -190,15 +599,20 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
})
|
||||
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,
|
||||
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,
|
||||
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"),
|
||||
@@ -206,6 +620,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
HotSyncEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
},
|
||||
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
@@ -235,6 +650,50 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
return handler, func() { _ = store.Close() }
|
||||
}
|
||||
|
||||
func routeZipBytes(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 routeEncryptPackage(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(feedback.PackageMagic))
|
||||
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
|
||||
tag := sealed[len(sealed)-gcm.Overhead():]
|
||||
out := []byte(feedback.PackageMagic)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, tag...)
|
||||
out = append(out, ciphertext...)
|
||||
return out
|
||||
}
|
||||
|
||||
func routeSHA256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func mustWriteJSON(t *testing.T, path string, payload any) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
@@ -68,8 +68,8 @@ 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,
|
||||
"baseDir": ".",
|
||||
"configPath": relativeToBase(r.cfg.BaseDir, r.cfg.ConfigPath),
|
||||
"defaults": map[string]any{
|
||||
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
||||
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,33 @@ if (-not $SkipFrontend) {
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||
|
||||
function Copy-IfExists {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
if (Test-Path $Source) {
|
||||
if (Test-Path $Destination) {
|
||||
Remove-Item -Recurse -Force $Destination
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $Destination -Parent) | Out-Null
|
||||
Copy-Item -Recurse -Force $Source $Destination
|
||||
}
|
||||
}
|
||||
|
||||
$RepoRoot = Resolve-Path (Join-Path $Root "..\..")
|
||||
$DataUpdatePublic = Join-Path $Out "data\update\public"
|
||||
$DataUpdateNotice = Join-Path $Out "data\update-notice"
|
||||
New-Item -ItemType Directory -Force -Path $DataUpdatePublic | Out-Null
|
||||
Copy-IfExists (Join-Path $RepoRoot "update-notice") $DataUpdateNotice
|
||||
foreach ($Name in @("update-info.json", "media-types.json", "tool-status.json", "modules.json")) {
|
||||
$Source = Join-Path $RepoRoot "server\update\public\$Name"
|
||||
if (Test-Path $Source) {
|
||||
Copy-Item -Force $Source (Join-Path $DataUpdatePublic $Name)
|
||||
}
|
||||
}
|
||||
Copy-IfExists (Join-Path $RepoRoot "server\update\public\downloads") (Join-Path $DataUpdatePublic "downloads")
|
||||
|
||||
$Targets = @(
|
||||
@{ GOOS = "windows"; GOARCH = "amd64"; Name = "ymhut-unified-management-windows-amd64.exe" },
|
||||
@{ GOOS = "linux"; GOARCH = "amd64"; Name = "ymhut-unified-management-linux-amd64" },
|
||||
|
||||
@@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="$ROOT/$OUT_DIR"
|
||||
|
||||
if [[ "$SKIP_FRONTEND" != "1" ]]; then
|
||||
for app in admin portal; do
|
||||
for app in admin portal setup; do
|
||||
web_dir="$ROOT/web/$app"
|
||||
if [[ ! -d "$web_dir/node_modules" ]]; then
|
||||
(cd "$web_dir" && npm install)
|
||||
@@ -19,6 +19,28 @@ fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
copy_if_exists() {
|
||||
local source="$1"
|
||||
local target="$2"
|
||||
if [[ -e "$source" ]]; then
|
||||
rm -rf "$target"
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -R "$source" "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$ROOT/../.." && pwd)"
|
||||
DATA_UPDATE_PUBLIC="$OUT/data/update/public"
|
||||
DATA_UPDATE_NOTICE="$OUT/data/update-notice"
|
||||
mkdir -p "$DATA_UPDATE_PUBLIC"
|
||||
copy_if_exists "$REPO_ROOT/update-notice" "$DATA_UPDATE_NOTICE"
|
||||
for name in update-info.json media-types.json tool-status.json modules.json; do
|
||||
if [[ -f "$REPO_ROOT/server/update/public/$name" ]]; then
|
||||
cp -f "$REPO_ROOT/server/update/public/$name" "$DATA_UPDATE_PUBLIC/$name"
|
||||
fi
|
||||
done
|
||||
copy_if_exists "$REPO_ROOT/server/update/public/downloads" "$DATA_UPDATE_PUBLIC/downloads"
|
||||
|
||||
build_target() {
|
||||
local goos="$1"
|
||||
local goarch="$2"
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } 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";
|
||||
import SystemView from "./views/SystemView.vue";
|
||||
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
||||
import { createAuthStore } from "./stores/auth";
|
||||
import { createDashboardStore } from "./stores/dashboard";
|
||||
import { createFeedbackStore } from "./stores/feedback";
|
||||
import { createLegacyStore, type LegacyName } from "./stores/legacy";
|
||||
import { createReleaseStore } from "./stores/releases";
|
||||
import { createSourceStore } from "./stores/sources";
|
||||
import { createSystemStore } from "./stores/system";
|
||||
|
||||
type LegacyName = "update-info" | "media-types";
|
||||
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
||||
|
||||
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
|
||||
type Captcha = {
|
||||
@@ -50,7 +52,6 @@ type RouteItem = {
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
@@ -61,62 +62,21 @@ let refreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let events: EventSource | null = null;
|
||||
|
||||
const captcha = ref<Captcha | null>(null);
|
||||
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = 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 authStore = createAuthStore();
|
||||
const dashboardStore = createDashboardStore();
|
||||
const feedbackStore = createFeedbackStore();
|
||||
const releaseStore = createReleaseStore();
|
||||
const legacyStore = createLegacyStore();
|
||||
const sourceStore = createSourceStore();
|
||||
const systemStore = createSystemStore();
|
||||
|
||||
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; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
});
|
||||
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
|
||||
const { dashboard, sourceCheckJobs } = dashboardStore;
|
||||
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
|
||||
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
||||
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore;
|
||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||
const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
@@ -126,10 +86,7 @@ const routes: RouteItem[] = [
|
||||
{ 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 },
|
||||
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
|
||||
];
|
||||
|
||||
const navGroups = [
|
||||
@@ -137,17 +94,7 @@ const navGroups = [
|
||||
{ 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 },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||
@@ -167,6 +114,7 @@ const visibleEndpointCount = computed(() => endpoints.value.filter((item) => ite
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
|
||||
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
||||
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
|
||||
|
||||
const heartbeatOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
@@ -261,6 +209,9 @@ const viewContext = computed(() => ({
|
||||
copyEndpointToSource,
|
||||
database: database.value,
|
||||
databaseForm,
|
||||
databaseLastSync: databaseLastSync.value,
|
||||
databaseSyncDirectionLabel,
|
||||
databaseSyncTableCount,
|
||||
endpointStatus,
|
||||
endpoints: endpoints.value,
|
||||
feedbackFilters,
|
||||
@@ -280,6 +231,7 @@ const viewContext = computed(() => ({
|
||||
legacyDocuments,
|
||||
legacyDrafts,
|
||||
legacySync: legacySync.value,
|
||||
legacySyncMode: legacySyncMode.value,
|
||||
loadAudit,
|
||||
loadFeedbacks,
|
||||
navigate,
|
||||
@@ -291,7 +243,6 @@ const viewContext = computed(() => ({
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
removeItem,
|
||||
quickActions,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
releases: releases.value,
|
||||
@@ -309,6 +260,8 @@ const viewContext = computed(() => ({
|
||||
sourceDraft,
|
||||
statusTone,
|
||||
syncDatabase,
|
||||
systemTab: systemTab.value,
|
||||
setSystemTab,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
updateLegacyRawFromForm,
|
||||
@@ -322,21 +275,26 @@ const viewContext = computed(() => ({
|
||||
}));
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) 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;
|
||||
return adminFetch<T>(target, init, { csrf: csrf.value });
|
||||
}
|
||||
|
||||
function uploadWithProgress<T>(target: string, form: FormData, onProgress: (loaded: number, total: number) => void): Promise<T> {
|
||||
return uploadAdminFile<T>(target, form, { csrf: csrf.value }, (progress) => onProgress(progress.loaded, progress.total));
|
||||
}
|
||||
|
||||
function normalizeAdminPath(value: string) {
|
||||
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
|
||||
if (value === "/") return "/admin/dashboard";
|
||||
if (["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(value)) return "/admin/system";
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSystemTab(value: unknown): SystemTab {
|
||||
const tab = Array.isArray(value) ? value[0] : value;
|
||||
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
|
||||
return "database";
|
||||
}
|
||||
|
||||
function navigate(next: string) {
|
||||
if (currentPath.value === next) {
|
||||
void load();
|
||||
@@ -345,6 +303,10 @@ function navigate(next: string) {
|
||||
void router.push(next);
|
||||
}
|
||||
|
||||
function setSystemTab(tab: SystemTab) {
|
||||
void router.replace({ path: "/admin/system", query: tab === "database" ? {} : { tab } });
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||
}
|
||||
@@ -362,9 +324,15 @@ async function guarded(task: () => Promise<void>) {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const message = toChineseError(rawMessage);
|
||||
setToast(message, "error");
|
||||
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||
if (isAuthError(rawMessage, message)) {
|
||||
csrf.value = "";
|
||||
sessionStorage.removeItem("ymhut.csrf");
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
events?.close();
|
||||
events = null;
|
||||
navigate("/admin/login");
|
||||
}
|
||||
} finally {
|
||||
@@ -372,6 +340,11 @@ async function guarded(task: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthError(raw: string, message: string) {
|
||||
const text = `${raw} ${message}`.toLowerCase();
|
||||
return text.includes("unauthorized") || text.includes("login required") || text.includes("401") || message.includes("需要登录");
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
|
||||
}
|
||||
@@ -387,7 +360,8 @@ async function login() {
|
||||
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||
});
|
||||
csrf.value = data.csrfToken;
|
||||
localStorage.setItem("ymhut.csrf", csrf.value);
|
||||
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
connectAdminEvents();
|
||||
navigate("/admin/dashboard");
|
||||
});
|
||||
@@ -396,6 +370,7 @@ async function login() {
|
||||
async function logout() {
|
||||
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
|
||||
csrf.value = "";
|
||||
sessionStorage.removeItem("ymhut.csrf");
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
events?.close();
|
||||
events = null;
|
||||
@@ -408,17 +383,19 @@ async function load() {
|
||||
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
|
||||
return;
|
||||
}
|
||||
if (!csrf.value) {
|
||||
navigate("/admin/login");
|
||||
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();
|
||||
if (currentPath.value === "/admin/system") await loadSystem();
|
||||
const legacyName = activeLegacyName.value;
|
||||
if (legacyName) await loadLegacy(legacyName);
|
||||
connectAdminEvents();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,6 +403,10 @@ async function loadDashboard() {
|
||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||
}
|
||||
|
||||
async function loadSystem() {
|
||||
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
|
||||
}
|
||||
|
||||
async function loadFeedbacks() {
|
||||
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||
@@ -540,6 +521,10 @@ async function loadLegacy(name: LegacyName) {
|
||||
function onPackageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadDraft.file = input.files?.[0] || null;
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
uploadDraft.status = uploadDraft.file ? "等待上传" : "";
|
||||
if (uploadDraft.file && !uploadDraft.version) {
|
||||
const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0];
|
||||
if (version) uploadDraft.version = version;
|
||||
@@ -560,11 +545,33 @@ async function uploadPackage() {
|
||||
form.append("channel", uploadDraft.channel);
|
||||
form.append("notes", uploadDraft.notes);
|
||||
form.append("updateManifest", String(uploadDraft.updateManifest));
|
||||
await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} });
|
||||
uploadDraft.uploading = true;
|
||||
uploadDraft.status = "正在上传";
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
await uploadWithProgress("/api/admin/releases/packages", form, (loaded, total) => {
|
||||
uploadDraft.loadedBytes = loaded;
|
||||
uploadDraft.totalBytes = total;
|
||||
uploadDraft.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
uploadDraft.status = uploadDraft.progress >= 100 ? "服务端处理中" : "正在上传";
|
||||
});
|
||||
uploadDraft.progress = 100;
|
||||
uploadDraft.status = "上传完成";
|
||||
uploadDraft.file = null;
|
||||
uploadDraft.notes = "";
|
||||
setToast("发布包已上传并放入下载目录");
|
||||
await loadReleases();
|
||||
window.setTimeout(() => {
|
||||
if (!uploadDraft.uploading) {
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = 0;
|
||||
uploadDraft.status = "";
|
||||
}
|
||||
}, 1200);
|
||||
}).finally(() => {
|
||||
uploadDraft.uploading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -727,6 +734,7 @@ async function checkSources() {
|
||||
setToast(`接口心跳检测已进入队列:${data.jobId}`);
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
if (currentPath.value === "/admin/system") await loadSystem();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -759,11 +767,11 @@ function copyEndpointToSource(item: any) {
|
||||
navigate("/admin/sources");
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
|
||||
const data = await api<{ database: any }>("/api/admin/database/status");
|
||||
database.value = data.database;
|
||||
databaseForm.provider = data.database?.configProvider || "sqlite";
|
||||
await previewLegacySync();
|
||||
if (options.previewLegacy !== false) await previewLegacySync();
|
||||
}
|
||||
|
||||
async function testDatabase() {
|
||||
@@ -778,21 +786,24 @@ async function testDatabase() {
|
||||
|
||||
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: "{}" });
|
||||
const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
||||
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
|
||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||
await loadDatabase();
|
||||
await loadDatabase({ previewLegacy: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function previewLegacySync() {
|
||||
legacySyncMode.value = "preview";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
||||
}
|
||||
|
||||
async function runLegacySync() {
|
||||
await guarded(async () => {
|
||||
legacySyncMode.value = "run";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||
setToast("旧项目同步已完成");
|
||||
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -875,6 +886,17 @@ function auditMessage(item: any) {
|
||||
return legacy[message] || message || auditTypeLabel(item?.type);
|
||||
}
|
||||
|
||||
function databaseSyncDirectionLabel(value: string) {
|
||||
if (value === "sqlite_to_remote") return "SQLite -> MySQL";
|
||||
if (value === "remote_to_sqlite") return "MySQL -> SQLite";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function databaseSyncTableCount(result: any) {
|
||||
const tables = result?.tables || {};
|
||||
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -921,13 +943,17 @@ function splitList(value: string) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
void load();
|
||||
connectAdminEvents();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
watch(currentPath, () => {
|
||||
void load();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
events?.close();
|
||||
@@ -942,8 +968,7 @@ function connectAdminEvents() {
|
||||
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
|
||||
if (currentPath.value === "/admin/audit") void loadAudit();
|
||||
if (currentPath.value === "/admin/database") void loadDatabase();
|
||||
if (currentPath.value === "/admin/system") void loadSystem();
|
||||
};
|
||||
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
|
||||
events.addEventListener(name, refreshCurrent);
|
||||
@@ -1001,7 +1026,7 @@ function connectAdminEvents() {
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }"
|
||||
:class="{ active: currentPath === item.path }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
@@ -1030,10 +1055,7 @@ function connectAdminEvents() {
|
||||
<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" />
|
||||
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AdminApiOptions = {
|
||||
csrf?: string;
|
||||
};
|
||||
|
||||
const exactMessages: Record<string, string> = {
|
||||
"current password is invalid": "当前密码不正确",
|
||||
"new password is required": "新密码不能为空",
|
||||
"new password must be at least 8 characters": "新密码至少需要 8 位",
|
||||
"new password cannot be admin": "新密码不能为 admin",
|
||||
"new password must be different from current password": "新密码不能与当前密码相同",
|
||||
"invalid password or captcha": "密码或验证码不正确",
|
||||
"login required": "需要登录后继续操作",
|
||||
"csrf token required": "页面安全令牌已失效,请刷新后重试",
|
||||
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
|
||||
"code is required": "缺少反馈编号",
|
||||
"revisionid is required": "请选择要恢复的历史版本",
|
||||
"post required": "该操作需要使用 POST 请求",
|
||||
"get required": "该操作需要使用 GET 请求",
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到心跳检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
};
|
||||
|
||||
const codeMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: "需要登录后继续操作",
|
||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
||||
INVALID_PAYLOAD: "提交内容格式不正确",
|
||||
DATABASE_TEST_FAILED: "数据库连接测试失败",
|
||||
DATABASE_IMPORT_FAILED: "SQLite 导入远端库失败",
|
||||
DATABASE_SYNC_FAILED: "远端库同步回本地失败",
|
||||
LEGACY_SAVE_FAILED: "兼容 JSON 保存失败",
|
||||
LEGACY_VALIDATE_FAILED: "兼容 JSON 校验失败",
|
||||
LEGACY_RESTORE_FAILED: "兼容 JSON 恢复失败",
|
||||
NOTICE_SAVE_FAILED: "版本日志保存失败",
|
||||
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
|
||||
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
|
||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||
CHECK_FAILED: "接口健康检测失败",
|
||||
SYNC_FAILED: "同步操作失败",
|
||||
FORBIDDEN: "没有权限执行该操作",
|
||||
METHOD_NOT_ALLOWED: "请求方法不正确",
|
||||
};
|
||||
|
||||
export async function adminFetch<T>(target: string, init: RequestInit = {}, options: AdminApiOptions = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", target);
|
||||
xhr.withCredentials = true;
|
||||
if (options.csrf) xhr.setRequestHeader("X-CSRF-Token", options.csrf);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const data = parseJSONSafe(xhr.responseText, {});
|
||||
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
|
||||
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve(data as T);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络异常,发布包上传失败"));
|
||||
xhr.onabort = () => reject(new Error("发布包上传已取消"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
export function toChineseError(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
const lower = raw.toLowerCase();
|
||||
if (exactMessages[lower]) return exactMessages[lower];
|
||||
if (codeMessages[raw]) return codeMessages[raw];
|
||||
if (/^HTTP\s+\d+/.test(raw)) return `请求失败:${raw}`;
|
||||
return raw || "操作失败";
|
||||
}
|
||||
|
||||
function parseJSONSafe(value: string, fallback: any) {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,17 @@ const routes = [
|
||||
"/admin/legacy/media-types",
|
||||
"/admin/sources",
|
||||
"/admin/endpoints",
|
||||
"/admin/database",
|
||||
"/admin/health",
|
||||
"/admin/settings",
|
||||
"/admin/audit",
|
||||
"/admin/system",
|
||||
].map((path) => ({ path, component: RoutePlaceholder }));
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...routes,
|
||||
{ path: "/admin/database", redirect: { path: "/admin/system", query: { tab: "database" } } },
|
||||
{ path: "/admin/health", redirect: { path: "/admin/system", query: { tab: "health" } } },
|
||||
{ path: "/admin/settings", redirect: { path: "/admin/system", query: { tab: "security" } } },
|
||||
{ path: "/admin/audit", redirect: { path: "/admin/system", query: { tab: "audit" } } },
|
||||
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createAuthStore() {
|
||||
const csrf = ref(sessionStorage.getItem("ymhut.csrf") || "");
|
||||
const captcha = ref<any | null>(null);
|
||||
const bootstrap = ref<any | null>(null);
|
||||
const loginForm = reactive({ username: "", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
|
||||
return { csrf, captcha, bootstrap, loginForm, passwordForm };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createDashboardStore() {
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = ref<any[]>([]);
|
||||
|
||||
return { dashboard, sourceCheckJobs };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createFeedbackStore() {
|
||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<any | null>(null);
|
||||
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
||||
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
|
||||
return { page, selected, filters, update, commentDraft };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export type LegacyName = "update-info" | "media-types";
|
||||
|
||||
export function createLegacyStore() {
|
||||
const sync = ref<any>(null);
|
||||
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
|
||||
return { sync, documents, drafts };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createReleaseStore() {
|
||||
const releases = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const selectedNotice = ref<any | null>(null);
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: "",
|
||||
});
|
||||
|
||||
return { releases, notices, selectedNotice, noticeDraft, uploadDraft };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSourceStore() {
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const draft = 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\"]",
|
||||
});
|
||||
|
||||
return { sources, endpoints, draft };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
const databaseLastSync = ref<any>(null);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
|
||||
const legacySyncMode = ref<"preview" | "run">("preview");
|
||||
|
||||
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; overflow-x: hidden; }
|
||||
body { margin: 0; background: var(--bg); overflow-x: hidden; }
|
||||
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
|
||||
body { margin: 0; background: var(--bg); max-width: 100%; overflow-x: clip; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.65; }
|
||||
@@ -166,13 +166,18 @@ input:focus, textarea:focus, select:focus {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; 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; overflow-x: hidden; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
.brand strong, .brand small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; min-width: 0; overflow-x: hidden; }
|
||||
.nav-group p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--muted);
|
||||
@@ -193,6 +198,11 @@ input:focus, textarea:focus, select:focus {
|
||||
color: #526070;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.nav-group button svg, .logout svg { flex: 0 0 auto; }
|
||||
.nav-group button span, .logout span {
|
||||
@@ -201,7 +211,7 @@ input:focus, textarea:focus, select:focus {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); box-shadow: inset 3px 0 0 var(--primary); overflow: hidden; }
|
||||
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||
.logout { color: #7f1d1d; }
|
||||
|
||||
@@ -219,10 +229,10 @@ input:focus, textarea:focus, select:focus {
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.metric, .panel, .quick-grid button, .revision-list button, .nested-card {
|
||||
.metric, .panel, .revision-list button, .nested-card {
|
||||
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.metric:hover, .panel:hover, .quick-grid button:hover, .revision-list button:hover, .nested-card:hover {
|
||||
.metric:hover, .panel:hover, .revision-list button:hover, .nested-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
@@ -233,23 +243,6 @@ input:focus, textarea:focus, select:focus {
|
||||
.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: 12px;
|
||||
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); }
|
||||
|
||||
@@ -344,6 +337,44 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.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; }
|
||||
.sync-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.sync-summary div {
|
||||
min-height: 74px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
.sync-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.sync-summary strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
}
|
||||
.ops-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #f4d38c;
|
||||
border-radius: 10px;
|
||||
background: var(--warn-bg);
|
||||
color: #7a3b00;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -386,12 +417,47 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
background: #fff;
|
||||
}
|
||||
.upload-card {
|
||||
background: linear-gradient(135deg, #ffffff, #f8fbff);
|
||||
background: #fff;
|
||||
}
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.upload-progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.upload-progress-head strong { color: var(--ink); }
|
||||
.upload-progress-head span { color: var(--primary-dark); font-weight: 900; }
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 9px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--primary);
|
||||
transition: width 0.18s var(--ease);
|
||||
}
|
||||
.upload-progress small {
|
||||
color: var(--muted);
|
||||
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; }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
@@ -403,7 +469,6 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.quick-grid { grid-template-columns: 1fr; }
|
||||
.captcha-row { grid-template-columns: 1fr; }
|
||||
table { min-width: 720px; }
|
||||
.panel { overflow-x: auto; max-width: 100%; }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<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><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</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>
|
||||
@@ -47,17 +47,6 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<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>
|
||||
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
@@ -24,6 +24,10 @@ defineProps<{ ctx: any }>();
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
|
||||
生产环境不再自动依赖旧项目路径。需要以 server/update/public/media-types.json 为基板时,请切换到 Raw JSON 粘贴完整内容,校验通过后保存发布。
|
||||
</p>
|
||||
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
|
||||
@@ -25,7 +25,22 @@ defineProps<{ ctx: any }>();
|
||||
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.uploadPackage"><UploadCloud :size="16" />上传发布包</button>
|
||||
<div v-if="ctx.uploadDraft.file || ctx.uploadDraft.uploading || ctx.uploadDraft.status" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<strong>{{ ctx.uploadDraft.status || "等待上传" }}</strong>
|
||||
<span>{{ ctx.uploadDraft.progress || 0 }}%</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${ctx.uploadDraft.progress || 0}%` }"></span>
|
||||
</div>
|
||||
<small>
|
||||
{{ ctx.uploadDraft.file?.name || "发布包" }}
|
||||
<template v-if="ctx.uploadDraft.totalBytes">
|
||||
· {{ ctx.formatBytes(ctx.uploadDraft.loadedBytes || 0) }} / {{ ctx.formatBytes(ctx.uploadDraft.totalBytes || 0) }}
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
|
||||
</section>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<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,159 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
const tabs = [
|
||||
{ id: "database", label: "数据库", icon: Database },
|
||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||
{ id: "security", label: "安全设置", icon: ShieldCheck },
|
||||
{ id: "health", label: "健康快照", icon: Activity },
|
||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<nav class="tabs" aria-label="系统运维标签">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="{ active: ctx.systemTab === tab.id }"
|
||||
@click="ctx.setSystemTab(tab.id)"
|
||||
>
|
||||
<component :is="tab.icon" :size="15" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="ctx.systemTab === 'database'" 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>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span><ArrowDownUp :size="15" />最近同步方向</span>
|
||||
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><ListChecks :size="15" />影响记录</span>
|
||||
<strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><Clock3 :size="15" />完成时间</span>
|
||||
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库同步是覆盖式全表 upsert。执行前确认方向:SQLite 导入远端会以本地库为源,远端同步回本地会以 MySQL 为源。</span>
|
||||
</div>
|
||||
</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>
|
||||
<div class="button-row">
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'sync'" class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>旧项目同步</h2>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行会先备份当前发布目录,再复制旧项目数据并导入反馈记录。</p>
|
||||
</div>
|
||||
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span>当前模式</span>
|
||||
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>状态</span>
|
||||
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>完成时间</span>
|
||||
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
||||
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
|
||||
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
|
||||
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
||||
</div>
|
||||
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" autocomplete="current-password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" autocomplete="new-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><span class="badge good">已启用</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
|
||||
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
|
||||
<span>Cookie</span><strong>HTTPS 或 X-Forwarded-Proto=https 时自动 Secure</strong>
|
||||
<span>会话范围</span><strong>后台 API 与 SSE 事件流均要求登录</strong>
|
||||
<span>密码规则</span><strong>至少 8 位,不能为 admin,不能与当前密码相同</strong>
|
||||
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容,后续可平滑迁移到更强算法</strong>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'health'" class="panel page-stack">
|
||||
<div class="section-head"><h2>健康快照</h2><span class="badge neutral">只读</span></div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
|
||||
<section v-else 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><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -4,9 +4,21 @@ import vue from "@vitejs/plugin-vue";
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ["vue", "vue-router"],
|
||||
charts: ["echarts", "vue-echarts"],
|
||||
icons: ["lucide-vue-next"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
"/api": "http://127.0.0.1:33550",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network } from "lucide-vue-next";
|
||||
import { usePortalState } from "./state";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -22,7 +22,7 @@ onMounted(() => state.load());
|
||||
<main class="portal-shell">
|
||||
<nav class="topnav">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span><ShieldCheck :size="22" /></span>
|
||||
<span><img src="/logo-44.png" alt="YMhut Box" /></span>
|
||||
<strong>YMhut Box</strong>
|
||||
</RouterLink>
|
||||
<div class="nav-links">
|
||||
@@ -30,11 +30,11 @@ onMounted(() => state.load());
|
||||
<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>
|
||||
<p v-if="state.error.value" class="state-banner error">服务状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取客户端公开状态...</p>
|
||||
<p v-else-if="state.loadedAt.value" class="state-banner ready">公开状态已更新:{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</p>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const items = [
|
||||
{ title: "旧版更新能力", body: "旧客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
|
||||
{ title: "旧版媒体源能力", body: "媒体源结构保持旧字段兼容,后台保存后会同步到旧客户端可读结构。" },
|
||||
{ title: "新版动态配置", body: "新版客户端优先从服务端读取发布、接口源、健康状态和缓存策略,失败时回退旧路径。" },
|
||||
{ title: "反馈兼容", body: "旧反馈提交和状态查询入口继续保留,后台统一沉淀为反馈工单。" },
|
||||
{ title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
|
||||
{ title: "新版动态配置", body: "新版客户端优先读取发布、接口源、健康状态和缓存策略,失败时可回退旧路径。" },
|
||||
{ title: "反馈兼容", body: "反馈提交和状态查询入口继续保留,查询结果只展示公开进度。" },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h1>反馈查询</h1>
|
||||
<p>旧客户端继续向根路径提交反馈。已有反馈可通过反馈码查询处理状态。</p>
|
||||
<p>已有反馈可通过反馈码查询公开处理状态。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel feedback-panel">
|
||||
@@ -19,6 +19,6 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
|
||||
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
|
||||
</div>
|
||||
<p class="muted">反馈提交接口保持旧版兼容:客户端仍可 POST 到服务根路径。</p>
|
||||
<p class="muted">状态查询只返回公开进度、公开回复和接收时间,不展示后台内部处理记录。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,7 @@ const state = usePortalState();
|
||||
<span>接口健康检测</span>
|
||||
<span>反馈状态追踪</span>
|
||||
</div>
|
||||
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口,请稍后刷新。</p>
|
||||
</div>
|
||||
|
||||
<aside class="release-card">
|
||||
@@ -58,7 +59,8 @@ const state = usePortalState();
|
||||
<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>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else class="empty">暂无可展示的版本日志。</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ const state = usePortalState();
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Releases</p>
|
||||
<h1>发布版本</h1>
|
||||
<p>展示发布包、下载入口和 update-notice 版本日志。</p>
|
||||
<p>展示客户端可见的发布包、下载入口和版本日志。</p>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
@@ -25,9 +25,11 @@ const state = usePortalState();
|
||||
<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>
|
||||
<tr v-if="state.loading.value"><td colspan="5">正在读取发布包...</td></tr>
|
||||
<tr v-else-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败:{{ state.error.value }}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel wide">
|
||||
@@ -41,7 +43,8 @@ const state = usePortalState();
|
||||
<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>
|
||||
<p v-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -9,7 +9,7 @@ const state = usePortalState();
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Sources</p>
|
||||
<h1>接口源健康</h1>
|
||||
<p>媒体源、数据源和客户端动态接口目录的可用性汇总。</p>
|
||||
<p>客户端可见接口目录和最近健康状态汇总。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
@@ -27,6 +27,8 @@ const state = usePortalState();
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="empty">暂无接口源数据。后台同步旧媒体源配置或手动添加后会显示在这里。</p>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
|
||||
<p v-else-if="state.error.value" class="empty">接口源状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else class="empty">暂无客户端可见接口源。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -6,12 +6,39 @@ const sources = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const loadedAt = ref("");
|
||||
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
|
||||
bootstrap: "idle",
|
||||
releases: "idle",
|
||||
sources: "idle",
|
||||
notices: "idle",
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
const endpointLabels: Record<string, string> = {
|
||||
"/api/client/bootstrap": "客户端启动配置",
|
||||
"/api/client/releases": "发布信息",
|
||||
"/api/client/sources": "接口源目录",
|
||||
"/api/client/notices": "版本日志",
|
||||
};
|
||||
|
||||
async function fetchJSON(path: string) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
|
||||
return res.json();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { headers: { Accept: "application/json" } });
|
||||
} catch {
|
||||
throw new Error(`${endpointLabels[path] || path} 暂时无法连接`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`${endpointLabels[path] || path} 返回 HTTP ${res.status}`);
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new Error(`${endpointLabels[path] || path} 返回内容不是有效 JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : String(reason || "读取失败");
|
||||
}
|
||||
|
||||
export function usePortalState() {
|
||||
@@ -26,11 +53,16 @@ export function usePortalState() {
|
||||
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
|
||||
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||
const isReady = computed(() => loaded && !loading.value && !error.value);
|
||||
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
|
||||
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
|
||||
const sourcesEmpty = computed(() => !loading.value && categories.value.length === 0);
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded && !force) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
|
||||
try {
|
||||
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
|
||||
fetchJSON("/api/client/bootstrap"),
|
||||
@@ -38,15 +70,36 @@ export function usePortalState() {
|
||||
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 || [];
|
||||
if (bootstrapData.status === "fulfilled") {
|
||||
bootstrap.value = bootstrapData.value;
|
||||
requestState.value.bootstrap = "ready";
|
||||
} else {
|
||||
requestState.value.bootstrap = "error";
|
||||
}
|
||||
if (releaseData.status === "fulfilled") {
|
||||
releases.value = releaseData.value;
|
||||
requestState.value.releases = "ready";
|
||||
} else {
|
||||
requestState.value.releases = "error";
|
||||
}
|
||||
if (sourceData.status === "fulfilled") {
|
||||
sources.value = sourceData.value;
|
||||
requestState.value.sources = "ready";
|
||||
} else {
|
||||
requestState.value.sources = "error";
|
||||
}
|
||||
if (noticeData.status === "fulfilled") {
|
||||
notices.value = noticeData.value.items || [];
|
||||
requestState.value.notices = "ready";
|
||||
} else {
|
||||
requestState.value.notices = "error";
|
||||
}
|
||||
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);
|
||||
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
|
||||
loaded = true;
|
||||
loadedAt.value = new Date().toISOString();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
error.value = failureMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -59,6 +112,8 @@ export function usePortalState() {
|
||||
notices,
|
||||
loading,
|
||||
error,
|
||||
loadedAt,
|
||||
requestState,
|
||||
packages,
|
||||
categories,
|
||||
latestNotice,
|
||||
@@ -68,6 +123,10 @@ export function usePortalState() {
|
||||
downloadUrl,
|
||||
appVersion,
|
||||
serviceVersion,
|
||||
isReady,
|
||||
hasPartialData,
|
||||
releasesEmpty,
|
||||
sourcesEmpty,
|
||||
load,
|
||||
sourceStatus,
|
||||
statusTone,
|
||||
|
||||
@@ -82,6 +82,12 @@ button { cursor: pointer; }
|
||||
background: #10231d;
|
||||
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
|
||||
}
|
||||
.brand img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.brand strong { letter-spacing: 0; }
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@@ -89,7 +95,7 @@ button { cursor: pointer; }
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-links a, .admin-link {
|
||||
.nav-links a {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -107,12 +113,6 @@ button { cursor: pointer; }
|
||||
background: rgba(31, 111, 91, 0.10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.admin-link {
|
||||
color: #fff;
|
||||
background: #10231d;
|
||||
box-shadow: 0 12px 28px rgba(31, 111, 91, 0.22);
|
||||
}
|
||||
.admin-link:hover { transform: translateY(-1px); box-shadow: 0 16px 36px rgba(31, 111, 91, 0.28); }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
@@ -351,6 +351,7 @@ th {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.muted, .empty { color: var(--muted); }
|
||||
.empty.strong { font-weight: 900; color: var(--bad); }
|
||||
.notice-list { display: grid; gap: 12px; }
|
||||
.notice-card {
|
||||
display: grid;
|
||||
@@ -440,6 +441,7 @@ input:focus {
|
||||
}
|
||||
.error { color: var(--bad); }
|
||||
.loading { color: var(--muted); }
|
||||
.ready { color: var(--good); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.topnav {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"modules": []
|
||||
}
|
||||
@@ -1,95 +1,60 @@
|
||||
{
|
||||
"api_keys": {
|
||||
"uapipro": ""
|
||||
},
|
||||
"app_version": "2.0.6.2",
|
||||
"build": "2",
|
||||
"channel": "stable",
|
||||
"title": "YMhut Box 2.0.6.2",
|
||||
"message": "本版本重点修复覆盖安装后白屏退出、用户目录 runtime 占用、语言包膨胀和设置页初始化问题,并继续完善 WinUI 3 工具型工作台体验。",
|
||||
"message_md": "# YMhut Box 2.0.6.2\n\n本版本继续收尾 WinUI 3 工具型工作台:修复覆盖安装/直启稳定性、用户目录 runtime 残留、自检结果页卡顿、排行榜/资讯显示、中文日志和设置页初始化问题,并新增安装器输出框、Markdown 公告、媒体播放器与随机放映室增强、价格/指标图表化展示。",
|
||||
"release_notes": "修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出;发布布局改为纯 lang\\zh-CN 与 lang\\en-US,移除多余语言包和旧 resources\\lang 压缩依赖;启动与自检链路禁止在用户数据目录保存 Runtime/runtime/Runtimes/runtimes 等运行时副本,旧残留会在启动和安装时清理;完善启动自检、安装完整性检查、服务状态结果页、工具箱与工具详情布局、结果/原始输出渲染、排行榜/资讯结构化显示、设置页控制中心和系统概况实时图表;修复设置页初始化失败、中文模式英文漏出、部分日志英文展示、天气胶囊图标缺失以及关闭确认记住选择等问题。",
|
||||
"release_notes_md": "## Bug 修复\n\n- 修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出,失败时写入清晰日志并显示可读错误。\n- 杜绝用户数据目录生成 Runtime/runtime/Runtimes/runtimes 或完整程序 payload 副本,旧残留会在启动和安装时清理。\n- 修复自检结果页加载大量历史明细时卡顿或短暂无响应的问题,改为确认后摘要优先、明细按需加载。\n- 修复中文模式下部分日志仍显示英文的问题,错误码、HTTP 状态和反馈状态仍保留必要原文。\n- 修复排行榜、热榜和资讯类工具被“已隐藏远程地址,仅展示脱敏来源名称”提示干扰后无法生成卡片的问题,同时继续隐藏远程 URL。\n- 修复设置页初始化失败、关闭确认记住选择、天气胶囊部分状态缺少动画图标等问题。\n\n## 支持增强\n\n- 发布布局统一为纯 `lang\\zh-CN` / `lang\\en-US`,移除多余语言包、根目录 culture 目录和旧 `resources\\lang` 压缩依赖。\n- 客户端公告、首页公告、关于页更新弹窗和更新日志弹窗支持 Markdown 标题、列表、表格、代码与链接,解析失败时回退纯文本。\n- 随机放映室支持远程媒体重定向解析,并为图片、视频、音频加载提供进度提示。\n- 数据类工具增强价格/指标结果展示,黄金价格等结果可同时显示摘要、表格和趋势折线图。\n\n## 新增能力\n\n- 安装引导程序在提取文件阶段新增只读输出框,展示旧版本清理、payload 提取、依赖检查和安装收尾进度。\n- 新增启动/安装完整性自检结果入口,服务状态页可确认后查看结果、复制摘要和导出 JSON。\n- 媒体播放器补齐播放列表、常用播放控制、倍速、音量、全屏、图片/视频/音频混播和常见系统解码格式入口。\n- 随机放映室迁移旧版展示思路,改为图片、视频、音频三段式远程媒体浏览。\n\n## 体验重构\n\n- 工具箱与工具详情页继续向高密度 WinUI 工具工作台收束,结果区固定提供“结果”和“原始输出”。\n- 设置控制中心增加分页滚动提示,系统概况图表补齐网格、坐标和实时信息。\n- 主题继续参考 Microsoft Store 与 Windows 媒体播放器的中性 Fluent 风格,不使用渐变,蓝色为主强调,橙/红仅用于警示。\n- 随机放映室和播放器 UI 更接近 Win11 媒体体验,加载、失败、保存、全屏等状态更清晰。",
|
||||
"category_list": [
|
||||
{
|
||||
"icon": "monitor",
|
||||
"id": "system",
|
||||
"name": "系统工具"
|
||||
},
|
||||
{
|
||||
"icon": "code",
|
||||
"id": "developer",
|
||||
"name": "开发工具"
|
||||
},
|
||||
{
|
||||
"icon": "image",
|
||||
"id": "image",
|
||||
"name": "图像工具"
|
||||
}
|
||||
],
|
||||
"detected_product": "YMhut Box",
|
||||
"detected_packages": {
|
||||
"YMhut Box": [
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
},
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"download_mirrors": [
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "light",
|
||||
"name": "轻量安装包",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe"
|
||||
}
|
||||
],
|
||||
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"home_notes": "v2.0.6.2 聚焦安装布局、启动稳定性和 WinUI 工作台体验:覆盖安装会清理旧程序布局和 runtime 残留,用户目录只保留设置、日志、缓存和轻量状态;语言资源只保留中英双语并归入 lang;工具箱、工具详情、结果渲染、排行榜/资讯卡片、设置控制中心、系统概况和服务状态页继续向原生 Fluent 工具软件风格收束。",
|
||||
"last_update_notes": {
|
||||
"v2.0.5.3 稳定性": "延续启动首页、插件扫描、反馈中心和日志展示修复;本版本进一步处理覆盖安装、语言资源和用户目录 runtime 残留。",
|
||||
"v2.0.5.3 安装包体积": "上一版新增轻量安装包通道;本版本继续收束语言包和旧资源布局,避免无关文件混入发布目录。",
|
||||
"v2.0.5.3 设置外观": "上一版加入窗口材质设置;本版本继续完善设置页控制中心、系统概况实时图表和中文本地化。"
|
||||
},
|
||||
"last_updated": "2026-06-14T00:00:00Z",
|
||||
"tool_metadata": {},
|
||||
"update_notes": {
|
||||
"启动与覆盖安装": "修复覆盖安装后启动白屏或应用自行退出的问题;旧压缩语言布局不再回退复制到用户目录,失败时写入清晰日志并显示可读错误。",
|
||||
"用户目录瘦身": "用户数据目录不再生成 Runtime、runtime、Runtimes 或 runtimes 文件夹;启动、自检和安装器都会清理旧 runtime 残留,避免大型运行时副本占用本机存储。",
|
||||
"语言资源布局": "发布布局统一为纯 lang,保留 lang\\zh-CN 与 lang\\en-US;移除其他语言包、根目录 culture 目录和 resources\\lang 压缩残留。",
|
||||
"启动自检": "迁移并改造旧版启动初始化思路:快速预检负责用户目录、设置、数据库、下载队列、安装根和关键资源;月度完整性检查在窗口可用后后台运行。",
|
||||
"自检结果页": "服务状态页新增启动自检与安装完整性入口;查看结果前增加确认提示和加载进度,历史先加载摘要,用户选择后再读取完整明细,降低大量结果渲染造成的卡顿。",
|
||||
"排行榜与资讯": "修复部分排行榜、热榜和资讯类工具因“已隐藏远程地址,仅展示脱敏来源名称”提示混入结构化解析而无法显示卡片内容的问题,同时继续隐藏远程地址。",
|
||||
"工具箱与工具详情": "工具箱改为更高密度的原生 WinUI 工作台布局;工具内容区域按功能类型优化输入、结果、原始输出、复制、搜索和导出体验。",
|
||||
"设置页": "修复设置页初始化失败;控制中心分页限制为 5 项可视并增加动态上下箭头提示;系统概况加入网格坐标、暗色绘图区和更多实时系统信息。",
|
||||
"主题与视觉": "主题改为微软商店/媒体播放器式中性 Fluent 风格,移除渐变主视觉,蓝色作为主强调色,橙红仅用于更新、警告和风险行为。",
|
||||
"天气胶囊": "补齐阴天、未知、离线等状态的基础图标和轻量动效,遵守关闭动画与高对比度设置。",
|
||||
"本地化与日志": "修复工具箱与安全、风险确认、默认工具范围、设置弹窗和高频日志的中文模式英文漏出;反馈码和原始错误信息仍保留必要英文。"
|
||||
}
|
||||
}
|
||||
"manifestVersion": 5,
|
||||
"latestVersion": "2.0.7.5",
|
||||
"appVersion": "2.0.7.5",
|
||||
"version": "2.0.7",
|
||||
"build": "05",
|
||||
"channel": "stable",
|
||||
"latest": {
|
||||
"version": "2.0.7.5",
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
|
||||
"size": 113480968,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
|
||||
"size": 259959751,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"appInstaller": {
|
||||
"fileName": "winui.appinstaller",
|
||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
||||
"size": 558,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"files": {
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
|
||||
"size": 113480968,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
|
||||
"size": 259959751,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"appInstaller": {
|
||||
"fileName": "winui.appinstaller",
|
||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
||||
"size": 558,
|
||||
"version": "2.0.7.5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
||||
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
||||
},
|
||||
"createdAt": "2026-06-26T10:00:33.3827184Z"
|
||||
}
|
||||
Reference in New Issue
Block a user