mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 04:13:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8552b670 | ||
|
|
dbc6db4f08 | ||
|
|
57539df603 | ||
|
|
ecce22eacf |
@@ -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
|
||||
+1
-1
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
|
||||
# ---- Stage 3: minimal runtime ----
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
@@ -2328,6 +2328,11 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
|
||||
}
|
||||
|
||||
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
|
||||
// Telegram polling is a long-lived notification channel and must use the
|
||||
// same administrator-configured destination exceptions as test messages,
|
||||
// SMS pushes and automatic-task notifications. This keeps SSRF protection
|
||||
// enabled while allowing explicit DNS Fake-IP ranges such as 198.18/15.
|
||||
ctx = bot.notificationDestinationContext(ctx)
|
||||
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
|
||||
if err != nil {
|
||||
return redactTelegramError(err, config.Token)
|
||||
@@ -2370,6 +2375,13 @@ func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *telegramBot) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if bot.server == nil {
|
||||
return ctx
|
||||
}
|
||||
return bot.server.notificationDestinationContext(ctx)
|
||||
}
|
||||
|
||||
func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error {
|
||||
target := config.ChatID
|
||||
if chatID != 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -57,6 +58,19 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
}}}
|
||||
ctx := bot.notificationDestinationContext(context.Background())
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
||||
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||
}
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://169.254.169.254", "123456:test-token", "getUpdates"); err == nil {
|
||||
t.Fatal("metadata address became reachable through Telegram allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramCommand(t *testing.T) {
|
||||
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
|
||||
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
|
||||
|
||||
Reference in New Issue
Block a user