Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/pr-size-limit.yml
Original file line number Diff line number Diff line change
@@ -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

Comment thread
MengMengCode marked this conversation as resolved.
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
43 changes: 20 additions & 23 deletions cmd/vocat/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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())
Expand Down Expand Up @@ -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")
Expand All @@ -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."},
Comment thread
MengMengCode marked this conversation as resolved.
"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."},
Expand Down Expand Up @@ -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") }
Expand Down Expand Up @@ -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."
Expand Down
17 changes: 17 additions & 0 deletions cmd/vocat/menu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
18 changes: 18 additions & 0 deletions internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions internal/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading