fix: невалидное имя варианта проекта — предупреждение вместо ошибки
Имя варианта "main" или с недопустимыми символами больше не отклоняет запрос ошибкой 400 — теперь автоматически конвертируется в валидное (sanitizeProjectVariantName), а API/UI показывают variant_warning вместо блокировки создания/переименования варианта. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
006d7e87c5
commit
2f3bdc609a
@@ -17,19 +17,21 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
)
|
||||
|
||||
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// invalidVariantCharsRe matches runs of characters not allowed in a variant name, so they
|
||||
// can be collapsed to a single separator by sanitizeProjectVariantName.
|
||||
var invalidVariantCharsRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
type ProjectService struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
@@ -61,7 +63,10 @@ type ProjectConfigurationsResult struct {
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||
// Create creates a project. If req.Variant is not a valid variant name (reserved word
|
||||
// "main" or disallowed characters) it is auto-converted into a valid one instead of
|
||||
// rejecting the request; the returned warning is non-empty when that happened.
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, string, error) {
|
||||
var namePtr *string
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
@@ -71,17 +76,14 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
return nil, "", fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := validateProjectVariantName(variant); err != nil {
|
||||
return nil, err
|
||||
return nil, "", ErrProjectCodeInvalidChars
|
||||
}
|
||||
variant, variantWarning := sanitizeProjectVariantName(strings.TrimSpace(req.Variant))
|
||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -99,43 +101,44 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
return localdb.LocalToProject(localProject), variantWarning, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
|
||||
// Update updates a project. Like Create, an invalid req.Variant (reserved word "main" or
|
||||
// disallowed characters) is auto-converted into a valid one instead of rejecting the
|
||||
// request; the returned warning is non-empty when that happened.
|
||||
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, string, error) {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
return nil, "", ErrProjectNotFound
|
||||
}
|
||||
|
||||
if req.Code != nil {
|
||||
code := strings.TrimSpace(*req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
return nil, "", fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
return nil, "", ErrProjectCodeInvalidChars
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
var variantWarning string
|
||||
if req.Variant != nil {
|
||||
newVariant := strings.TrimSpace(*req.Variant)
|
||||
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||
return nil, ErrCannotRenameMainVariant
|
||||
}
|
||||
localProject.Variant = newVariant
|
||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
return nil, "", ErrCannotRenameMainVariant
|
||||
}
|
||||
localProject.Variant, variantWarning = sanitizeProjectVariantName(newVariant)
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
@@ -163,12 +166,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
return localdb.LocalToProject(localProject), variantWarning, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||
@@ -203,14 +206,31 @@ func normalizeProjectVariant(variant string) string {
|
||||
return strings.ToLower(strings.TrimSpace(variant))
|
||||
}
|
||||
|
||||
func validateProjectVariantName(variant string) error {
|
||||
if normalizeProjectVariant(variant) == "main" {
|
||||
return ErrReservedMainVariant
|
||||
// sanitizeProjectVariantName converts an invalid variant name (the reserved word "main",
|
||||
// or a name containing characters outside [A-Za-z0-9._-]) into a valid one, returning a
|
||||
// warning describing the change. Empty input (the implicit main variant) always passes
|
||||
// through unchanged with no warning. Invalid characters are collapsed to a single "-";
|
||||
// if that empties the name, or the result collides with the reserved "main", a "-variant"
|
||||
// suffix is appended to guarantee a valid, non-reserved result.
|
||||
func sanitizeProjectVariantName(variant string) (sanitized string, warning string) {
|
||||
original := strings.TrimSpace(variant)
|
||||
if original == "" {
|
||||
return "", ""
|
||||
}
|
||||
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||
return ErrProjectVariantInvalidChars
|
||||
sanitized = original
|
||||
if !projectCodeRe.MatchString(sanitized) {
|
||||
sanitized = strings.Trim(invalidVariantCharsRe.ReplaceAllString(sanitized, "-"), "-")
|
||||
}
|
||||
return nil
|
||||
if sanitized == "" {
|
||||
sanitized = "variant"
|
||||
}
|
||||
if normalizeProjectVariant(sanitized) == "main" {
|
||||
sanitized += "-variant"
|
||||
}
|
||||
if sanitized == original {
|
||||
return sanitized, ""
|
||||
}
|
||||
return sanitized, fmt.Sprintf("имя варианта %q недопустимо — использовано %q", original, sanitized)
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
|
||||
Reference in New Issue
Block a user