mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 20:33:43 +08:00
feat: reset admin credentials without requiring current password and update related prompts (#17)
This commit is contained in:
@@ -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
|
||||
+20
-23
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user