This commit is contained in:
@@ -60,6 +60,9 @@ func Run() {
|
|||||||
legacySyncService := synclegacy.New(cfg, store, noticeService)
|
legacySyncService := synclegacy.New(cfg, store, noticeService)
|
||||||
authService := auth.NewService(store)
|
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 {
|
if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
|
||||||
log.Printf("legacy media source import skipped: %v", err)
|
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.Note, 3000), sanitize(update.Assignee), sanitize(update.HandledBy), update.DueAt, update.SLALevel,
|
||||||
sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code)
|
sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code)
|
||||||
if err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1416,9 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int,
|
|||||||
if status == "ok" {
|
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 = ?`,
|
_, 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)
|
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 {
|
} 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 = ?`,
|
_, 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)
|
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}
|
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) {
|
func (s *Service) Get(ctx context.Context, name string) (Document, error) {
|
||||||
fileName, err := fileNameFor(name)
|
fileName, err := fileNameFor(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,7 +140,7 @@ func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor
|
|||||||
return Document{}, err
|
return Document{}, err
|
||||||
}
|
}
|
||||||
_, _ = s.store.SaveLegacyRevision(name, formatted, req.Note, actor)
|
_, _ = 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)
|
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
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
|
return Document{}, err
|
||||||
}
|
}
|
||||||
_, _ = s.store.SaveLegacyRevision(name, formatted, "restored revision", actor)
|
_, _ = 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)
|
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||||||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
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) {
|
func fileNameFor(name string) (string, error) {
|
||||||
switch strings.TrimSpace(name) {
|
switch strings.TrimSpace(name) {
|
||||||
case "update-info":
|
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 {
|
if err := s.syncLegacyUpdateInfo(saved, parsed); err != nil {
|
||||||
return Document{}, err
|
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)
|
return s.Get(saved.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -40,6 +42,16 @@ type Package struct {
|
|||||||
UpdatedAt string `json:"updatedAt"`
|
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 {
|
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||||||
service := &Service{cfg: cfg, store: store}
|
service := &Service{cfg: cfg, store: store}
|
||||||
if len(noticeService) > 0 {
|
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))
|
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 {
|
func readJSON(path string) map[string]any {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,3 +366,40 @@ func sha256File(path string) string {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(hash.Sum(nil))
|
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
|
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) {
|
func TestCompareVersion(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -29,3 +38,68 @@ func TestDetectPackageMetadata(t *testing.T) {
|
|||||||
t.Fatalf("detectVersion returned %q", version)
|
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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -168,6 +169,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
|||||||
"last_checked_at": item.LastCheckedAt,
|
"last_checked_at": item.LastCheckedAt,
|
||||||
"last_error": item.LastError,
|
"last_error": item.LastError,
|
||||||
"consecutiveFailure": item.ConsecutiveFailure,
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
|
"meta": parseHealthMeta(item.LastError),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
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,
|
"lastCheckedAt": item.LastCheckedAt,
|
||||||
"lastError": item.LastError,
|
"lastError": item.LastError,
|
||||||
"consecutiveFailure": item.ConsecutiveFailure,
|
"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)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
|
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
|
||||||
return err
|
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()
|
start := time.Now()
|
||||||
resp, err := s.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
latency := int(time.Since(start).Milliseconds())
|
latency := int(time.Since(start).Milliseconds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error())
|
_ = 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()
|
defer resp.Body.Close()
|
||||||
status := "ok"
|
status := "ok"
|
||||||
message := ""
|
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 {
|
if resp.StatusCode >= 400 {
|
||||||
status = "degraded"
|
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)
|
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 {
|
func defaultString(value, fallback string) string {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return fallback
|
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.syncUpdatePublic(&result)
|
||||||
s.syncNotices(ctx, &result)
|
s.syncNotices(ctx, &result)
|
||||||
s.syncFeedbackSQLite(&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
|
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 {
|
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
||||||
continue
|
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"]++
|
result.Stats["importedRows"]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth.SetSessionCookie(w, sessionID)
|
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}})
|
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)
|
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
|
||||||
return
|
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})
|
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)
|
writeError(w, http.StatusBadRequest, "FEEDBACK_FAILED", err)
|
||||||
return
|
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})
|
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
|
return
|
||||||
}
|
}
|
||||||
switch path {
|
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":
|
case "/api/admin/releases":
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
||||||
case "/api/admin/releases/legacy-preview":
|
case "/api/admin/releases/legacy-preview":
|
||||||
|
|||||||
Reference in New Issue
Block a user