diff --git a/installer/YMhutBox-EULA.zh-CN.txt b/installer/YMhutBox-EULA.zh-CN.txt new file mode 100644 index 0000000..7daa498 --- /dev/null +++ b/installer/YMhutBox-EULA.zh-CN.txt @@ -0,0 +1,48 @@ +YMhut Box 软件许可与服务协议 + +更新日期:2026 年 7 月 25 日 + +请在安装和使用 YMhut Box 前完整阅读本协议。点击“我已阅读并同意”或继续安装,即表示您已理解并同意本协议的全部内容;如不同意,请退出安装程序。 + +一、许可范围 + +1. YMhut Box 授予您一项有限的、非独占的、不可转让的使用许可,用于在您拥有或合法管理的 Windows 设备上安装和使用本软件。 +2. 本许可不代表软件、品牌、界面设计或相关服务的所有权发生转移。 +3. 未经明确授权,不得利用本软件从事违法活动、破坏系统或网络安全、侵害他人权益,或规避第三方服务的付费、地区及版权限制。 + +二、安装、更新与卸载 + +1. 安装程序会检查 Windows 版本、系统架构、目标目录、磁盘空间以及必要运行组件。 +2. 当 WebView2 Runtime 或 Microsoft Visual C++ x64 Runtime 缺失时,安装程序可能从微软官方地址下载并安装相应组件,并可能请求管理员权限。 +3. 升级或修复安装将尽量保留用户设置和本地数据;卸载前请自行备份重要内容。 + +三、数据与网络访问 + +1. 软件设置、缓存、登录凭据和运行日志原则上保存在本机;敏感登录信息按产品实现使用 Windows 系统能力进行保护。 +2. 天气、更新、反馈、网络音乐和其他联网功能会访问相应服务提供方。具体可用性、内容和处理规则同时受该服务提供方条款约束。 +3. 为诊断故障,软件可能在本机记录必要的错误信息。日志不应被用于存储用户主动输入的密码等敏感内容。 + +四、第三方组件与服务 + +1. 软件可能包含或调用微软运行库、硬件监控组件及其他第三方组件。相关组件的权利由其权利人享有,并适用各自的许可或服务条款。 +2. 对于跳转至第三方网站、下载地址或服务产生的内容、费用、账户及可用性,应以第三方实际规则为准。 + +五、功能与风险提示 + +1. 硬件监控结果受设备、驱动、权限和传感器能力影响,仅供参考。 +2. 系统优化、注册表、服务、电源、网络和驱动相关操作可能改变系统行为。请在执行前阅读变更预览并保留可用备份或还原点。 +3. 因设备差异、系统策略、第三方服务中断或不可抗力,部分功能可能暂时不可用。 + +六、责任限制 + +在法律允许的范围内,YMhut Box 不对因错误配置、未经授权的系统操作、第三方服务变化、网络故障或用户未备份数据造成的间接损失承担责任。本条不排除依法不得限制或排除的责任。 + +七、协议更新与终止 + +1. 软件功能、服务方式或合规要求发生变化时,本协议可能更新。重要变更将在软件或发布渠道中提示。 +2. 如您违反本协议或利用软件从事违法行为,相关使用许可可以被终止。 +3. 协议终止后,您应停止使用并可通过系统卸载功能移除软件。 + +八、联系与反馈 + +您可以通过 YMhut Box 内置反馈页面提交功能建议、问题报告或协议相关咨询。 diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss index 4d84b32..e2e3903 100644 --- a/installer/ymhut_box_winui.iss +++ b/installer/ymhut_box_winui.iss @@ -482,6 +482,11 @@ begin QueueInstallOutput(Text, True, True); end; +procedure LogBootstrapEvent(const EventName, Value: string); +begin + Log('YMHUT_EVENT:' + EventName + '|' + Value); +end; + procedure LogCurrentExtractFile(); var FileName: string; @@ -718,28 +723,67 @@ begin Result := FmtMessage(CustomMessage(Key), [Value1, Value2]); end; -function DownloadAndInstallPrerequisite(const Url, FileName, Arguments, FriendlyName: string; var NeedsRestart: Boolean): Boolean; +function AcquireAndInstallPrerequisite( + const Url, DownloadFileName, BundledFileName, Arguments, FriendlyName: string; + const Bundled: Boolean; var NeedsRestart: Boolean): Boolean; var + Attempt: Integer; + Downloaded: Boolean; + LastDownloadError: string; ResultCode: Integer; ExecutablePath: string; begin Result := False; - ExecutablePath := ExpandConstant('{tmp}\' + FileName); - DownloadPage.Clear; - DownloadPage.Add(Url, FileName, ''); - if not WizardSilent then - DownloadPage.Show; - try - DownloadPage.Download; - except - if DownloadPage.AbortedByUser then - RaiseException(FormatCustomMessage('DependencyDownloadCancelled', FriendlyName)) - else - RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, GetExceptionMessage)); - end; - if not WizardSilent then - DownloadPage.Hide; + if Bundled then + begin + LogBootstrapEvent('DEPENDENCY', 'bundled|' + FriendlyName); + ExtractTemporaryFile(BundledFileName); + ExecutablePath := ExpandConstant('{tmp}\' + BundledFileName); + end + else + begin + ExecutablePath := ExpandConstant('{tmp}\' + DownloadFileName); + Downloaded := False; + LastDownloadError := ''; + for Attempt := 1 to 3 do + begin + LogBootstrapEvent('DEPENDENCY', 'download|' + FriendlyName); + DownloadPage.Clear; + DownloadPage.Add(Url, DownloadFileName, ''); + if not WizardSilent then + DownloadPage.Show; + try + try + DownloadPage.Download; + Downloaded := True; + except + if DownloadPage.AbortedByUser then + RaiseException(FormatCustomMessage('DependencyDownloadCancelled', FriendlyName)) + else + LastDownloadError := GetExceptionMessage; + end; + finally + if not WizardSilent then + DownloadPage.Hide; + end; + if Downloaded then + Break; + if Attempt < 3 then + begin + LogBootstrapEvent('DEPENDENCY', 'retry|' + FriendlyName); + Sleep(Attempt * 1500); + end; + end; + + if not Downloaded then + RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, LastDownloadError)); + end; + + if not FileExists(ExecutablePath) then + RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, 'downloaded file is missing')); + + LogBootstrapEvent('DEPENDENCY', 'install|' + FriendlyName); if not Exec(ExecutablePath, Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then RaiseException(FormatCustomMessage('DependencyStartFailed', FriendlyName)); @@ -749,9 +793,16 @@ begin if (ResultCode = 3010) or (ResultCode = 1641) then NeedsRestart := True; + LogBootstrapEvent('DEPENDENCY', 'complete|' + FriendlyName); Result := True; end; +procedure SetSetupFailure(var ResultText: string; const MessageText: string); +begin + ResultText := MessageText; + LogBootstrapEvent('FAILURE', MessageText); +end; + procedure CopyIfExists(const Source, TargetDir: string); begin if FileExists(Source) then @@ -900,46 +951,57 @@ end; function PrepareToInstall(var NeedsRestart: Boolean): string; begin AppendInstallOutput('Prepare system prerequisites.'); + LogBootstrapEvent('STAGE', 'prerequisites'); Result := ''; #if Int(QAIsolated) == 1 Exit; #endif if (ExistingInstallDir <> '') and IsProtectedInstallPath(ExistingInstallDir) and (not IsAdminInstallMode) then begin - Result := CustomMessage('ProtectedInstallNeedsAdmin'); + SetSetupFailure(Result, CustomMessage('ProtectedInstallNeedsAdmin')); Exit; end; if IsProtectedInstallPath(WizardDirValue) and (not IsAdminInstallMode) then begin - Result := CustomMessage('ProtectedInstallNeedsAdmin'); + SetSetupFailure(Result, CustomMessage('ProtectedInstallNeedsAdmin')); Exit; end; try - if not IsWebView2Installed() then + if IsWebView2Installed() then + LogBootstrapEvent('DEPENDENCY', 'ready|' + GetCustomMessageValue('WebView2RuntimeName')) + else begin - DownloadAndInstallPrerequisite('{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe', '/silent /install', GetCustomMessageValue('WebView2RuntimeName'), NeedsRestart); + AcquireAndInstallPrerequisite( + '{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe', + 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/silent /install', + GetCustomMessageValue('WebView2RuntimeName'), BundledWebView2Included = 1, NeedsRestart); if not WaitForWebView2Installed(60) then begin - Result := GetCustomMessageValue('WebView2StillMissing'); + SetSetupFailure(Result, GetCustomMessageValue('WebView2StillMissing')); Exit; end; end; - if (not IsVCRedistInstalled()) then + if IsVCRedistInstalled() then + LogBootstrapEvent('DEPENDENCY', 'ready|' + GetCustomMessageValue('VCRuntimeName')) + else begin - DownloadAndInstallPrerequisite('{#VCRedistUrl}', 'vc_redist.x64.exe', '/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'), NeedsRestart); + AcquireAndInstallPrerequisite( + '{#VCRedistUrl}', 'vc_redist.x64.exe', 'vc_redist.x64.exe', + '/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'), + BundledVCRedistIncluded = 1, NeedsRestart); if not WaitForVCRedistInstalled(10) then begin - Result := GetCustomMessageValue('VCRuntimeStillMissing'); + SetSetupFailure(Result, GetCustomMessageValue('VCRuntimeStillMissing')); Exit; end; end; except - Result := GetExceptionMessage; + SetSetupFailure(Result, GetExceptionMessage); end; end; @@ -954,6 +1016,7 @@ begin if CurStep = ssInstall then begin LastInstallProgressPercent := -1; + LogBootstrapEvent('STAGE', 'files'); AppendInstallOutput(CustomMessage('InstallOutputClean')); CleanLegacyInstallLayout(); AppendInstallOutput(CustomMessage('InstallOutputExtract')); @@ -961,6 +1024,7 @@ begin else if CurStep = ssPostInstall then begin FlushInstallOutput(); + LogBootstrapEvent('STAGE', 'finalize'); AppendInstallOutput(CustomMessage('InstallOutputPostInstall')); #if Int(QAIsolated) == 0 MigrateLegacyData(); @@ -980,6 +1044,7 @@ begin if LastInstallProgressPercent <> 100 then begin LastInstallProgressPercent := 100; + LogBootstrapEvent('PROGRESS', '100|' + ExpandConstant(CurrentFileName)); FlushInstallOutput(); end; Exit; @@ -991,6 +1056,7 @@ begin if ProgressPercent <> LastInstallProgressPercent then begin LastInstallProgressPercent := ProgressPercent; + LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + ExpandConstant(CurrentFileName)); FlushInstallOutput(); end; end; diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1 index 46d77b2..a7f5fee 100644 --- a/scripts/build-winui.ps1 +++ b/scripts/build-winui.ps1 @@ -1206,12 +1206,16 @@ function Build-InnoInstaller([object] $VersionInfo, [string] $SignTool, [switch] $iss = Join-Path $Root 'installer\ymhut_box_winui.iss' $chineseMessagesFile = Resolve-InnoLanguageFile $iscc + $bundleVCRedist = if (Test-Path -LiteralPath (Join-Path $PayloadDirectory 'prereqs\vc_redist.x64.exe')) { 1 } else { 0 } + $bundleWebView2 = if (Test-Path -LiteralPath (Join-Path $PayloadDirectory 'prereqs\MicrosoftEdgeWebView2RuntimeInstallerX64.exe')) { 1 } else { 0 } Invoke-Tool $iscc @( "/DMyAppVersion=$($VersionInfo.PackageVersion)", "/DMyAppBuild=$($VersionInfo.Build)", "/DMyAppChannel=$($VersionInfo.Channel)", "/DChineseMessagesFile=$chineseMessagesFile", "/DPayloadDir=$PayloadDirectory", + "/DBundleVCRedist=$bundleVCRedist", + "/DBundleWebView2=$bundleWebView2", $iss ) 'Inno Setup build failed' @@ -1225,7 +1229,8 @@ function Build-InnoInstaller([object] $VersionInfo, [string] $SignTool, [switch] function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { $bootstrapRoot = Join-Path $Root 'build\winui\installer-bootstrap' - $setupPath = Join-Path $OutputRoot "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion).exe" + $bootstrapName = "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion)" + $setupPath = Join-Path $OutputRoot "$bootstrapName.exe" Reset-DirectoryInsideRepo $bootstrapRoot Invoke-DotNet @( 'publish', $InstallerBootstrapProject, @@ -1236,11 +1241,12 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { "-p:Version=$($VersionInfo.PackageVersion)", "-p:FileVersion=$($VersionInfo.PackageVersion)", "-p:InformationalVersion=$($VersionInfo.PackageVersion)", + "-p:AssemblyName=$bootstrapName", "-p:InstallerEnginePath=$setupPath", '-o', $bootstrapRoot ) - $bootstrap = Join-Path $bootstrapRoot 'YMhutBoxSetup.exe' + $bootstrap = Join-Path $bootstrapRoot "$bootstrapName.exe" if (-not (Test-Path -LiteralPath $bootstrap)) { throw "WinUI installer bootstrap was not produced: $bootstrap" } @@ -1251,6 +1257,7 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { } Invoke-ToolQuiet $setupPath @('/SELFTEST') 'WinUI installer bootstrap self-test failed' " Installer self-test: $setupPath" + Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath" } function Sign-Artifact([string] $SignTool, [string] $Path) { Ensure-LocalDeveloperCertificate diff --git a/server/unified-management/internal/auth/auth.go b/server/unified-management/internal/auth/auth.go index c4cf1a2..81c3d32 100644 --- a/server/unified-management/internal/auth/auth.go +++ b/server/unified-management/internal/auth/auth.go @@ -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() diff --git a/server/unified-management/internal/auth/auth_test.go b/server/unified-management/internal/auth/auth_test.go index af4e6b7..8386d68 100644 --- a/server/unified-management/internal/auth/auth_test.go +++ b/server/unified-management/internal/auth/auth_test.go @@ -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") diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index fc6acba..5e06b93 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -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 } diff --git a/server/unified-management/internal/config/config_test.go b/server/unified-management/internal/config/config_test.go index 3541b28..00258ad 100644 --- a/server/unified-management/internal/config/config_test.go +++ b/server/unified-management/internal/config/config_test.go @@ -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(``), + 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) + } +} diff --git a/server/unified-management/internal/config/preflight.go b/server/unified-management/internal/config/preflight.go index ae6124a..048c4c8 100644 --- a/server/unified-management/internal/config/preflight.go +++ b/server/unified-management/internal/config/preflight.go @@ -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 { diff --git a/server/unified-management/internal/db/admin_store.go b/server/unified-management/internal/db/admin_store.go index a3cbf58..2b10893 100644 --- a/server/unified-management/internal/db/admin_store.go +++ b/server/unified-management/internal/db/admin_store.go @@ -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 } diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 7475a73..2de4bca 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -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 } diff --git a/server/unified-management/internal/db/store_test.go b/server/unified-management/internal/db/store_test.go index 44152ca..8c5ca3a 100644 --- a/server/unified-management/internal/db/store_test.go +++ b/server/unified-management/internal/db/store_test.go @@ -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") diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index ac418fa..92a6a30 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -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() diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go index cf030c0..2754eaa 100644 --- a/server/unified-management/internal/releases/releases_test.go +++ b/server/unified-management/internal/releases/releases_test.go @@ -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() } +} diff --git a/server/unified-management/internal/web/admin_release_routes.go b/server/unified-management/internal/web/admin_release_routes.go index 00a8914..10d1147 100644 --- a/server/unified-management/internal/web/admin_release_routes.go +++ b/server/unified-management/internal/web/admin_release_routes.go @@ -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")) diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index 1f5cb3f..a3f8668 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -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": "数据库连接测试失败", diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index c74b6b1..45fd8d6 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -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) { diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index dcfcdb8..7c93853 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -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(``), 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(``), 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"), diff --git a/server/unified-management/internal/web/static_routes.go b/server/unified-management/internal/web/static_routes.go index e7553a7..31910bb 100644 --- a/server/unified-management/internal/web/static_routes.go +++ b/server/unified-management/internal/web/static_routes.go @@ -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(`YMhut Admin

YMhut Admin

Build web/admin to enable the Vue console.

`)) } +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" { diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index fd5584b..637dbaa 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -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(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("/api/admin/auth/captcha"); + captcha.value = await adminFetch("/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" }}

- - + + -