Improve installer extraction, login failures, and upload handling
This commit is contained in:
@@ -160,7 +160,8 @@ Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:Startup
|
|||||||
Name: "launchapp"; Description: "{cm:LaunchApp}"; GroupDescription: "{cm:StartupGroup}"; Flags: checkedonce
|
Name: "launchapp"; Description: "{cm:LaunchApp}"; GroupDescription: "{cm:StartupGroup}"; Flags: checkedonce
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#PayloadDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile
|
Source: "{#PayloadDir}\*"; DestDir: "{app}"; Excludes: "*.pri"; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile
|
||||||
|
Source: "{#PayloadDir}\*.pri"; DestDir: "{app}"; Attribs: hidden; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile
|
||||||
#if Int(BundleVCRedist) == 1
|
#if Int(BundleVCRedist) == 1
|
||||||
Source: "{#PayloadDir}\prereqs\vc_redist.x64.exe"; Flags: dontcopy
|
Source: "{#PayloadDir}\prereqs\vc_redist.x64.exe"; Flags: dontcopy
|
||||||
#endif
|
#endif
|
||||||
@@ -259,6 +260,7 @@ var
|
|||||||
InstallOutputLineCount: Integer;
|
InstallOutputLineCount: Integer;
|
||||||
InstallOutputPendingLineCount: Integer;
|
InstallOutputPendingLineCount: Integer;
|
||||||
InstallOutputBuffer: string;
|
InstallOutputBuffer: string;
|
||||||
|
LastExtractFileName: string;
|
||||||
|
|
||||||
function SendMessage(hWnd: Longint; Msg: Longint; wParam: Longint; lParam: Longint): Longint;
|
function SendMessage(hWnd: Longint; Msg: Longint; wParam: Longint; lParam: Longint): Longint;
|
||||||
external 'SendMessageW@user32.dll stdcall';
|
external 'SendMessageW@user32.dll stdcall';
|
||||||
@@ -493,8 +495,11 @@ var
|
|||||||
begin
|
begin
|
||||||
FileName := ExpandConstant(CurrentFileName);
|
FileName := ExpandConstant(CurrentFileName);
|
||||||
if FileName <> '' then
|
if FileName <> '' then
|
||||||
|
begin
|
||||||
|
LastExtractFileName := FileName;
|
||||||
QueueInstallOutput(CustomMessage('InstallOutputFile') + ' ' + FileName, False, True);
|
QueueInstallOutput(CustomMessage('InstallOutputFile') + ' ' + FileName, False, True);
|
||||||
end;
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function SelectedTaskText(const TaskName, LabelText: string): string;
|
function SelectedTaskText(const TaskName, LabelText: string): string;
|
||||||
begin
|
begin
|
||||||
@@ -784,7 +789,8 @@ begin
|
|||||||
RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, 'downloaded file is missing'));
|
RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, 'downloaded file is missing'));
|
||||||
|
|
||||||
LogBootstrapEvent('DEPENDENCY', 'install|' + FriendlyName);
|
LogBootstrapEvent('DEPENDENCY', 'install|' + FriendlyName);
|
||||||
if not Exec(ExecutablePath, Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
LogBootstrapEvent('DEPENDENCY', 'waiting|' + FriendlyName);
|
||||||
|
if not Exec(ExecutablePath, Arguments, '', SW_SHOW, ewWaitUntilTerminated, ResultCode) then
|
||||||
RaiseException(FormatCustomMessage('DependencyStartFailed', FriendlyName));
|
RaiseException(FormatCustomMessage('DependencyStartFailed', FriendlyName));
|
||||||
|
|
||||||
if (ResultCode <> 0) and (ResultCode <> 1638) and (ResultCode <> 3010) and (ResultCode <> 1641) then
|
if (ResultCode <> 0) and (ResultCode <> 1638) and (ResultCode <> 3010) and (ResultCode <> 1641) then
|
||||||
@@ -975,7 +981,7 @@ begin
|
|||||||
begin
|
begin
|
||||||
AcquireAndInstallPrerequisite(
|
AcquireAndInstallPrerequisite(
|
||||||
'{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe',
|
'{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe',
|
||||||
'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/silent /install',
|
'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/install',
|
||||||
GetCustomMessageValue('WebView2RuntimeName'), BundledWebView2Included = 1, NeedsRestart);
|
GetCustomMessageValue('WebView2RuntimeName'), BundledWebView2Included = 1, NeedsRestart);
|
||||||
|
|
||||||
if not WaitForWebView2Installed(60) then
|
if not WaitForWebView2Installed(60) then
|
||||||
@@ -991,7 +997,7 @@ begin
|
|||||||
begin
|
begin
|
||||||
AcquireAndInstallPrerequisite(
|
AcquireAndInstallPrerequisite(
|
||||||
'{#VCRedistUrl}', 'vc_redist.x64.exe', 'vc_redist.x64.exe',
|
'{#VCRedistUrl}', 'vc_redist.x64.exe', 'vc_redist.x64.exe',
|
||||||
'/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'),
|
'/install /norestart', GetCustomMessageValue('VCRuntimeName'),
|
||||||
BundledVCRedistIncluded = 1, NeedsRestart);
|
BundledVCRedistIncluded = 1, NeedsRestart);
|
||||||
|
|
||||||
if not WaitForVCRedistInstalled(10) then
|
if not WaitForVCRedistInstalled(10) then
|
||||||
@@ -1016,6 +1022,7 @@ begin
|
|||||||
if CurStep = ssInstall then
|
if CurStep = ssInstall then
|
||||||
begin
|
begin
|
||||||
LastInstallProgressPercent := -1;
|
LastInstallProgressPercent := -1;
|
||||||
|
LastExtractFileName := '';
|
||||||
LogBootstrapEvent('STAGE', 'files');
|
LogBootstrapEvent('STAGE', 'files');
|
||||||
AppendInstallOutput(CustomMessage('InstallOutputClean'));
|
AppendInstallOutput(CustomMessage('InstallOutputClean'));
|
||||||
CleanLegacyInstallLayout();
|
CleanLegacyInstallLayout();
|
||||||
@@ -1044,7 +1051,7 @@ begin
|
|||||||
if LastInstallProgressPercent <> 100 then
|
if LastInstallProgressPercent <> 100 then
|
||||||
begin
|
begin
|
||||||
LastInstallProgressPercent := 100;
|
LastInstallProgressPercent := 100;
|
||||||
LogBootstrapEvent('PROGRESS', '100|' + ExpandConstant(CurrentFileName));
|
LogBootstrapEvent('PROGRESS', '100|' + LastExtractFileName);
|
||||||
FlushInstallOutput();
|
FlushInstallOutput();
|
||||||
end;
|
end;
|
||||||
Exit;
|
Exit;
|
||||||
@@ -1056,7 +1063,7 @@ begin
|
|||||||
if ProgressPercent <> LastInstallProgressPercent then
|
if ProgressPercent <> LastInstallProgressPercent then
|
||||||
begin
|
begin
|
||||||
LastInstallProgressPercent := ProgressPercent;
|
LastInstallProgressPercent := ProgressPercent;
|
||||||
LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + ExpandConstant(CurrentFileName));
|
LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + LastExtractFileName);
|
||||||
FlushInstallOutput();
|
FlushInstallOutput();
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ type Captcha struct {
|
|||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginFailure string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoginFailureNone LoginFailure = ""
|
||||||
|
LoginFailureLocked LoginFailure = "locked"
|
||||||
|
LoginFailureCaptcha LoginFailure = "captcha"
|
||||||
|
LoginFailureCredentials LoginFailure = "credentials"
|
||||||
|
)
|
||||||
|
|
||||||
func NewService(store *db.Store) *Service {
|
func NewService(store *db.Store) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
store: store,
|
store: store,
|
||||||
@@ -103,21 +112,26 @@ func (s *Service) NewCaptcha() (Captcha, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, bool, error) {
|
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, bool, error) {
|
||||||
|
sessionID, csrf, failure, err := s.LoginDetailed(ctx, username, password, captchaID, captcha, clientKeys...)
|
||||||
|
return sessionID, csrf, err == nil && failure == LoginFailureNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoginDetailed(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, LoginFailure, error) {
|
||||||
attemptKey := loginAttemptKey(username, clientKeys...)
|
attemptKey := loginAttemptKey(username, clientKeys...)
|
||||||
if s.loginLocked(attemptKey) {
|
if s.loginLocked(attemptKey) {
|
||||||
return "", "", false, nil
|
return "", "", LoginFailureLocked, nil
|
||||||
}
|
}
|
||||||
if !s.consumeCaptcha(captchaID, captcha) {
|
if !s.consumeCaptcha(captchaID, captcha) {
|
||||||
s.recordLoginFailure(attemptKey)
|
s.recordLoginFailure(attemptKey)
|
||||||
return "", "", false, nil
|
return "", "", LoginFailureCaptcha, nil
|
||||||
}
|
}
|
||||||
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", false, err
|
return "", "", LoginFailureNone, err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
s.recordLoginFailure(attemptKey)
|
s.recordLoginFailure(attemptKey)
|
||||||
return "", "", false, nil
|
return "", "", LoginFailureCredentials, nil
|
||||||
}
|
}
|
||||||
sessionID := randomToken(32)
|
sessionID := randomToken(32)
|
||||||
csrf := randomToken(32)
|
csrf := randomToken(32)
|
||||||
@@ -126,7 +140,7 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
|
|||||||
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
|
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
|
||||||
delete(s.loginAttempts, attemptKey)
|
delete(s.loginAttempts, attemptKey)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return sessionID, csrf, true, nil
|
return sessionID, csrf, LoginFailureNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -120,8 +120,29 @@ func TestLoginLocksAfterRepeatedFailures(t *testing.T) {
|
|||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
answer := service.captchas[captcha.ID].answer
|
answer := service.captchas[captcha.ID].answer
|
||||||
service.mu.Unlock()
|
service.mu.Unlock()
|
||||||
if _, _, ok, err := service.Login(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || ok {
|
if _, _, failure, err := service.LoginDetailed(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || failure != LoginFailureLocked {
|
||||||
t.Fatalf("locked login should fail without error, ok=%v err=%v", ok, err)
|
t.Fatalf("locked login returned failure=%q err=%v", failure, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginDetailedDistinguishesCaptchaFailure(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
store, err := db.Open(&config.Config{
|
||||||
|
StorageDir: root,
|
||||||
|
Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "captcha.sqlite"), 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)
|
||||||
|
_, _, failure, err := service.LoginDetailed(context.Background(), "admin", "admin", "missing", "00000", "127.0.0.1")
|
||||||
|
if err != nil || failure != LoginFailureCaptcha {
|
||||||
|
t.Fatalf("captcha login returned failure=%q err=%v", failure, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF
|
|||||||
return Package{}, ErrUploadedPackageEmpty
|
return Package{}, ErrUploadedPackageEmpty
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
|
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)
|
target := filepath.Join(s.cfg.DownloadsDir, name)
|
||||||
resolved, err := filepath.Abs(target)
|
resolved, err := filepath.Abs(target)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -65,6 +66,9 @@ func newReleaseUploadError(status int, code string, err error) error {
|
|||||||
|
|
||||||
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
|
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
|
||||||
limit := r.releaseUploadLimit()
|
limit := r.releaseUploadLimit()
|
||||||
|
if req.ContentLength > limit+(8<<20) {
|
||||||
|
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("release package or form data exceeds upload limit"))
|
||||||
|
}
|
||||||
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
|
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
|
||||||
reader, err := req.MultipartReader()
|
reader, err := req.MultipartReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,11 +150,18 @@ func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (rel
|
|||||||
}()
|
}()
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
limited := &io.LimitedReader{R: part, N: limit + 1}
|
limited := &io.LimitedReader{R: part, N: limit + 1}
|
||||||
written, err := io.Copy(tmp, io.TeeReader(limited, hash))
|
written, err := io.Copy(io.MultiWriter(tmp, hash), limited)
|
||||||
|
if err == nil {
|
||||||
|
err = tmp.Sync()
|
||||||
|
}
|
||||||
if closeErr := tmp.Close(); err == nil {
|
if closeErr := tmp.Close(); err == nil {
|
||||||
err = closeErr
|
err = closeErr
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if errors.As(err, &pathErr) {
|
||||||
|
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||||
|
}
|
||||||
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
|
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
|
||||||
}
|
}
|
||||||
if written > limit {
|
if written > limit {
|
||||||
@@ -179,6 +190,9 @@ func classifyMultipartReadError(err error) error {
|
|||||||
if errors.As(err, &maxErr) {
|
if errors.As(err, &maxErr) {
|
||||||
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
|
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, context.Canceled) {
|
||||||
|
return newReleaseUploadError(http.StatusBadRequest, "UPLOAD_INTERRUPTED", errors.New("upload was interrupted before the package was complete"))
|
||||||
|
}
|
||||||
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ func localizedErrorMessage(code, message string) string {
|
|||||||
byCode := map[string]string{
|
byCode := map[string]string{
|
||||||
"UNAUTHORIZED": "需要登录后继续操作",
|
"UNAUTHORIZED": "需要登录后继续操作",
|
||||||
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
||||||
|
"LOGIN_LOCKED": "登录失败次数过多,请 5 分钟后重试",
|
||||||
|
"CAPTCHA_INVALID": "验证码错误或已过期,请输入新的验证码",
|
||||||
|
"CREDENTIALS_INVALID": "账号或密码不正确",
|
||||||
"LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
|
"LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
|
||||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||||
@@ -93,6 +96,11 @@ func localizedErrorMessage(code, message string) string {
|
|||||||
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
|
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
|
||||||
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
|
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
|
||||||
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
|
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
|
||||||
|
"PACKAGE_EMPTY": "发布包不能为空",
|
||||||
|
"PACKAGE_TOO_LARGE": "发布包超过服务端上传上限",
|
||||||
|
"UPLOAD_STORAGE_FAILED": "服务端无法保存上传文件",
|
||||||
|
"MANIFEST_UPDATE_FAILED": "发布包已回滚,更新清单写入失败",
|
||||||
|
"UPLOAD_INTERRUPTED": "上传连接已中断,请重新上传",
|
||||||
"SOURCE_SAVE_FAILED": "接口源保存失败",
|
"SOURCE_SAVE_FAILED": "接口源保存失败",
|
||||||
"CHECK_FAILED": "接口健康检测失败",
|
"CHECK_FAILED": "接口健康检测失败",
|
||||||
"SYNC_FAILED": "同步操作失败",
|
"SYNC_FAILED": "同步操作失败",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,7 +33,7 @@ type router struct {
|
|||||||
publicSnapshots *publicSnapshotService
|
publicSnapshots *publicSnapshotService
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginRequestTimeout = 5 * time.Second
|
const loginRequestTimeout = 8 * 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 {
|
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{
|
r := &router{
|
||||||
@@ -189,6 +190,11 @@ func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
|
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
captcha, err := r.auth.NewCaptcha()
|
captcha, err := r.auth.NewCaptcha()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
|
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
|
||||||
@@ -219,7 +225,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
|
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
sessionID, csrf, ok, err := r.auth.Login(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
|
clientAddress := remoteHost(req.RemoteAddr)
|
||||||
|
sessionID, csrf, failure, err := r.auth.LoginDetailed(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, clientAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
|
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
|
||||||
@@ -228,13 +235,33 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if failure != auth.LoginFailureNone {
|
||||||
writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
code := "LOGIN_FAILED"
|
||||||
|
message := "invalid username or password"
|
||||||
|
switch failure {
|
||||||
|
case auth.LoginFailureLocked:
|
||||||
|
code = "LOGIN_LOCKED"
|
||||||
|
message = "too many login attempts; try again later"
|
||||||
|
case auth.LoginFailureCaptcha:
|
||||||
|
code = "CAPTCHA_INVALID"
|
||||||
|
message = "captcha is invalid or expired"
|
||||||
|
case auth.LoginFailureCredentials:
|
||||||
|
code = "CREDENTIALS_INVALID"
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusOK, code, errors.New(message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth.SetSessionCookieForRequest(w, req, sessionID)
|
auth.SetSessionCookieForRequest(w, req, sessionID)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
||||||
go r.recordLoginAudit(body.Username, req.RemoteAddr, req.UserAgent())
|
go r.recordLoginAudit(body.Username, clientAddress, req.UserAgent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteHost(remoteAddress string) string {
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress))
|
||||||
|
if err == nil && host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(remoteAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
|
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
|
||||||
|
|||||||
@@ -678,6 +678,31 @@ func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminReleasePackageUploadReportsInterruptedMultipart(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
session, csrf, err := loginForTest(handler)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = "ymhut-interrupted-upload"
|
||||||
|
body := bytes.NewBufferString("--" + boundary + "\r\n" +
|
||||||
|
"Content-Disposition: form-data; name=\"file\"; filename=\"package.exe\"\r\n" +
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\npartial package")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", body)
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
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.StatusBadRequest || !strings.Contains(res.Body.String(), "UPLOAD_INTERRUPTED") {
|
||||||
|
t.Fatalf("interrupted upload returned %d %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
|
func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
@@ -950,7 +975,7 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
|
|||||||
if time.Since(started) > time.Second {
|
if time.Since(started) > time.Second {
|
||||||
t.Fatal("invalid login request did not return promptly")
|
t.Fatal("invalid login request did not return promptly")
|
||||||
}
|
}
|
||||||
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "LOGIN_FAILED") {
|
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "CAPTCHA_INVALID") {
|
||||||
t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
|
t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
|
||||||
}
|
}
|
||||||
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
@@ -958,6 +983,15 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteHostRemovesEphemeralPort(t *testing.T) {
|
||||||
|
if got := remoteHost("127.0.0.1:51842"); got != "127.0.0.1" {
|
||||||
|
t.Fatalf("remoteHost returned %q", got)
|
||||||
|
}
|
||||||
|
if got := remoteHost("[::1]:51842"); got != "::1" {
|
||||||
|
t.Fatalf("remoteHost returned %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func readTestCaptcha(dataURL string) (string, error) {
|
func readTestCaptcha(dataURL string) (string, error) {
|
||||||
const prefix = "data:image/png;base64,"
|
const prefix = "data:image/png;base64,"
|
||||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const router = useRouter();
|
|||||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loginPending = ref(false);
|
const loginPending = ref(false);
|
||||||
|
const captchaPending = ref(false);
|
||||||
const toast = ref<ToastState | null>(null);
|
const toast = ref<ToastState | null>(null);
|
||||||
const autoRefreshPaused = ref(false);
|
const autoRefreshPaused = ref(false);
|
||||||
const databaseFormEditing = ref(false);
|
const databaseFormEditing = ref(false);
|
||||||
@@ -75,6 +76,7 @@ let refreshTimer: number | undefined;
|
|||||||
let systemRefreshTimer: number | undefined;
|
let systemRefreshTimer: number | undefined;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let events: EventSource | null = null;
|
let events: EventSource | null = null;
|
||||||
|
let captchaRequestSerial = 0;
|
||||||
|
|
||||||
const authStore = createAuthStore();
|
const authStore = createAuthStore();
|
||||||
const dashboardStore = createDashboardStore();
|
const dashboardStore = createDashboardStore();
|
||||||
@@ -491,7 +493,26 @@ function isAuthError(raw: string, message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCaptcha() {
|
async function loadCaptcha() {
|
||||||
captcha.value = await adminFetch<Captcha>("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
|
const serial = ++captchaRequestSerial;
|
||||||
|
captchaPending.value = true;
|
||||||
|
try {
|
||||||
|
const next = await adminFetch<Captcha>("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
|
||||||
|
if (serial === captchaRequestSerial) {
|
||||||
|
captcha.value = next;
|
||||||
|
loginForm.captcha = "";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (serial === captchaRequestSerial) captchaPending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
try {
|
||||||
|
await loadCaptcha();
|
||||||
|
} catch {
|
||||||
|
captcha.value = null;
|
||||||
|
setToast("验证码加载失败,请检查服务端连接", "error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAuthBootstrap() {
|
async function loadAuthBootstrap() {
|
||||||
@@ -511,7 +532,7 @@ async function login() {
|
|||||||
const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
|
const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||||
}, { timeoutMs: 8000 });
|
}, { timeoutMs: 12000 });
|
||||||
csrf.value = data.csrfToken;
|
csrf.value = data.csrfToken;
|
||||||
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
||||||
localStorage.removeItem("ymhut.csrf");
|
localStorage.removeItem("ymhut.csrf");
|
||||||
@@ -520,8 +541,7 @@ async function login() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toChineseError(error instanceof Error ? error.message : String(error));
|
const message = toChineseError(error instanceof Error ? error.message : String(error));
|
||||||
setToast(message, "error");
|
setToast(message, "error");
|
||||||
loginForm.captcha = "";
|
await loadCaptcha().catch(() => {
|
||||||
void loadCaptcha().catch(() => {
|
|
||||||
captcha.value = null;
|
captcha.value = null;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1742,9 +1762,9 @@ function connectAdminEvents() {
|
|||||||
验证码
|
验证码
|
||||||
<div class="captcha-row">
|
<div class="captcha-row">
|
||||||
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
|
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
|
||||||
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending" @click="loadCaptcha">
|
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending || captchaPending" @click="refreshCaptcha">
|
||||||
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
||||||
<span v-else>刷新</span>
|
<span v-else>{{ captchaPending ? "加载中" : "刷新" }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const exactMessages: Record<string, string> = {
|
|||||||
const codeMessages: Record<string, string> = {
|
const codeMessages: Record<string, string> = {
|
||||||
UNAUTHORIZED: "需要登录后继续操作",
|
UNAUTHORIZED: "需要登录后继续操作",
|
||||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||||
|
LOGIN_LOCKED: "登录失败次数过多,请 5 分钟后重试",
|
||||||
|
CAPTCHA_INVALID: "验证码错误或已过期,请输入新的验证码",
|
||||||
|
CREDENTIALS_INVALID: "账号或密码不正确",
|
||||||
LOGIN_TIMEOUT: "登录校验超时,请稍后重试",
|
LOGIN_TIMEOUT: "登录校验超时,请稍后重试",
|
||||||
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
||||||
INVALID_PAYLOAD: "提交内容格式不正确",
|
INVALID_PAYLOAD: "提交内容格式不正确",
|
||||||
@@ -50,6 +53,7 @@ const codeMessages: Record<string, string> = {
|
|||||||
UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
|
UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
|
||||||
MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
|
MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
|
||||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||||
|
UPLOAD_INTERRUPTED: "上传连接已中断,请保持页面打开后重试",
|
||||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||||
CHECK_FAILED: "接口健康检测失败",
|
CHECK_FAILED: "接口健康检测失败",
|
||||||
SYNC_FAILED: "同步操作失败",
|
SYNC_FAILED: "同步操作失败",
|
||||||
|
|||||||
@@ -256,14 +256,14 @@ public static class InstallerEngine
|
|||||||
prerequisites.WebView2Installed ? PreflightStatus.Passed : PreflightStatus.Warning,
|
prerequisites.WebView2Installed ? PreflightStatus.Passed : PreflightStatus.Warning,
|
||||||
prerequisites.WebView2Installed
|
prerequisites.WebView2Installed
|
||||||
? $"已安装 {prerequisites.WebView2Version ?? "可用版本"}。"
|
? $"已安装 {prerequisites.WebView2Version ?? "可用版本"}。"
|
||||||
: "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
|
: "未检测到;开始安装时将从微软官方地址下载,请在弹出的安装窗口中完成安装。"),
|
||||||
new(
|
new(
|
||||||
"vcruntime",
|
"vcruntime",
|
||||||
"Microsoft Visual C++ x64 Runtime",
|
"Microsoft Visual C++ x64 Runtime",
|
||||||
prerequisites.VcRuntimeInstalled ? PreflightStatus.Passed : PreflightStatus.Warning,
|
prerequisites.VcRuntimeInstalled ? PreflightStatus.Passed : PreflightStatus.Warning,
|
||||||
prerequisites.VcRuntimeInstalled
|
prerequisites.VcRuntimeInstalled
|
||||||
? $"已安装 {prerequisites.VcRuntimeVersion ?? "可用版本"}。"
|
? $"已安装 {prerequisites.VcRuntimeVersion ?? "可用版本"}。"
|
||||||
: "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
|
: "未检测到;开始安装时将从微软官方地址下载,请在弹出的安装窗口中完成安装。"),
|
||||||
EvaluateDirectory(targetDirectory),
|
EvaluateDirectory(targetDirectory),
|
||||||
SelfTest()
|
SelfTest()
|
||||||
? new PreflightItem("engine", "安装引擎完整性", PreflightStatus.Passed, "嵌入引擎已通过哈希命名释放和 PE 完整性检查。")
|
? new PreflightItem("engine", "安装引擎完整性", PreflightStatus.Passed, "嵌入引擎已通过哈希命名释放和 PE 完整性检查。")
|
||||||
@@ -481,6 +481,7 @@ public static class InstallerEngine
|
|||||||
"retry" => $"下载中断,正在重试 {fields[2]}。",
|
"retry" => $"下载中断,正在重试 {fields[2]}。",
|
||||||
"bundled" => $"正在使用安装包内置的 {fields[2]}。",
|
"bundled" => $"正在使用安装包内置的 {fields[2]}。",
|
||||||
"install" => $"正在安装 {fields[2]}。",
|
"install" => $"正在安装 {fields[2]}。",
|
||||||
|
"waiting" => $"请在弹出的安装窗口中完成 {fields[2]} 安装;窗口关闭后将自动继续。",
|
||||||
"complete" => $"{fields[2]} 安装完成。",
|
"complete" => $"{fields[2]} 安装完成。",
|
||||||
_ => fields[2]
|
_ => fields[2]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ public sealed class ToolCatalogTests
|
|||||||
Assert.IsTrue(catalog.Search("音乐").Any(module => module.Id == "music"));
|
Assert.IsTrue(catalog.Search("音乐").Any(module => module.Id == "music"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FullToolboxCatalogIncludesMigratedToolFamilies()
|
||||||
|
{
|
||||||
|
var builtin = new BuiltinReferenceToolCatalog().GetModules();
|
||||||
|
var catalog = new ToolCatalog(ToolCatalog.DefaultModules()
|
||||||
|
.Concat(NexNativeToolCatalog.Modules)
|
||||||
|
.Concat(builtin));
|
||||||
|
|
||||||
|
Assert.IsNotNull(catalog.GetById("hotboard"));
|
||||||
|
Assert.IsTrue(catalog.Modules.Any(module => module is NexNativeToolModule));
|
||||||
|
Assert.IsTrue(catalog.Modules.Any(module => module is BuiltinReferenceToolModule));
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void EveryCatalogToolHasOneStableDisplayGroup()
|
public void EveryCatalogToolHasOneStableDisplayGroup()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1562,6 +1562,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
|||||||
var builtinTools = _builtinToolCatalog.GetModules();
|
var builtinTools = _builtinToolCatalog.GetModules();
|
||||||
|
|
||||||
var modules = ToolCatalog.DefaultModules()
|
var modules = ToolCatalog.DefaultModules()
|
||||||
|
.Concat(NexNativeToolCatalog.Modules)
|
||||||
.Concat(builtinTools)
|
.Concat(builtinTools)
|
||||||
.Concat(externalTools)
|
.Concat(externalTools)
|
||||||
.Concat(pluginTools)
|
.Concat(pluginTools)
|
||||||
@@ -1573,7 +1574,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
|||||||
"Information",
|
"Information",
|
||||||
"tool-catalog",
|
"tool-catalog",
|
||||||
"Tool catalog rebuilt",
|
"Tool catalog rebuilt",
|
||||||
$"native={ToolCatalog.DefaultModules().Count()}; builtin={builtinTools.Count}; external={externalTools.Count}; plugin={pluginTools.Count()}",
|
$"native={ToolCatalog.DefaultModules().Count()}; nex={NexNativeToolCatalog.Modules.Count}; builtin={builtinTools.Count}; external={externalTools.Count}; plugin={pluginTools.Count()}",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new ToolCatalog(modules);
|
return new ToolCatalog(modules);
|
||||||
|
|||||||
@@ -255,7 +255,11 @@ public sealed class StartupInitializationService(
|
|||||||
context.Report(0.25, T("正在扫描内置与随包工具...", "Scanning built-in and bundled tools..."));
|
context.Report(0.25, T("正在扫描内置与随包工具...", "Scanning built-in and bundled tools..."));
|
||||||
var externalTools = await externalToolCatalog.GetModulesAsync(token).ConfigureAwait(false);
|
var externalTools = await externalToolCatalog.GetModulesAsync(token).ConfigureAwait(false);
|
||||||
var builtinTools = builtinToolCatalog.GetModules();
|
var builtinTools = builtinToolCatalog.GetModules();
|
||||||
var count = ToolCatalog.DefaultModules().Count() + builtinTools.Count + externalTools.Count;
|
var count = ToolCatalog.DefaultModules()
|
||||||
|
.Concat(NexNativeToolCatalog.Modules)
|
||||||
|
.Concat(builtinTools)
|
||||||
|
.Concat(externalTools)
|
||||||
|
.Count();
|
||||||
setToolCount(count);
|
setToolCount(count);
|
||||||
context.Report(1, T($"工具目录已就绪:{count} 个工具。", $"Tool catalog ready: {count} tools."));
|
context.Report(1, T($"工具目录已就绪:{count} 个工具。", $"Tool catalog ready: {count} tools."));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user