From b3cab3477bf7b7bddb371fd1950045a455699e33 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 23 Jun 2026 11:39:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20/:code/:variant=20URL=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=BF=D1=82=D0=B8=20+=20=D0=B2=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Роут GET /:code/:variant → редирект на /projects/:uuid (case-insensitive) - Валидация имени варианта: только URL-безопасные символы [A-Za-z0-9._-] (бэкенд validateProjectVariantName + клиентская проверка в обеих формах) - Подсказки в UI: «Используется в URL: /КОД/Вариант» Co-Authored-By: Claude Sonnet 4.6 --- cmd/qfs/main.go | 18 +++++++++++++++--- internal/localdb/localdb.go | 8 ++++++++ internal/services/project.go | 27 ++++++++++++++++++++------- web/templates/project_detail.html | 10 ++++++++-- web/templates/projects.html | 9 ++++++++- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index ec94812..3cf1470 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -894,7 +894,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/partnumber-books", webHandler.PartnumberBooks) - // Short project URL: /:code → redirect to /projects/:uuid + // Short project URLs: /:code → main variant, /:code/:variant → named variant router.GET("/:code", func(c *gin.Context) { code := c.Param("code") project, err := projectService.GetByCode(code) @@ -904,6 +904,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } c.Redirect(http.StatusFound, "/projects/"+project.UUID) }) + router.GET("/:code/:variant", func(c *gin.Context) { + code := c.Param("code") + variant := c.Param("variant") + project, err := projectService.GetByCodeAndVariant(code, variant) + if err != nil { + c.Redirect(http.StatusFound, "/projects") + return + } + c.Redirect(http.StatusFound, "/projects/"+project.UUID) + }) // htmx partials partials := router.Group("/partials") @@ -1538,7 +1548,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect if err != nil { switch { case errors.Is(err, services.ErrReservedMainVariant), - errors.Is(err, services.ErrProjectCodeInvalidChars): + errors.Is(err, services.ErrProjectCodeInvalidChars), + errors.Is(err, services.ErrProjectVariantInvalidChars): respondError(c, http.StatusBadRequest, "invalid request", err) case errors.Is(err, services.ErrProjectCodeExists): respondError(c, http.StatusConflict, "conflict detected", err) @@ -1577,7 +1588,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect switch { case errors.Is(err, services.ErrReservedMainVariant), errors.Is(err, services.ErrCannotRenameMainVariant), - errors.Is(err, services.ErrProjectCodeInvalidChars): + errors.Is(err, services.ErrProjectCodeInvalidChars), + errors.Is(err, services.ErrProjectVariantInvalidChars): respondError(c, http.StatusBadRequest, "invalid request", err) case errors.Is(err, services.ErrProjectCodeExists): respondError(c, http.StatusConflict, "conflict detected", err) diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 663ce5b..10f7bca 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -700,6 +700,14 @@ func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) { return &project, nil } +func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) { + var project LocalProject + if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { var project LocalProject if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { diff --git a/internal/services/project.go b/internal/services/project.go index ecf6a86..98e8825 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -17,13 +17,14 @@ 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("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)") + 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("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)") ) // projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs. @@ -194,6 +195,9 @@ func validateProjectVariantName(variant string) error { if normalizeProjectVariant(variant) == "main" { return ErrReservedMainVariant } + if variant != "" && !projectCodeRe.MatchString(variant) { + return ErrProjectVariantInvalidChars + } return nil } @@ -302,6 +306,15 @@ func (s *ProjectService) GetByCode(code string) (*models.Project, error) { return localdb.LocalToProject(localProject), nil } +// GetByCodeAndVariant finds a project by code + variant (both case-insensitive). +func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) { + localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant) + if err != nil { + return nil, ErrProjectNotFound + } + return localdb.LocalToProject(localProject), nil +} + func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) { project, err := s.GetByUUID(projectUUID, ownerUsername) if err != nil { diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 551bff8..17195ea 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -207,9 +207,11 @@
- -
Оставьте пустым для main нельзя — нужно уникальное значение.
+
Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.
@@ -842,6 +844,10 @@ async function createNewVariant() { showToast('Укажите вариант', 'error'); return; } + if (!/^[A-Za-z0-9._-]+$/.test(variant)) { + showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error'); + return; + } const payload = { code: code, variant: variant, diff --git a/web/templates/projects.html b/web/templates/projects.html index 17f6615..60ad235 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -46,8 +46,11 @@
- +

Используется в URL: /КОД/Вариант

@@ -403,6 +406,10 @@ 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'},