From 2f3bdc609a98906d1646330cb7077cef94241594 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 17 Sep 2026 11:56:55 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BD=D0=B5=D0=B2=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=E2=80=94=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Имя варианта "main" или с недопустимыми символами больше не отклоняет запрос ошибкой 400 — теперь автоматически конвертируется в валидное (sanitizeProjectVariantName), а API/UI показывают variant_warning вместо блокировки создания/переименования варианта. Co-Authored-By: Claude Sonnet 5 --- cmd/qfs/main.go | 20 ++-- internal/services/project.go | 96 +++++++++++-------- internal/services/project_test.go | 60 ++++++++++-- .../sync/service_projects_push_test.go | 6 +- web/templates/project_detail.html | 27 ++---- web/templates/projects.html | 11 ++- 6 files changed, 140 insertions(+), 80 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index c5099a5..309b872 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1613,6 +1613,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect c.JSON(http.StatusOK, simplified) }) + // ProjectWithWarning flattens a project's fields (via embedding) alongside an + // optional variant_warning, set when an invalid variant name (reserved "main" or + // disallowed characters) was auto-converted into a valid one instead of rejected. + type ProjectWithWarning struct { + *models.Project + VariantWarning string `json:"variant_warning,omitempty"` + } + projects.POST("", func(c *gin.Context) { var req services.CreateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -1623,17 +1631,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"}) return } - project, err := projectService.Create(dbUsername, &req) + project, variantWarning, err := projectService.Create(dbUsername, &req) if err != nil { respondByErrCase(c, err, - errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"}, errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"}, - errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"}, errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"}, ) return } - c.JSON(http.StatusCreated, project) + c.JSON(http.StatusCreated, ProjectWithWarning{Project: project, VariantWarning: variantWarning}) }) projects.GET("/:uuid", func(c *gin.Context) { @@ -1654,20 +1660,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect respondError(c, http.StatusBadRequest, "invalid request", err) return } - project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) + project, variantWarning, err := projectService.Update(c.Param("uuid"), dbUsername, &req) if err != nil { respondByErrCase(c, err, - errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"}, errCase{services.ErrCannotRenameMainVariant, http.StatusBadRequest, "invalid request"}, errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"}, - errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"}, errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"}, errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"}, errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"}, ) return } - c.JSON(http.StatusOK, project) + c.JSON(http.StatusOK, ProjectWithWarning{Project: project, VariantWarning: variantWarning}) }) projects.POST("/:uuid/archive", func(c *gin.Context) { diff --git a/internal/services/project.go b/internal/services/project.go index b7bd769..3655790 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -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 { diff --git a/internal/services/project_test.go b/internal/services/project_test.go index 14887cd..c4f9ab6 100644 --- a/internal/services/project_test.go +++ b/internal/services/project_test.go @@ -1,37 +1,47 @@ package services import ( - "errors" "path/filepath" "testing" "git.mchus.pro/mchus/quoteforge/internal/localdb" ) -func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) { +// TestProjectServiceCreateSanitizesReservedMainVariant verifies that naming a new +// variant "main" no longer errors: the name is auto-converted to a valid one and a +// warning is returned instead of the request being rejected. +func TestProjectServiceCreateSanitizesReservedMainVariant(t *testing.T) { local, err := newProjectTestLocalDB(t) if err != nil { t.Fatalf("open localdb: %v", err) } service := NewProjectService(local) - _, err = service.Create("tester", &CreateProjectRequest{ + project, warning, err := service.Create("tester", &CreateProjectRequest{ Code: "OPS-1", Variant: "main", }) - if !errors.Is(err, ErrReservedMainVariant) { - t.Fatalf("expected ErrReservedMainVariant, got %v", err) + if err != nil { + t.Fatalf("create project: %v", err) + } + if warning == "" { + t.Fatalf("expected a warning about the reserved variant name") + } + if project.Variant == "" || normalizeProjectVariant(project.Variant) == "main" { + t.Fatalf("expected variant to be converted away from 'main', got %q", project.Variant) } } -func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) { +// TestProjectServiceUpdateSanitizesReservedMainVariant mirrors the create case for +// renaming an existing (non-main) variant to "main". +func TestProjectServiceUpdateSanitizesReservedMainVariant(t *testing.T) { local, err := newProjectTestLocalDB(t) if err != nil { t.Fatalf("open localdb: %v", err) } service := NewProjectService(local) - created, err := service.Create("tester", &CreateProjectRequest{ + created, _, err := service.Create("tester", &CreateProjectRequest{ Code: "OPS-1", Variant: "Lenovo", }) @@ -40,11 +50,41 @@ func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) { } mainName := "main" - _, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{ + updated, warning, err := service.Update(created.UUID, "tester", &UpdateProjectRequest{ Variant: &mainName, }) - if !errors.Is(err, ErrReservedMainVariant) { - t.Fatalf("expected ErrReservedMainVariant, got %v", err) + if err != nil { + t.Fatalf("update project: %v", err) + } + if warning == "" { + t.Fatalf("expected a warning about the reserved variant name") + } + if updated.Variant == "" || normalizeProjectVariant(updated.Variant) == "main" { + t.Fatalf("expected variant to be converted away from 'main', got %q", updated.Variant) + } +} + +// TestSanitizeProjectVariantName_InvalidChars verifies disallowed characters are +// stripped/replaced rather than rejected outright. +func TestSanitizeProjectVariantName_InvalidChars(t *testing.T) { + sanitized, warning := sanitizeProjectVariantName("Лендинг сервер!") + if warning == "" { + t.Fatalf("expected a warning for invalid characters") + } + if !projectCodeRe.MatchString(sanitized) { + t.Fatalf("sanitized variant %q still fails projectCodeRe", sanitized) + } +} + +// TestSanitizeProjectVariantName_ValidPassesThrough ensures already-valid names are +// left untouched with no warning. +func TestSanitizeProjectVariantName_ValidPassesThrough(t *testing.T) { + sanitized, warning := sanitizeProjectVariantName("lenovo-2u") + if warning != "" { + t.Fatalf("expected no warning, got %q", warning) + } + if sanitized != "lenovo-2u" { + t.Fatalf("expected unchanged variant, got %q", sanitized) } } diff --git a/internal/services/sync/service_projects_push_test.go b/internal/services/sync/service_projects_push_test.go index c48586b..e5f8b61 100644 --- a/internal/services/sync/service_projects_push_test.go +++ b/internal/services/sync/service_projects_push_test.go @@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) { projectService := services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) - project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"}) + project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"}) if err != nil { t.Fatalf("create project: %v", err) } @@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) - project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"}) + project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"}) if err != nil { t.Fatalf("create project: %v", err) } - if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil { + if _, _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil { t.Fatalf("update project: %v", err) } diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 141b9e9..ace694b 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -681,8 +681,8 @@ function closeVariantActionModal() { function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) { const cleanedBase = (baseName || '').trim(); - if (!cleanedBase || normalizeVariantLabel(cleanedBase).toLowerCase() === 'main') { - return {error: 'Имя варианта не должно быть пустым и не может быть main'}; + if (!cleanedBase) { + return {error: 'Имя варианта не должно быть пустым'}; } const code = (targetCode || '').trim(); @@ -805,10 +805,6 @@ async function saveVariantAction() { }) }); if (!createResp.ok) { - if (createResp.status === 400) { - notify('Имя варианта не может быть main', 'error'); - return; - } if (createResp.status === 409) { notify('Вариант с таким кодом и значением уже существует', 'error'); return; @@ -829,7 +825,7 @@ async function saveVariantAction() { return; } closeVariantActionModal(); - notify('Копия варианта создана', 'success'); + notify(created.variant_warning ? 'Копия варианта создана. ' + created.variant_warning : 'Копия варианта создана', 'success'); window.location.href = '/projects/' + created.uuid; return; } @@ -846,10 +842,6 @@ async function saveVariantAction() { body: JSON.stringify({code: code, variant: name}) }); if (!updateResp.ok) { - if (updateResp.status === 400) { - notify('Имя варианта не может быть main', 'error'); - return; - } if (updateResp.status === 409) { notify('Вариант с таким кодом и значением уже существует', 'error'); return; @@ -857,12 +849,13 @@ async function saveVariantAction() { notify('Не удалось сохранить вариант', 'error'); return; } + const updated = await updateResp.json().catch(() => null); closeVariantActionModal(); await loadProject(); await loadConfigs(); updateDeleteVariantButton(); - notify('Вариант обновлён', 'success'); + notify(updated && updated.variant_warning ? 'Вариант обновлён. ' + updated.variant_warning : 'Вариант обновлён', 'success'); } async function createNewVariant() { @@ -874,10 +867,6 @@ async function createNewVariant() { showToast('Укажите вариант', 'error'); return; } - if (!/^[A-Za-z0-9._-]+$/.test(variant)) { - showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error'); - return; - } const payload = { code: code, variant: variant, @@ -895,7 +884,11 @@ async function createNewVariant() { } const created = await resp.json().catch(() => null); closeNewVariantModal(); - showToast('Вариант создан', 'success'); + if (created && created.variant_warning) { + showToast('Вариант создан. ' + created.variant_warning, 'info'); + } else { + showToast('Вариант создан', 'success'); + } if (created && created.uuid) { window.location.href = '/projects/' + created.uuid; return; diff --git a/web/templates/projects.html b/web/templates/projects.html index 60ad235..c47804b 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -406,10 +406,6 @@ async function createProject() { alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.'); return; } - if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) { - alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.'); - return; - } const resp = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -433,7 +429,11 @@ async function createProject() { alert('Не удалось создать проект'); return; } + const created = await resp.json().catch(() => null); closeCreateProjectModal(); + if (created && created.variant_warning) { + alert(created.variant_warning); + } loadProjects(); } @@ -511,6 +511,9 @@ async function copyProject(projectUUID, projectName) { return; } const newProject = await createResp.json(); + if (newProject.variant_warning) { + alert(newProject.variant_warning); + } const listResp = await fetch('/api/projects/' + projectUUID + '/configs'); if (!listResp.ok) {