From efc6e4a816df82189f8e866c8a56b4b3f2032378 Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:52 +0800 Subject: [PATCH] fix: validate announcement and proxy date ranges --- backend/internal/service/admin_proxy.go | 6 ++ .../admin_proxy_date_validation_test.go | 41 +++++++++++++ .../internal/service/announcement_service.go | 9 +++ backend/internal/service/json_time.go | 9 +++ .../service/json_time_validation_test.go | 60 +++++++++++++++++++ .../src/views/admin/AnnouncementsView.vue | 4 +- frontend/src/views/admin/ProxiesView.vue | 4 +- 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/admin_proxy_date_validation_test.go create mode 100644 backend/internal/service/json_time.go create mode 100644 backend/internal/service/json_time_validation_test.go diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go index 9b5a9223c7..1ae7eb807e 100644 --- a/backend/internal/service/admin_proxy.go +++ b/backend/internal/service/admin_proxy.go @@ -56,6 +56,9 @@ func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([] } func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { + if !isJSONTimeInRange(input.ExpiresAt) { + return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999") + } // 规范化 fallback_mode mode := input.FallbackMode if mode == "" { @@ -91,6 +94,9 @@ func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyIn } func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { + if !isJSONTimeInRange(input.ExpiresAt) { + return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999") + } // 校验:backup_proxy_id 不能是自身 if input.BackupProxyID != nil && *input.BackupProxyID == id { return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") diff --git a/backend/internal/service/admin_proxy_date_validation_test.go b/backend/internal/service/admin_proxy_date_validation_test.go new file mode 100644 index 0000000000..f04e8609ee --- /dev/null +++ b/backend/internal/service/admin_proxy_date_validation_test.go @@ -0,0 +1,41 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminProxyRejectsOutOfRangeExpiry(t *testing.T) { + for _, year := range []int{-1, 10000} { + expiry := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + t.Run(expiry.String(), func(t *testing.T) { + // A nil repository ensures rejection happens before any persistence or probe. + svc := &adminServiceImpl{} + _, err := svc.CreateProxy(context.Background(), &CreateProxyInput{ExpiresAt: &expiry}) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy expiry year") + _, err = svc.UpdateProxy(context.Background(), 1, &UpdateProxyInput{ExpiresAt: &expiry}) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy expiry year") + }) + } +} + +func TestAdminProxyAcceptsJSONExpiryBoundaries(t *testing.T) { + for _, year := range []int{0, 9999} { + expiry := time.Date(year, 12, 31, 23, 59, 59, 0, time.UTC) + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9}} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ExpiresAt: &expiry}) + require.NoError(t, err) + _, err = json.Marshal(got) + require.NoError(t, err) + require.Equal(t, &expiry, repo.proxy.ExpiresAt) + } +} diff --git a/backend/internal/service/announcement_service.go b/backend/internal/service/announcement_service.go index 124790419b..8f78182441 100644 --- a/backend/internal/service/announcement_service.go +++ b/backend/internal/service/announcement_service.go @@ -73,6 +73,10 @@ func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncem return nil, ErrAnnouncementNilInput } + if !isJSONTimeInRange(input.StartsAt) || !isJSONTimeInRange(input.EndsAt) { + return nil, ErrAnnouncementInvalidSchedule + } + title := strings.TrimSpace(input.Title) content := strings.TrimSpace(input.Content) if title == "" || len(title) > 200 { @@ -134,6 +138,11 @@ func (s *AnnouncementService) Update(ctx context.Context, id int64, input *Updat return nil, ErrAnnouncementNilInput } + if (input.StartsAt != nil && !isJSONTimeInRange(*input.StartsAt)) || + (input.EndsAt != nil && !isJSONTimeInRange(*input.EndsAt)) { + return nil, ErrAnnouncementInvalidSchedule + } + a, err := s.announcementRepo.GetByID(ctx, id) if err != nil { return nil, err diff --git a/backend/internal/service/json_time.go b/backend/internal/service/json_time.go new file mode 100644 index 0000000000..7dae95af48 --- /dev/null +++ b/backend/internal/service/json_time.go @@ -0,0 +1,9 @@ +package service + +import "time" + +// isJSONTimeInRange rejects dates that PostgreSQL can store but time.Time cannot +// serialize in API responses. Nil represents an unset schedule or expiry. +func isJSONTimeInRange(value *time.Time) bool { + return value == nil || (value.Year() >= 0 && value.Year() <= 9999) +} diff --git a/backend/internal/service/json_time_validation_test.go b/backend/internal/service/json_time_validation_test.go new file mode 100644 index 0000000000..4ff05f8c2e --- /dev/null +++ b/backend/internal/service/json_time_validation_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAnnouncementServiceRejectsOutOfRangeDates(t *testing.T) { + for _, year := range []int{-1, 10000} { + date := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + for _, field := range []string{"start", "end"} { + t.Run(date.String()+field, func(t *testing.T) { + repo := &announcementRepoStub{} + svc := NewAnnouncementService(repo, nil, nil, nil) + input := &CreateAnnouncementInput{Title: "test", Content: "test"} + if field == "start" { + input.StartsAt = &date + } else { + input.EndsAt = &date + } + _, err := svc.Create(context.Background(), input) + require.ErrorIs(t, err, ErrAnnouncementInvalidSchedule) + require.Nil(t, repo.item, "invalid dates must never reach persistence") + + repo.item = &Announcement{ID: 1, Title: "test", Content: "test"} + update := &UpdateAnnouncementInput{} + ptr := &date + if field == "start" { + update.StartsAt = &ptr + } else { + update.EndsAt = &ptr + } + _, err = svc.Update(context.Background(), 1, update) + require.ErrorIs(t, err, ErrAnnouncementInvalidSchedule) + require.Nil(t, repo.item.StartsAt) + require.Nil(t, repo.item.EndsAt) + }) + } + } +} + +func TestAnnouncementServiceAcceptsJSONDateBoundariesAndClear(t *testing.T) { + start := time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + repo := &announcementRepoStub{} + svc := NewAnnouncementService(repo, nil, nil, nil) + a, err := svc.Create(context.Background(), &CreateAnnouncementInput{Title: "test", Content: "test", StartsAt: &start, EndsAt: &end}) + require.NoError(t, err) + _, err = json.Marshal(a) + require.NoError(t, err) + var clear *time.Time + a, err = svc.Update(context.Background(), 1, &UpdateAnnouncementInput{StartsAt: &clear, EndsAt: &clear}) + require.NoError(t, err) + require.Nil(t, a.StartsAt) + require.Nil(t, a.EndsAt) +} diff --git a/frontend/src/views/admin/AnnouncementsView.vue b/frontend/src/views/admin/AnnouncementsView.vue index ba3981929b..29ea134e8d 100644 --- a/frontend/src/views/admin/AnnouncementsView.vue +++ b/frontend/src/views/admin/AnnouncementsView.vue @@ -201,12 +201,12 @@
{{ t('admin.announcements.form.startsAtHint') }}
{{ t('admin.announcements.form.endsAtHint') }}