From 08cdd99141b9db8fea84e8d3511252e01a7e33f7 Mon Sep 17 00:00:00 2001 From: Meng Meng <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:38:15 +0800 Subject: [PATCH] feat: reset admin credentials without requiring current password and update related prompts (#17) --- .github/workflows/pr-size-limit.yml | 80 +++++++++++++++++++++++++++++ cmd/vocat/menu.go | 43 ++++++++-------- cmd/vocat/menu_test.go | 17 ++++++ internal/auth/service.go | 18 +++++++ internal/auth/service_test.go | 41 +++++++++++++++ 5 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/pr-size-limit.yml diff --git a/.github/workflows/pr-size-limit.yml b/.github/workflows/pr-size-limit.yml new file mode 100644 index 0000000..65ad327 --- /dev/null +++ b/.github/workflows/pr-size-limit.yml @@ -0,0 +1,80 @@ +name: Pull request size limit + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-size-limit-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + enforce-size-limit: + name: Enforce 5,000-line limit + runs-on: ubuntu-latest + timeout-minutes: 2 + env: + MAX_CHANGED_LINES: "5000" + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Reject oversized pull request + shell: bash + run: | + set -euo pipefail + + api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" + response="$({ + curl --fail-with-body --silent --show-error \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}" + })" + + additions="$(jq -r '.additions' <<<"${response}")" + deletions="$(jq -r '.deletions' <<<"${response}")" + if [[ ! "${additions}" =~ ^[0-9]+$ || ! "${deletions}" =~ ^[0-9]+$ ]]; then + echo "Unable to read pull request line statistics." >&2 + exit 1 + fi + + changed_lines=$((additions + deletions)) + { + echo "### Pull request size" + echo + echo "- Additions: ${additions}" + echo "- Deletions: ${deletions}" + echo "- Total changed lines: ${changed_lines}" + echo "- Limit: ${MAX_CHANGED_LINES}" + } >>"${GITHUB_STEP_SUMMARY}" + + if (( changed_lines <= MAX_CHANGED_LINES )); then + echo "Pull request is within the ${MAX_CHANGED_LINES}-line limit." + exit 0 + fi + + curl --fail-with-body --silent --show-error \ + --request PATCH \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}" \ + --data '{"state":"closed"}' >/dev/null + + message="This pull request changes ${changed_lines} lines (${additions} additions + ${deletions} deletions), exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests." + comment_payload="$(jq -nc --arg body "${message}" '{body: $body}')" + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --data "${comment_payload}" >/dev/null + + echo "::error::Pull request changes ${changed_lines} lines; the maximum is ${MAX_CHANGED_LINES}." + exit 1 diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index d4746a8..4f90c7a 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -88,7 +88,7 @@ func menuEnvFilePath() string { return envFilePath } -// runMenu is the interactive lifecycle menu: toggle language, change password, +// runMenu is the interactive lifecycle menu: toggle language, reset credentials, // change the Web listener port, restart the systemd unit, self-update, or fully // uninstall vocat. It must run as root on the host (needs systemctl + the 0600 // env file). Docker deployments do not use it. @@ -128,7 +128,7 @@ func runMenu(logger *slog.Logger) error { fmt.Println(menu.errorPrefix(err)) } case "2": - if err := menuChangePassword(reader, menu); err != nil { + if err := menuResetAdminCredentials(reader, menu); err != nil { fmt.Println(menu.errorPrefix(err)) } case "3": @@ -193,7 +193,7 @@ func loadMenuLanguage() (string, error) { return "en", nil } -func menuChangePassword(reader *bufio.Reader, m *menu) error { +func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("%w: %v", errMenuConfig, err) @@ -216,10 +216,14 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error { return fmt.Errorf("%w: %v", errMenuStore, err) } - fmt.Print(m.currentPassword()) - currentPw, err := readPasswordMasked() + fmt.Print(m.newUsername(admin.Username)) + username, err := reader.ReadString('\n') if err != nil { - return err + return fmt.Errorf("read administrator username: %w", err) + } + username = strings.TrimSpace(username) + if username == "" { + username = admin.Username } fmt.Print(m.newPassword()) newPw, err := readPasswordMasked() @@ -235,10 +239,7 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error { if newPw != confirmPw { return errPasswordsDiffer } - if err := authService.ChangePassword(ctx, admin.Username, currentPw, newPw); err != nil { - if errors.Is(err, auth.ErrInvalidCredentials) { - return errCurrentWrong - } + if err := authService.ResetAdminCredentials(ctx, username, newPw); err != nil { return fmt.Errorf("%w: %v", errMenuAuth, err) } fmt.Println(m.passwordChanged()) @@ -563,7 +564,6 @@ func menuUninstall(reader *bufio.Reader, m *menu) error { // menu-local sentinel errors so callers can map them to localized messages. var ( - errCurrentWrong = errors.New("menu: current password is incorrect") errPasswordsDiffer = errors.New("menu: passwords do not match") errNoSystemctl = errors.New("menu: systemctl not found") errRestartFailed = errors.New("menu: restart failed") @@ -588,17 +588,17 @@ func (m *menu) msg(key string) string { table := map[string][2]string{ "title": {"vocat 管理菜单", "vocat management menu"}, "opt_lang": {"1) 切换中英文", "1) Toggle language"}, - "opt_change": {"2) 修改账号密码", "2) Change admin password"}, + "opt_change": {"2) 修改账号密码", "2) Change admin credentials"}, "opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"}, "opt_restart": {"4) 重启软件", "4) Restart software"}, "opt_update": {"5) 更新软件", "5) Update software"}, "opt_uninstall": {"0) 卸载软件", "0) Uninstall software"}, "prompt": {"请选择: ", "Select: "}, "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, - "cur_pw": {"当前密码: ", "Current password: "}, + "new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "}, "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, "confirm_pw": {"确认新密码: ", "Confirm new password: "}, - "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, + "pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."}, "current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"}, "new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "}, "web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."}, @@ -632,10 +632,12 @@ func (m *menu) msg(key string) string { return entry[zh] } -func (m *menu) title() string { return m.msg("title") } -func (m *menu) prompt() string { return m.msg("prompt") } -func (m *menu) invalid() string { return m.msg("invalid") } -func (m *menu) currentPassword() string { return m.msg("cur_pw") } +func (m *menu) title() string { return m.msg("title") } +func (m *menu) prompt() string { return m.msg("prompt") } +func (m *menu) invalid() string { return m.msg("invalid") } +func (m *menu) newUsername(current string) string { + return fmt.Sprintf(m.msg("new_username"), current) +} func (m *menu) newPassword() string { return m.msg("new_pw") } func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } func (m *menu) passwordChanged() string { return m.msg("pw_changed") } @@ -670,11 +672,6 @@ func (m *menu) options() []string { func (m *menu) errorPrefix(err error) string { switch { - case errors.Is(err, errCurrentWrong): - if m.lang == "en" { - return "Current password is incorrect." - } - return "当前密码不正确。" case errors.Is(err, errPasswordsDiffer): if m.lang == "en" { return "Passwords do not match." diff --git a/cmd/vocat/menu_test.go b/cmd/vocat/menu_test.go index 2cf3d40..a440370 100644 --- a/cmd/vocat/menu_test.go +++ b/cmd/vocat/menu_test.go @@ -72,3 +72,20 @@ func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) { } } } + +func TestMenuCredentialResetPromptsDoNotRequestCurrentPassword(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + menu := newMenu(lang) + prompts := strings.Join([]string{ + menu.newUsername("admin"), + menu.newPassword(), + menu.confirmPassword(), + }, "\n") + if strings.Contains(strings.ToLower(prompts), "current password") || strings.Contains(prompts, "当前密码") { + t.Fatalf("%s credential reset still requests the current password: %q", lang, prompts) + } + if !strings.Contains(prompts, "admin") { + t.Fatalf("%s username prompt does not show the current username: %q", lang, prompts) + } + } +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 7b587e7..e9266b4 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -118,6 +118,24 @@ func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, pas return true, nil } +// ResetAdminCredentials replaces the single administrator without requiring +// the previous credentials. It is intended for trusted local recovery flows +// such as the root-only management CLI. Store.SetAdmin atomically revokes all +// existing sessions when the credentials change. +func (s *Service) ResetAdminCredentials(ctx context.Context, username string, password string) error { + username = strings.TrimSpace(username) + if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") { + return errors.New("administrator username must contain between 1 and 64 characters without control whitespace") + } + if len(password) < 12 || len(password) > 1024 { + return errors.New("administrator password must contain between 12 and 1024 characters") + } + if err := s.EnsureAdmin(ctx, username, password); err != nil { + return fmt.Errorf("auth: reset administrator credentials: %w", err) + } + return nil +} + func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) { admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) if errors.Is(err, store.ErrNotFound) { diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index b804a2e..7a9f028 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -97,6 +97,47 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) { } } +func TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword(t *testing.T) { + ctx := context.Background() + service := newTestService(t) + credentials, err := service.Login(ctx, "admin", "correct-password") + if err != nil { + t.Fatal(err) + } + + if err := service.ResetAdminCredentials(ctx, "new-admin", "replacement-password"); err != nil { + t.Fatalf("ResetAdminCredentials() error = %v", err) + } + if _, err := service.Login(ctx, "admin", "correct-password"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("old credentials error = %v, want ErrInvalidCredentials", err) + } + if _, err := service.Login(ctx, "new-admin", "replacement-password"); err != nil { + t.Fatalf("new credentials login error = %v", err) + } + if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("old session error = %v, want ErrUnauthorized", err) + } +} + +func TestResetAdminCredentialsValidatesInput(t *testing.T) { + service := newTestService(t) + for _, test := range []struct { + name string + username string + password string + }{ + {name: "empty username", password: "replacement-password"}, + {name: "control whitespace", username: "bad\tname", password: "replacement-password"}, + {name: "short password", username: "admin", password: "short"}, + } { + t.Run(test.name, func(t *testing.T) { + if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil { + t.Fatal("ResetAdminCredentials() accepted invalid input") + } + }) + } +} + func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) { ctx := context.Background() service := newTestService(t)