Update application UI and functionality
This commit is contained in:
@@ -112,10 +112,13 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
|
||||
return "", "", false, nil
|
||||
}
|
||||
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
||||
if err != nil || !ok {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if !ok {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
return "", "", false, nil
|
||||
}
|
||||
sessionID := randomToken(32)
|
||||
csrf := randomToken(32)
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -124,6 +125,46 @@ func TestLoginLocksAfterRepeatedFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginDatabaseCancellationDoesNotCountAsCredentialFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := db.Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "cancel-login.sqlite"),
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(store)
|
||||
captcha, err := service.NewCaptcha()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.mu.Lock()
|
||||
answer := service.captchas[captcha.ID].answer
|
||||
service.mu.Unlock()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, ok, err := service.Login(ctx, "admin", "admin", captcha.ID, answer, "127.0.0.1")
|
||||
if !errors.Is(err, context.Canceled) || ok {
|
||||
t.Fatalf("canceled login returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
service.mu.Lock()
|
||||
_, exists := service.loginAttempts[loginAttemptKey("admin", "127.0.0.1")]
|
||||
service.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("database cancellation was counted as a credential failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieUsesSecureForForwardedHTTPS(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
@@ -36,6 +36,7 @@ type Config struct {
|
||||
TimestampWindowSeconds int64 `json:"timestamp_window_seconds"`
|
||||
MaxRequestBytes int64 `json:"max_request_bytes"`
|
||||
MaxPackageBytes int64 `json:"max_package_bytes"`
|
||||
ReleaseUploadMaxBytes int64 `json:"release_upload_max_bytes"`
|
||||
Database DatabaseConfig `json:"database"`
|
||||
Mail MailConfig `json:"mail"`
|
||||
Branding BrandingConfig `json:"branding"`
|
||||
@@ -149,6 +150,7 @@ func defaults(root string) *Config {
|
||||
TimestampWindowSeconds: 600,
|
||||
MaxRequestBytes: 12 * 1024 * 1024,
|
||||
MaxPackageBytes: 10 * 1024 * 1024,
|
||||
ReleaseUploadMaxBytes: 1024 * 1024 * 1024,
|
||||
SourceCheckSeconds: 60,
|
||||
Database: DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
@@ -340,6 +342,11 @@ func applyEnv(cfg *Config) {
|
||||
cfg.MaxPackageBytes = parsed
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("YMHUT_RELEASE_UPLOAD_MAX_BYTES"); value != "" {
|
||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
cfg.ReleaseUploadMaxBytes = parsed
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("YMHUT_SOURCE_CHECK_SECONDS"); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.SourceCheckSeconds = parsed
|
||||
@@ -453,6 +460,9 @@ func normalize(root string, cfg *Config) {
|
||||
if cfg.MaxPackageBytes <= 0 {
|
||||
cfg.MaxPackageBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if cfg.ReleaseUploadMaxBytes <= 0 {
|
||||
cfg.ReleaseUploadMaxBytes = 1024 * 1024 * 1024
|
||||
}
|
||||
if cfg.UploadGuard.MaxZipFiles <= 0 {
|
||||
cfg.UploadGuard.MaxZipFiles = 80
|
||||
}
|
||||
|
||||
@@ -100,3 +100,39 @@ func TestLoadRewritesAbsoluteConfigPaths(t *testing.T) {
|
||||
t.Fatalf("config still contains absolute base path: %s", string(rewritten))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUploadLimitCanBeConfiguredFromEnvironment(t *testing.T) {
|
||||
t.Setenv("YMHUT_RELEASE_UPLOAD_MAX_BYTES", "67108864")
|
||||
cfg := defaults(t.TempDir())
|
||||
applyEnv(cfg)
|
||||
normalize(cfg.BaseDir, cfg)
|
||||
if cfg.ReleaseUploadMaxBytes != 64*1024*1024 {
|
||||
t.Fatalf("ReleaseUploadMaxBytes = %d, want %d", cfg.ReleaseUploadMaxBytes, 64*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightReportsMissingAdminAssetName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
if err := os.MkdirAll(filepath.Join(cfg.AdminWebDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(cfg.AdminWebDir, "index.html"),
|
||||
[]byte(`<script type="module" src="/admin/assets/missing.js"></script>`),
|
||||
0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checks := Preflight(cfg)
|
||||
admin := checks[0]
|
||||
for _, check := range checks {
|
||||
if check.Name == "admin web dist" {
|
||||
admin = check
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(admin.Message, "assets/missing.js") {
|
||||
t.Fatalf("admin preflight message = %q, want missing asset name", admin.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
@@ -44,13 +46,47 @@ func Preflight(cfg *Config) []Check {
|
||||
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"),
|
||||
checkAdminWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
|
||||
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
|
||||
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
var adminPreflightAssetPattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
|
||||
|
||||
func checkAdminWebBuild(name, path, embedRoot string) Check {
|
||||
check := checkWebBuild(name, path, embedRoot)
|
||||
if check.Status != "ok" || strings.Contains(check.Message, "embedded frontend assets") {
|
||||
return check
|
||||
}
|
||||
index := filepath.Join(path, "index.html")
|
||||
data, err := os.ReadFile(index)
|
||||
if err != nil {
|
||||
return check
|
||||
}
|
||||
matches := adminPreflightAssetPattern.FindAllSubmatch(data, -1)
|
||||
if len(matches) == 0 {
|
||||
return Check{Name: name, Status: "error", Path: index, Message: "index.html does not reference any /admin/assets files"}
|
||||
}
|
||||
for _, match := range matches {
|
||||
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
|
||||
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||
return Check{Name: name, Status: "error", Path: index, Message: fmt.Sprintf("invalid admin asset reference %s", assetPath)}
|
||||
}
|
||||
info, statErr := os.Stat(filepath.Join(path, filepath.FromSlash(assetPath)))
|
||||
if statErr == nil && !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("disk asset %s is missing", assetPath)
|
||||
if embeddedWebBuildOK(embedRoot) {
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: message + "; using embedded frontend assets"}
|
||||
}
|
||||
return Check{Name: name, Status: "error", Path: filepath.Join(path, filepath.FromSlash(assetPath)), Message: message}
|
||||
}
|
||||
return check
|
||||
}
|
||||
|
||||
func checkDir(name, path string, create bool) Check {
|
||||
if create {
|
||||
if err := os.MkdirAll(path, 0o750); err != nil {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
user, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, password)
|
||||
user, ok, err := s.verifyAdminPasswordOnContext(ctx, s.localDB, s.localDialect, username, password)
|
||||
if err == nil && (ok || user.Username != "") {
|
||||
return user, ok, nil
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
user, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
|
||||
user, ok, err := s.verifyAdminPasswordOnContext(ctx, remote, remoteDialect, username, password)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
@@ -67,12 +67,16 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
|
||||
}
|
||||
|
||||
func (s *Store) verifyAdminPasswordOn(conn *sql.DB, d dialect, username, password string) (AdminUser, bool, error) {
|
||||
return s.verifyAdminPasswordOnContext(context.Background(), conn, d, username, password)
|
||||
}
|
||||
|
||||
func (s *Store) verifyAdminPasswordOnContext(ctx context.Context, 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).
|
||||
err := conn.QueryRowContext(ctx, 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
|
||||
@@ -104,12 +108,12 @@ func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, cu
|
||||
return "", err
|
||||
}
|
||||
username = firstNonEmpty(strings.TrimSpace(username), "admin")
|
||||
_, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, current)
|
||||
_, ok, err := s.verifyAdminPasswordOnContext(ctx, s.localDB, s.localDialect, username, current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
remoteOK, remoteErr := s.verifyRemoteAdminPassword(username, current)
|
||||
remoteOK, remoteErr := s.verifyRemoteAdminPassword(ctx, username, current)
|
||||
if remoteErr != nil {
|
||||
s.markFailover(remoteErr)
|
||||
}
|
||||
@@ -150,14 +154,14 @@ func validateAdminPasswordChange(current, next string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) verifyRemoteAdminPassword(username, password string) (bool, error) {
|
||||
func (s *Store) verifyRemoteAdminPassword(ctx context.Context, 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)
|
||||
_, ok, err := s.verifyAdminPasswordOnContext(ctx, remote, remoteDialect, username, password)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -170,10 +171,18 @@ func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
|
||||
}
|
||||
|
||||
func (s *Store) InsertAudit(log AuditLog) error {
|
||||
return s.InsertAuditContext(context.Background(), log)
|
||||
}
|
||||
|
||||
func (s *Store) InsertAuditContext(ctx context.Context, 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 (?, ?, ?, ?, ?, ?, ?)`,
|
||||
conn, d := s.active()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("database is not available")
|
||||
}
|
||||
_, err := conn.ExecContext(ctx, d.rebind(`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
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
)
|
||||
@@ -109,6 +111,45 @@ func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAdminPasswordHonorsContextDeadlineWhenSQLiteIsBusy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "busy-login.sqlite"),
|
||||
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)
|
||||
}
|
||||
|
||||
conn, err := store.localDB.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
_, ok, err := store.VerifyAdminPassword(ctx, "admin", "admin")
|
||||
if !errors.Is(err, context.DeadlineExceeded) || ok {
|
||||
t.Fatalf("busy login returned ok=%v err=%v, want deadline exceeded", ok, err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("busy login ignored context deadline for %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
|
||||
@@ -61,6 +61,19 @@ type UploadOptions struct {
|
||||
UpdateManifest bool
|
||||
}
|
||||
|
||||
type UploadedPackageFile struct {
|
||||
TempPath string
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUploadedPackageMissing = errors.New("uploaded file is missing")
|
||||
ErrUploadedPackageEmpty = errors.New("uploaded file is empty")
|
||||
ErrUploadedPackageStorageFailed = errors.New("upload storage failed")
|
||||
ErrUploadedPackageManifestFailed = errors.New("manifest update failed")
|
||||
)
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||||
service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}}
|
||||
if len(noticeService) > 0 {
|
||||
@@ -226,7 +239,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
|
||||
return Package{}, err
|
||||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||||
}
|
||||
target := filepath.Join(s.cfg.DownloadsDir, name)
|
||||
resolved, err := filepath.Abs(target)
|
||||
@@ -288,6 +301,108 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageFile, opts UploadOptions, actor string) (Package, error) {
|
||||
name, err := safePackageName(opts.FileName)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if uploaded.TempPath == "" {
|
||||
return Package{}, ErrUploadedPackageMissing
|
||||
}
|
||||
if uploaded.Size <= 0 {
|
||||
return Package{}, ErrUploadedPackageEmpty
|
||||
}
|
||||
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")
|
||||
}
|
||||
if err := os.Chmod(uploaded.TempPath, 0o640); err != nil {
|
||||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||||
}
|
||||
backup, err := backupExistingPackage(target)
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||||
}
|
||||
if err := os.Rename(uploaded.TempPath, target); err != nil {
|
||||
_ = restorePackageBackup(target, backup)
|
||||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, 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: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + "/downloads/" + name,
|
||||
SHA256: strings.ToLower(uploaded.SHA256),
|
||||
Size: uploaded.Size,
|
||||
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 {
|
||||
if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil {
|
||||
return Package{}, fmt.Errorf("%w: %v; rollback failed: %v", ErrUploadedPackageManifestFailed, err, rollbackErr)
|
||||
}
|
||||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)
|
||||
}
|
||||
}
|
||||
if backup != "" {
|
||||
_ = os.Remove(backup)
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(uploaded.Size))})
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func backupExistingPackage(target string) (string, error) {
|
||||
if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".backup-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backup := file.Name()
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(backup)
|
||||
return "", err
|
||||
}
|
||||
if err := os.Remove(backup); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
func restorePackageBackup(target, backup string) error {
|
||||
if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if backup == "" {
|
||||
return nil
|
||||
}
|
||||
return os.Rename(backup, target)
|
||||
}
|
||||
|
||||
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := s.legacyUpdateBase()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package releases
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -132,3 +133,104 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
|
||||
t.Fatal("expected unsafe filename to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreparedPackageReplacesExistingFile(t *testing.T) {
|
||||
service, cfg, cleanup := newPreparedPackageTestService(t)
|
||||
defer cleanup()
|
||||
name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
|
||||
target := filepath.Join(cfg.DownloadsDir, name)
|
||||
if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
|
||||
if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := service.SavePreparedPackage(
|
||||
httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
|
||||
UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
|
||||
UploadOptions{FileName: name},
|
||||
"admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "new package" {
|
||||
t.Fatalf("target contains %q, want new package", data)
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(cfg.DownloadsDir, "."+name+".backup-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != 0 {
|
||||
t.Fatalf("successful replacement left backups: %v", backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreparedPackageRestoresExistingFileWhenManifestFails(t *testing.T) {
|
||||
service, cfg, cleanup := newPreparedPackageTestService(t)
|
||||
defer cleanup()
|
||||
name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
|
||||
if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(cfg.DownloadsDir, name)
|
||||
if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
|
||||
if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := filepath.Join(cfg.BaseDir, "manifest-blocked")
|
||||
if err := os.WriteFile(blocked, []byte("not a directory"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.UpdatePublicDir = blocked
|
||||
|
||||
_, err := service.SavePreparedPackage(
|
||||
httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
|
||||
UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
|
||||
UploadOptions{FileName: name, UpdateManifest: true},
|
||||
"admin")
|
||||
if !errors.Is(err, ErrUploadedPackageManifestFailed) {
|
||||
t.Fatalf("got %v, want manifest failure", err)
|
||||
}
|
||||
data, readErr := os.ReadFile(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(data) != "old package" {
|
||||
t.Fatalf("rollback restored %q, want old package", data)
|
||||
}
|
||||
}
|
||||
|
||||
func newPreparedPackageTestService(t *testing.T) (*Service, *config.Config, func()) {
|
||||
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"),
|
||||
HealthIntervalSec: 30,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewService(cfg, store), cfg, func() { _ = store.Close() }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
@@ -22,27 +28,15 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
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)
|
||||
uploaded, opts, cleanup, err := r.readReleasePackageUpload(w, req)
|
||||
if err != nil {
|
||||
writeReleaseUploadError(w, err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
defer cleanup()
|
||||
pkg, err := r.releases.SavePreparedPackage(req, uploaded, opts, "admin")
|
||||
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)
|
||||
writeReleaseUploadError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
@@ -55,6 +49,176 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const releaseUploadFieldMaxBytes = 1 << 20
|
||||
|
||||
type releaseUploadError struct {
|
||||
status int
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e releaseUploadError) Error() string { return e.err.Error() }
|
||||
|
||||
func newReleaseUploadError(status int, code string, err error) error {
|
||||
return releaseUploadError{status: status, code: code, err: err}
|
||||
}
|
||||
|
||||
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
|
||||
limit := r.releaseUploadLimit()
|
||||
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
|
||||
reader, err := req.MultipartReader()
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
}
|
||||
if err := os.MkdirAll(r.cfg.DownloadsDir, 0o750); err != nil {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||
}
|
||||
|
||||
fields := map[string]string{}
|
||||
uploadedName := ""
|
||||
uploaded := releases.UploadedPackageFile{}
|
||||
cleanup := func() {}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, classifyMultipartReadError(err)
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
if name != "file" {
|
||||
value, err := readReleaseUploadField(part)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
|
||||
}
|
||||
fields[name] = value
|
||||
continue
|
||||
}
|
||||
if uploaded.TempPath != "" {
|
||||
_ = part.Close()
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("multiple file fields are not supported"))
|
||||
}
|
||||
uploadedName = part.FileName()
|
||||
prepared, err := r.streamReleaseUploadPart(part, limit)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
|
||||
}
|
||||
uploaded = prepared
|
||||
cleanup = func() { _ = os.Remove(prepared.TempPath) }
|
||||
}
|
||||
if uploaded.TempPath == "" {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, cleanup, newReleaseUploadError(http.StatusBadRequest, "FILE_REQUIRED", errors.New("file is required"))
|
||||
}
|
||||
opts := releases.UploadOptions{
|
||||
FileName: firstNonEmpty(fields["fileName"], uploadedName),
|
||||
Version: fields["version"],
|
||||
Platform: fields["platform"],
|
||||
Arch: fields["arch"],
|
||||
Channel: fields["channel"],
|
||||
Notes: fields["notes"],
|
||||
UpdateManifest: fields["updateManifest"] == "true" || fields["updateManifest"] == "1",
|
||||
}
|
||||
return uploaded, opts, cleanup, nil
|
||||
}
|
||||
|
||||
func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (releases.UploadedPackageFile, error) {
|
||||
tmp, err := os.CreateTemp(r.cfg.DownloadsDir, ".release-package-*.upload")
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
removeOnError := true
|
||||
defer func() {
|
||||
if removeOnError {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
hash := sha256.New()
|
||||
limited := &io.LimitedReader{R: part, N: limit + 1}
|
||||
written, err := io.Copy(tmp, io.TeeReader(limited, hash))
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
|
||||
}
|
||||
if written > limit {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", fmt.Errorf("发布包超过上传上限 %s", formatReleaseUploadBytes(limit)))
|
||||
}
|
||||
if written <= 0 {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusBadRequest, "PACKAGE_EMPTY", releases.ErrUploadedPackageEmpty)
|
||||
}
|
||||
removeOnError = false
|
||||
return releases.UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
func readReleaseUploadField(part *multipart.Part) (string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(part, releaseUploadFieldMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", classifyMultipartReadError(err)
|
||||
}
|
||||
if len(data) > releaseUploadFieldMaxBytes {
|
||||
return "", newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("form field is too large"))
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func classifyMultipartReadError(err error) error {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
|
||||
}
|
||||
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
}
|
||||
|
||||
func (r *router) releaseUploadLimit() int64 {
|
||||
if r.cfg != nil && r.cfg.ReleaseUploadMaxBytes > 0 {
|
||||
return r.cfg.ReleaseUploadMaxBytes
|
||||
}
|
||||
return 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
func formatReleaseUploadBytes(value int64) string {
|
||||
const megabyte = 1024 * 1024
|
||||
if value < megabyte {
|
||||
return fmt.Sprintf("%d KB", (value+1023)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(value)/megabyte)
|
||||
}
|
||||
|
||||
func writeReleaseUploadError(w http.ResponseWriter, err error) {
|
||||
var uploadErr releaseUploadError
|
||||
if errors.As(err, &uploadErr) {
|
||||
writeError(w, uploadErr.status, uploadErr.code, uploadErr.err)
|
||||
return
|
||||
}
|
||||
status := http.StatusBadRequest
|
||||
code := "PACKAGE_UPLOAD_FAILED"
|
||||
if errors.Is(err, releases.ErrUploadedPackageManifestFailed) {
|
||||
status = http.StatusInternalServerError
|
||||
code = "MANIFEST_UPDATE_FAILED"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) {
|
||||
status = http.StatusInternalServerError
|
||||
code = "UPLOAD_STORAGE_FAILED"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageEmpty) {
|
||||
code = "PACKAGE_EMPTY"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageMissing) {
|
||||
code = "FILE_REQUIRED"
|
||||
}
|
||||
writeError(w, status, code, err)
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
@@ -79,6 +79,7 @@ func localizedErrorMessage(code, message string) string {
|
||||
byCode := map[string]string{
|
||||
"UNAUTHORIZED": "需要登录后继续操作",
|
||||
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
||||
"LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
|
||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||
"DATABASE_TEST_FAILED": "数据库连接测试失败",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -31,6 +32,8 @@ type router struct {
|
||||
publicSnapshots *publicSnapshotService
|
||||
}
|
||||
|
||||
const loginRequestTimeout = 5 * time.Second
|
||||
|
||||
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
|
||||
r := &router{
|
||||
cfg: cfg,
|
||||
@@ -55,7 +58,10 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f
|
||||
|
||||
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if strings.HasPrefix(path, "/api/admin/") && req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
if strings.HasPrefix(path, "/api/admin/") &&
|
||||
path != "/api/admin/auth/login" &&
|
||||
path != "/api/admin/auth/logout" &&
|
||||
req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
captured := &mutationResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
w = captured
|
||||
defer func() {
|
||||
@@ -116,7 +122,7 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case strings.HasPrefix(path, "/downloads/"):
|
||||
r.handleDownload(w, req)
|
||||
case strings.HasPrefix(path, "/admin/assets/"):
|
||||
serveStaticAsset(w, req, r.cfg.AdminWebDir, "admin/dist", strings.TrimPrefix(path, "/admin/"))
|
||||
r.serveAdminAsset(w, req, strings.TrimPrefix(path, "/admin/"))
|
||||
case strings.HasPrefix(path, "/assets/"):
|
||||
serveStaticAsset(w, req, r.cfg.PortalWebDir, "portal/dist", strings.TrimPrefix(path, "/"))
|
||||
case strings.HasPrefix(path, "/api/admin/feedbacks"):
|
||||
@@ -196,6 +202,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -209,8 +217,14 @@ 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, req.RemoteAddr)
|
||||
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
|
||||
defer cancel()
|
||||
sessionID, csrf, ok, err := r.auth.Login(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
||||
return
|
||||
}
|
||||
@@ -219,8 +233,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
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}})
|
||||
go r.recordLoginAudit(body.Username, req.RemoteAddr, req.UserAgent())
|
||||
}
|
||||
|
||||
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.store.InsertAuditContext(ctx, db.AuditLog{
|
||||
Actor: username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: remoteAddr, UserAgent: userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -598,6 +598,148 @@ func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
||||
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
|
||||
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
|
||||
}
|
||||
if got := res.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("%s cache control = %q, want no-store", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAdminAssetIsNotCached(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/assets/missing.js", nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing asset returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("missing asset cache control = %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleasePackageUploadStreamsMultipart(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := newReleaseUploadRequest(t, "YMhut_Box_WinUI_Setup_2.0.8_x64.exe", []byte("package bytes"), true)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("upload 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["ok"] != true {
|
||||
t.Fatalf("unexpected upload payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
include bool
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"},
|
||||
{name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"},
|
||||
{name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"},
|
||||
{name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileName := "package.exe"
|
||||
if tc.name == "unsafe-name" {
|
||||
fileName = "../package.exe"
|
||||
}
|
||||
req := newReleaseUploadRequest(t, fileName, tc.data, tc.include)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != tc.wantStatus || !strings.Contains(res.Body.String(), tc.wantCode) {
|
||||
t.Fatalf("upload returned %d %s, want %d containing %s", res.Code, res.Body.String(), tc.wantStatus, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if includeFile {
|
||||
part, err := writer.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.WriteField("version", "2.0.8"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
if err := writer.WriteField("fileName", name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") {
|
||||
t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &router{cfg: &config.Config{AdminWebDir: dir}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil)
|
||||
res := httptest.NewRecorder()
|
||||
r.serveAdminAsset(res, req, "assets/stale.js")
|
||||
if res.Code != http.StatusNotFound {
|
||||
t.Fatalf("incomplete disk asset returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if strings.Contains(res.Body.String(), "stale disk asset") {
|
||||
t.Fatal("incomplete disk build served a stale disk asset")
|
||||
}
|
||||
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("incomplete asset cache control = %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +938,26 @@ func loginForTest(handler http.Handler) (string, string, error) {
|
||||
return "", "", errors.New("session cookie not set")
|
||||
}
|
||||
|
||||
func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
body := bytes.NewBufferString(`{"username":"admin","password":"wrong","captchaId":"missing","captcha":"00000"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
started := time.Now()
|
||||
handler.ServeHTTP(res, req)
|
||||
if time.Since(started) > time.Second {
|
||||
t.Fatal("invalid login request did not return promptly")
|
||||
}
|
||||
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "LOGIN_FAILED") {
|
||||
t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("login cache control = %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestCaptcha(dataURL string) (string, error) {
|
||||
const prefix = "data:image/png;base64,"
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
||||
@@ -936,6 +1098,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
TimestampWindowSeconds: 600,
|
||||
MaxRequestBytes: 12 << 20,
|
||||
MaxPackageBytes: 10 << 20,
|
||||
ReleaseUploadMaxBytes: 1 << 20,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
|
||||
@@ -3,10 +3,13 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,6 +49,7 @@ func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot,
|
||||
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
@@ -53,6 +57,29 @@ func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, asse
|
||||
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
|
||||
}
|
||||
|
||||
func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
|
||||
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
||||
return
|
||||
}
|
||||
setStaticCacheHeaders(w, assetPath)
|
||||
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
|
||||
if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) {
|
||||
return
|
||||
}
|
||||
log.Printf("admin web disk build is missing requested asset: %s", assetPath)
|
||||
} else {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err)
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
|
||||
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
|
||||
}
|
||||
@@ -108,11 +135,13 @@ func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
||||
if _, err := os.Stat(index); err == nil {
|
||||
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
|
||||
http.ServeFile(w, req, index)
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("admin web disk build is incomplete: %v", err)
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
||||
return
|
||||
@@ -121,6 +150,35 @@ func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
_, _ = 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>`))
|
||||
}
|
||||
|
||||
var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
|
||||
|
||||
func validateAdminDiskBuild(root string) error {
|
||||
index := filepath.Join(root, "index.html")
|
||||
data, err := os.ReadFile(index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches := adminAssetReferencePattern.FindAllSubmatch(data, -1)
|
||||
if len(matches) == 0 {
|
||||
return errors.New("index.html does not reference any admin assets")
|
||||
}
|
||||
for _, match := range matches {
|
||||
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
|
||||
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||
return fmt.Errorf("invalid admin asset reference %s", assetPath)
|
||||
}
|
||||
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
return fmt.Errorf("missing %s: %w", assetPath, statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s is not a file", assetPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
|
||||
extension := strings.ToLower(filepath.Ext(assetPath))
|
||||
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" {
|
||||
|
||||
@@ -66,6 +66,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
const loading = ref(false);
|
||||
const loginPending = ref(false);
|
||||
const toast = ref<ToastState | null>(null);
|
||||
const autoRefreshPaused = ref(false);
|
||||
const databaseFormEditing = ref(false);
|
||||
@@ -490,7 +491,7 @@ function isAuthError(raw: string, message: string) {
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
|
||||
captcha.value = await adminFetch<Captcha>("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
|
||||
}
|
||||
|
||||
async function loadAuthBootstrap() {
|
||||
@@ -498,17 +499,35 @@ async function loadAuthBootstrap() {
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
|
||||
if (loginPending.value) return;
|
||||
if (!loginForm.password || !loginForm.captcha || !captcha.value?.captchaId) {
|
||||
setToast("请填写密码和验证码", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
loginPending.value = true;
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||
});
|
||||
}, { timeoutMs: 8000 });
|
||||
csrf.value = data.csrfToken;
|
||||
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
connectAdminEvents();
|
||||
navigate("/admin/dashboard");
|
||||
});
|
||||
} catch (error) {
|
||||
const message = toChineseError(error instanceof Error ? error.message : String(error));
|
||||
setToast(message, "error");
|
||||
loginForm.captcha = "";
|
||||
void loadCaptcha().catch(() => {
|
||||
captcha.value = null;
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loginPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -747,6 +766,7 @@ async function uploadPackage() {
|
||||
setToast("请选择要上传的发布包", "warn");
|
||||
return;
|
||||
}
|
||||
let completed = false;
|
||||
await guarded(async () => {
|
||||
const form = new FormData();
|
||||
form.append("file", uploadDraft.file as File);
|
||||
@@ -771,6 +791,7 @@ async function uploadPackage() {
|
||||
uploadDraft.status = "上传完成";
|
||||
uploadDraft.file = null;
|
||||
uploadDraft.notes = "";
|
||||
completed = true;
|
||||
setToast("发布包已上传并放入下载目录");
|
||||
await loadReleases();
|
||||
window.setTimeout(() => {
|
||||
@@ -783,6 +804,12 @@ async function uploadPackage() {
|
||||
}, 1200);
|
||||
}).finally(() => {
|
||||
uploadDraft.uploading = false;
|
||||
if (!completed) {
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
uploadDraft.status = "上传失败,可直接重试";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1709,19 +1736,19 @@ function connectAdminEvents() {
|
||||
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
|
||||
</p>
|
||||
<form class="form-stack" @submit.prevent="login">
|
||||
<label>账号<input v-model="loginForm.username" autocomplete="username" /></label>
|
||||
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" /></label>
|
||||
<label>账号<input v-model="loginForm.username" autocomplete="username" :disabled="loginPending" /></label>
|
||||
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" :disabled="loginPending" /></label>
|
||||
<label>
|
||||
验证码
|
||||
<div class="captcha-row">
|
||||
<input v-model="loginForm.captcha" />
|
||||
<button class="captcha-button" type="button" title="刷新验证码" @click="loadCaptcha">
|
||||
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
|
||||
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending" @click="loadCaptcha">
|
||||
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<Button class="full" type="submit" label="登录" />
|
||||
<Button class="full" type="submit" :label="loginPending ? '正在登录…' : '登录'" :loading="loginPending" :disabled="loginPending" />
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@ export type UploadProgress = {
|
||||
|
||||
export type AdminApiOptions = {
|
||||
csrf?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const exactMessages: Record<string, string> = {
|
||||
@@ -31,6 +32,7 @@ const exactMessages: Record<string, string> = {
|
||||
const codeMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: "需要登录后继续操作",
|
||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||
LOGIN_TIMEOUT: "登录校验超时,请稍后重试",
|
||||
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
||||
INVALID_PAYLOAD: "提交内容格式不正确",
|
||||
DATABASE_TEST_FAILED: "数据库连接测试失败",
|
||||
@@ -42,6 +44,11 @@ const codeMessages: Record<string, string> = {
|
||||
NOTICE_SAVE_FAILED: "版本日志保存失败",
|
||||
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
|
||||
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
|
||||
FILE_REQUIRED: "请选择要上传的发布包",
|
||||
PACKAGE_EMPTY: "发布包不能为空",
|
||||
PACKAGE_TOO_LARGE: "发布包超过服务端上传上限",
|
||||
UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
|
||||
MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
|
||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||
CHECK_FAILED: "接口健康检测失败",
|
||||
@@ -56,12 +63,27 @@ export async function adminFetch<T>(target: string, init: RequestInit = {}, opti
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, options.timeoutMs ?? 20000);
|
||||
const timeout = window.setTimeout(() => controller.abort("timeout"), timeoutMs);
|
||||
const forwardAbort = () => controller.abort(init.signal?.reason);
|
||||
init.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
try {
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include", signal: controller.signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(adminErrorMessage(data, res.status));
|
||||
}
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted && !init.signal?.aborted) {
|
||||
throw new Error("请求超时,服务端未及时响应");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
init.signal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
|
||||
@@ -76,7 +98,7 @@ export function uploadAdminFile<T>(target: string, form: FormData, options: Admi
|
||||
xhr.onload = () => {
|
||||
const data = parseJSONSafe(xhr.responseText, {});
|
||||
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
|
||||
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
|
||||
reject(new Error(adminErrorMessage(data, xhr.status)));
|
||||
return;
|
||||
}
|
||||
resolve(data as T);
|
||||
@@ -87,6 +109,15 @@ export function uploadAdminFile<T>(target: string, form: FormData, options: Admi
|
||||
});
|
||||
}
|
||||
|
||||
function adminErrorMessage(data: any, status: number) {
|
||||
const code = String(data?.error || "").trim();
|
||||
const detail = String(data?.message || "").trim();
|
||||
if (code && codeMessages[code]) {
|
||||
return codeMessages[code];
|
||||
}
|
||||
return toChineseError(detail || code || `HTTP ${status}`);
|
||||
}
|
||||
|
||||
export function toChineseError(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
@@ -8,6 +8,64 @@ import "primeicons/primeicons.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
const resourceReloadKey = "ymhut.admin.resource-reload";
|
||||
|
||||
function isAdminResourceFailure(value: unknown) {
|
||||
const message = value instanceof Error ? value.message : String(value || "");
|
||||
return /failed to fetch dynamically imported module|loading chunk|module script|importing a module/i.test(message);
|
||||
}
|
||||
|
||||
function showResourceFailure() {
|
||||
if (document.getElementById("admin-resource-failure")) return;
|
||||
const notice = document.createElement("div");
|
||||
notice.id = "admin-resource-failure";
|
||||
notice.setAttribute("role", "alert");
|
||||
notice.textContent = "后台资源加载失败,请刷新页面后重试。";
|
||||
Object.assign(notice.style, {
|
||||
position: "fixed",
|
||||
inset: "16px 16px auto 16px",
|
||||
zIndex: "2147483647",
|
||||
padding: "12px 16px",
|
||||
border: "1px solid #dc2626",
|
||||
borderRadius: "6px",
|
||||
color: "#7f1d1d",
|
||||
background: "#fef2f2",
|
||||
fontFamily: "Segoe UI, sans-serif",
|
||||
fontSize: "14px",
|
||||
});
|
||||
document.body.appendChild(notice);
|
||||
}
|
||||
|
||||
function recoverAdminResources() {
|
||||
const canonical = new URL(location.href);
|
||||
canonical.searchParams.delete("_admin_reload");
|
||||
const marker = `${canonical.pathname}${canonical.search}`;
|
||||
if (sessionStorage.getItem(resourceReloadKey) === marker) {
|
||||
showResourceFailure();
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(resourceReloadKey, marker);
|
||||
const next = new URL(location.href);
|
||||
next.searchParams.set("_admin_reload", Date.now().toString());
|
||||
location.replace(next);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const target = event.target as HTMLScriptElement | HTMLLinkElement | null;
|
||||
const resource = target instanceof HTMLScriptElement
|
||||
? target.src
|
||||
: target instanceof HTMLLinkElement
|
||||
? target.href
|
||||
: "";
|
||||
if (resource.includes("/admin/assets/") || isAdminResourceFailure(event.error || event.message)) {
|
||||
recoverAdminResources();
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (isAdminResourceFailure(event.reason)) recoverAdminResources();
|
||||
});
|
||||
|
||||
const RoutePlaceholder = { template: "<span />" };
|
||||
|
||||
const routes = [
|
||||
@@ -57,3 +115,5 @@ createApp(App)
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.mount("#app");
|
||||
|
||||
window.setTimeout(() => sessionStorage.removeItem(resourceReloadKey), 30000);
|
||||
|
||||
Reference in New Issue
Block a user