This commit is contained in:
@@ -60,6 +60,9 @@ func Run() {
|
||||
legacySyncService := synclegacy.New(cfg, store, noticeService)
|
||||
authService := auth.NewService(store)
|
||||
|
||||
if err := legacyService.EnsureSeedDocuments(context.Background()); err != nil {
|
||||
log.Printf("legacy json seed skipped: %v", err)
|
||||
}
|
||||
if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
|
||||
log.Printf("legacy media source import skipped: %v", err)
|
||||
}
|
||||
|
||||
@@ -1147,7 +1147,7 @@ func (s *Store) UpdateFeedbackTicket(code string, update FeedbackUpdate) error {
|
||||
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: "Feedback updated"})
|
||||
_ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "反馈工单已更新"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1416,6 +1416,9 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int,
|
||||
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)
|
||||
|
||||
@@ -38,6 +38,49 @@ func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||
return &Service{cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureSeedDocuments(ctx context.Context) error {
|
||||
for _, name := range []string{"update-info", "media-types"} {
|
||||
if err := s.ensureSeedDocument(ctx, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureSeedDocument(ctx context.Context, name string) error {
|
||||
fileName, err := fileNameFor(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
source := filepath.Join(s.cfg.LegacyUpdateDir, "public", fileName)
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, formatted, err := parseAndFormat(name, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(target, []byte(formatted)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.store.SaveLegacyRevision(name, formatted, "从旧 update/public 初始化基板", "system")
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "system", Type: "legacy_json.seeded", Target: name, Message: "已从旧项目导入兼容 JSON 基板"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, name string) (Document, error) {
|
||||
fileName, err := fileNameFor(name)
|
||||
if err != nil {
|
||||
@@ -97,7 +140,7 @@ func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor
|
||||
return Document{}, err
|
||||
}
|
||||
_, _ = s.store.SaveLegacyRevision(name, formatted, req.Note, actor)
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.saved", Target: name, Message: "Legacy JSON saved"})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.saved", Target: name, Message: legacyMessage(name, "已保存并发布兼容 JSON")})
|
||||
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||||
}
|
||||
@@ -124,11 +167,22 @@ func (s *Service) Restore(ctx context.Context, name string, revisionID int64, ac
|
||||
return Document{}, err
|
||||
}
|
||||
_, _ = s.store.SaveLegacyRevision(name, formatted, "restored revision", actor)
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.restored", Target: name, Message: "Legacy JSON restored"})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.restored", Target: name, Message: legacyMessage(name, "已恢复兼容 JSON 历史版本")})
|
||||
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||||
}
|
||||
|
||||
func legacyMessage(name, action string) string {
|
||||
switch name {
|
||||
case "update-info":
|
||||
return "更新 JSON:" + action
|
||||
case "media-types":
|
||||
return "媒体源 JSON:" + action
|
||||
default:
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
func fileNameFor(name string) (string, error) {
|
||||
switch strings.TrimSpace(name) {
|
||||
case "update-info":
|
||||
|
||||
@@ -135,7 +135,7 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act
|
||||
if err := s.syncLegacyUpdateInfo(saved, parsed); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "Release notice saved"})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "版本日志已保存并同步兼容更新信息"})
|
||||
return s.Get(saved.Version)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,6 +42,16 @@ type Package struct {
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UploadOptions struct {
|
||||
FileName string
|
||||
Version string
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
Notes string
|
||||
UpdateManifest bool
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||||
service := &Service{cfg: cfg, store: store}
|
||||
if len(noticeService) > 0 {
|
||||
@@ -162,6 +174,103 @@ func (s *Service) StaticJSON(name string) map[string]any {
|
||||
return readJSON(filepath.Join(s.cfg.UpdatePublicDir, name))
|
||||
}
|
||||
|
||||
func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts UploadOptions, actor string) (Package, error) {
|
||||
name, err := safePackageName(opts.FileName)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
target := filepath.Join(s.cfg.DownloadsDir, name)
|
||||
resolved, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
base, _ := filepath.Abs(s.cfg.DownloadsDir)
|
||||
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||
return Package{}, errors.New("path escape rejected")
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload")
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(tmp, io.TeeReader(reader, hash))
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if written <= 0 {
|
||||
return Package{}, errors.New("uploaded file is empty")
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
version := firstNonEmpty(opts.Version, detectVersion(name))
|
||||
platform, arch := detectPlatform(name)
|
||||
platform = firstNonEmpty(opts.Platform, platform)
|
||||
arch = firstNonEmpty(opts.Arch, arch)
|
||||
product := detectProduct(name)
|
||||
pkg := Package{
|
||||
ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
|
||||
Name: product,
|
||||
Version: version,
|
||||
Platform: platform,
|
||||
Arch: arch,
|
||||
URL: requestBaseURL(r, s.cfg.BaseURL) + "/downloads/" + name,
|
||||
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||
Size: written,
|
||||
Required: strings.Contains(strings.ToLower(product), "ymhut"),
|
||||
Enabled: true,
|
||||
FileName: name,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if opts.UpdateManifest {
|
||||
if err := s.updateLegacyManifest(pkg, opts); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(written))})
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := readJSON(path)
|
||||
payload["app_version"] = pkg.Version
|
||||
payload["download_url"] = pkg.URL
|
||||
payload["package_sha256"] = pkg.SHA256
|
||||
payload["package_size"] = pkg.Size
|
||||
payload["updated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
payload["download_mirrors"] = []map[string]any{{
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"url": pkg.URL,
|
||||
"type": "direct",
|
||||
"sha256": pkg.SHA256,
|
||||
"enabled": true,
|
||||
}}
|
||||
if strings.TrimSpace(opts.Notes) != "" {
|
||||
payload["release_notes"] = opts.Notes
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o640)
|
||||
}
|
||||
|
||||
func readJSON(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -257,3 +366,40 @@ func sha256File(path string) string {
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func safePackageName(name string) (string, error) {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("unsupported package extension")
|
||||
}
|
||||
|
||||
func formatBytes(value int64) string {
|
||||
if value < 1024 {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
next := float64(value) / 1024
|
||||
for _, unit := range []string{"KB", "MB", "GB"} {
|
||||
if next < 1024 || unit == "GB" {
|
||||
return fmt.Sprintf("%.1f %s", next, unit)
|
||||
}
|
||||
next /= 1024
|
||||
}
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package releases
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func TestCompareVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -29,3 +38,68 @@ func TestDetectPackageMetadata(t *testing.T) {
|
||||
t.Fatalf("detectVersion returned %q", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
BaseDir: dir,
|
||||
StorageDir: filepath.Join(dir, "storage"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
service := NewService(cfg, store)
|
||||
req := httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil)
|
||||
pkg, err := service.SaveUploadedPackage(req, strings.NewReader("package bytes"), UploadOptions{
|
||||
FileName: "YMhut_Box_WinUI_Setup_2.0.6.31.exe",
|
||||
UpdateManifest: true,
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkg.Version != "2.0.6.31" || pkg.SHA256 == "" || pkg.Size == 0 {
|
||||
t.Fatalf("unexpected package metadata: %#v", pkg)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.DownloadsDir, pkg.FileName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := readJSON(filepath.Join(cfg.UpdatePublicDir, "update-info.json"))
|
||||
if manifest["download_url"] != pkg.URL || manifest["package_sha256"] != pkg.SHA256 {
|
||||
t.Fatalf("manifest not updated: %#v", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
BaseDir: dir,
|
||||
StorageDir: filepath.Join(dir, "storage"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
service := NewService(cfg, store)
|
||||
_, err = service.SaveUploadedPackage(httptest.NewRequest("POST", "/", nil), strings.NewReader("x"), UploadOptions{FileName: "../evil.exe"}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe filename to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -168,6 +169,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||
"last_checked_at": item.LastCheckedAt,
|
||||
"last_error": item.LastError,
|
||||
"consecutiveFailure": item.ConsecutiveFailure,
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
}
|
||||
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
||||
@@ -209,6 +211,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
||||
"lastCheckedAt": item.LastCheckedAt,
|
||||
"lastError": item.LastError,
|
||||
"consecutiveFailure": item.ConsecutiveFailure,
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -252,13 +255,30 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, item.Method, item.APIURL, nil)
|
||||
method := strings.TrimSpace(item.Method)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, item.APIURL, nil)
|
||||
if err != nil {
|
||||
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
redirects := []string{}
|
||||
client := *s.client
|
||||
client.Timeout = timeout
|
||||
client.CheckRedirect = func(next *http.Request, via []*http.Request) error {
|
||||
if next.URL == nil || !isHTTPURL(next.URL) {
|
||||
return errors.New("redirect target must be http or https")
|
||||
}
|
||||
redirects = append(redirects, next.URL.String())
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
start := time.Now()
|
||||
resp, err := s.client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
_ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error())
|
||||
@@ -267,13 +287,49 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
defer resp.Body.Close()
|
||||
status := "ok"
|
||||
message := ""
|
||||
if len(redirects) > 0 {
|
||||
status = "redirected"
|
||||
message = healthMetaMessage(map[string]any{
|
||||
"redirected": true,
|
||||
"redirectCount": len(redirects),
|
||||
"finalUrl": resp.Request.URL.String(),
|
||||
"finalStatus": resp.StatusCode,
|
||||
})
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
status = "degraded"
|
||||
message = resp.Status
|
||||
message = healthMetaMessage(map[string]any{
|
||||
"redirected": len(redirects) > 0,
|
||||
"redirectCount": len(redirects),
|
||||
"finalUrl": resp.Request.URL.String(),
|
||||
"finalStatus": resp.StatusCode,
|
||||
"error": resp.Status,
|
||||
})
|
||||
}
|
||||
return s.store.RecordSourceCheck(item.ID, status, latency, message)
|
||||
}
|
||||
|
||||
func isHTTPURL(value *url.URL) bool {
|
||||
scheme := strings.ToLower(value.Scheme)
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
func healthMetaMessage(meta map[string]any) string {
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func parseHealthMeta(message string) map[string]any {
|
||||
var meta map[string]any
|
||||
if strings.TrimSpace(message) == "" || json.Unmarshal([]byte(message), &meta) != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func TestCheckOneTreatsRedirectToOKAsRedirected(t *testing.T) {
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer target.Close()
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
item, err := store.UpsertSource(db.Source{
|
||||
CategoryID: "test",
|
||||
CategoryName: "Test",
|
||||
SourceID: "redirect",
|
||||
Name: "Redirect",
|
||||
Method: "GET",
|
||||
APIURL: redirector.URL,
|
||||
TimeoutMS: 3000,
|
||||
CheckIntervalSec: 300,
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CheckOne(context.Background(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked, err := store.GetSourceBySourceID("redirect")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checked.LastStatus != "redirected" {
|
||||
t.Fatalf("LastStatus = %q, want redirected", checked.LastStatus)
|
||||
}
|
||||
if !strings.Contains(checked.LastError, `"redirected":true`) {
|
||||
t.Fatalf("LastError does not contain redirect metadata: %s", checked.LastError)
|
||||
}
|
||||
if checked.ConsecutiveFailure != 0 {
|
||||
t.Fatalf("ConsecutiveFailure = %d, want 0", checked.ConsecutiveFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
BaseDir: dir,
|
||||
StorageDir: filepath.Join(dir, "storage"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return cfg, store
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||
s.syncUpdatePublic(&result)
|
||||
s.syncNotices(ctx, &result)
|
||||
s.syncFeedbackSQLite(&result)
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "legacy.sync", Target: "legacy-projects", Message: fmt.Sprintf("Legacy sync finished: ok=%v copied=%d imported=%d errors=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "legacy.sync", Target: "legacy-projects", Message: fmt.Sprintf("旧项目同步完成:成功=%v,复制文件=%d,导入记录=%d,错误=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
|
||||
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: event + " " + message, CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(w, sessionID)
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "Admin login", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
_ = 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}})
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "Admin password changed", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request)
|
||||
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()})
|
||||
_ = 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})
|
||||
}
|
||||
|
||||
@@ -639,6 +639,35 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
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":
|
||||
|
||||
Reference in New Issue
Block a user