@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user