package routes import ( "context" "fmt" "regexp" "sort" "strings" "gorm.io/gorm" ) const defaultProductRule = "/product/{rewrite}" const defaultCategoryRule = "/{id}-{rewrite}" const optionalLanguagePrefix = "(?:/[a-zA-Z]{2}(?:-[a-zA-Z]{2})?)?" type Service struct { db *gorm.DB prefix string } type ProductRoute struct { Rule string Prefix string regex *regexp.Regexp } type ProductMatch struct { ID int64 Slug string } type ProductURLData struct { ID int64 Slug string CategoryPath string ProductAttributeID int64 EAN13 string LanguagePrefix string } type CategoryRoute struct { Rule string Prefix string regex *regexp.Regexp } type CategoryMatch struct { ID int64 Slug string } type CategoryURLData struct { ID int64 Slug string LanguagePrefix string } type RouteMatcher interface { Owns(path string) bool } type combinedMatcher struct { matchers []RouteMatcher } var fallbackProductSegment = regexp.MustCompile(`^(?P\d+)(?:-\d+)?-(?P.+?)(?:-[^-/.]*)?\.html$`) var fallbackCategorySegment = regexp.MustCompile(`^(?P\d+)-(?P[^/]+)$`) func NewService(db *gorm.DB, prefix string) *Service { return &Service{db: db, prefix: prefix} } func CombineMatchers(matchers ...RouteMatcher) RouteMatcher { return combinedMatcher{matchers: matchers} } func (s *Service) LoadProductRoute(ctx context.Context) (*ProductRoute, error) { rule, err := s.loadRouteRule(ctx, "PS_ROUTE_product_rule", defaultProductRule) if err != nil { return nil, err } return CompileProductRoute(rule) } func (s *Service) LoadCategoryRoute(ctx context.Context) (*CategoryRoute, error) { rule, err := s.loadRouteRule(ctx, "PS_ROUTE_category_rule", defaultCategoryRule) if err != nil { return nil, err } return CompileCategoryRoute(rule) } func (s *Service) loadRouteRule(ctx context.Context, key, fallback string) (string, error) { rule := fallback if s != nil && s.db != nil { var row struct { Value string `gorm:"column:value"` } query := fmt.Sprintf("SELECT value FROM %sconfiguration WHERE name = ? LIMIT 1", s.prefix) if err := s.db.WithContext(ctx).Raw(query, key).Scan(&row).Error; err != nil { return "", err } if strings.TrimSpace(row.Value) != "" { rule = row.Value } } return rule, nil } func CompileCategoryRoute(rule string) (*CategoryRoute, error) { compiled, prefix, err := compileRouteRule(rule, defaultCategoryRule) if err != nil { return nil, err } return &CategoryRoute{ Rule: normalizeRule(rule, defaultCategoryRule), Prefix: prefix, regex: compiled, }, nil } func (r *CategoryRoute) Match(path string) (slug string, ok bool) { match, ok := r.MatchInfo(path) if !ok { return "", false } return match.Slug, true } func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) { if hasExcludedStaticSegment(path) { return nil, false } if r == nil || r.regex == nil { return fallbackCategoryMatch(path) } matches := r.regex.FindStringSubmatch(path) if matches != nil { out := &CategoryMatch{} for idx, name := range r.regex.SubexpNames() { if idx >= len(matches) || matches[idx] == "" { continue } switch name { case "rewrite": out.Slug = matches[idx] case "id", "id_category": out.ID = parseInt64(matches[idx]) } } if out.ID != 0 { return out, true } } return fallbackCategoryMatch(path) } func (r *CategoryRoute) Owns(path string) bool { match, ok := r.MatchInfo(path) return ok && match.ID != 0 } func (r *CategoryRoute) BuildPath(data CategoryURLData) string { rule := defaultCategoryRule if r != nil { rule = normalizeRule(r.Rule, defaultCategoryRule) } var path strings.Builder path.Grow(len(rule) + len(data.Slug) + 16) path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix)) for i := 0; i < len(rule); { if rule[i] != '{' { path.WriteByte(rule[i]) i++ continue } end := strings.IndexByte(rule[i:], '}') if end < 0 { break } end += i token := rule[i+1 : end] name, before, after := parseToken(token) value := categoryTokenValue(name, data) if value == "" { i = end + 1 continue } path.WriteString(before) path.WriteString(value) path.WriteString(after) i = end + 1 } result := path.String() if result == "" { return "/" } if !strings.HasPrefix(result, "/") { return "/" + result } return result } func (m combinedMatcher) Owns(path string) bool { for _, matcher := range m.matchers { if matcher != nil && matcher.Owns(path) { return true } } return false } func CompileProductRoute(rule string) (*ProductRoute, error) { compiled, prefix, err := compileRouteRule(rule, defaultProductRule) if err != nil { return nil, err } return &ProductRoute{ Rule: normalizeRule(rule, defaultProductRule), Prefix: prefix, regex: compiled, }, nil } func compileRouteRule(rule, fallback string) (*regexp.Regexp, string, error) { rule = normalizeRule(rule, fallback) var pattern strings.Builder pattern.WriteString("^") pattern.WriteString(optionalLanguagePrefix) for i := 0; i < len(rule); { if rule[i] != '{' { pattern.WriteString(regexp.QuoteMeta(string(rule[i]))) i++ continue } end := strings.IndexByte(rule[i:], '}') if end < 0 { return nil, "", fmt.Errorf("invalid product route rule %q", rule) } end += i token := rule[i+1 : end] pattern.WriteString(tokenRegex(token)) i = end + 1 } pattern.WriteString("$") compiled, err := regexp.Compile(pattern.String()) if err != nil { return nil, "", fmt.Errorf("compile route rule %q: %w", rule, err) } return compiled, staticPrefix(rule), nil } func (r *ProductRoute) Match(path string) (slug string, ok bool) { match, ok := r.MatchInfo(path) if !ok { return "", false } return match.Slug, true } func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) { if hasExcludedStaticSegment(path) { return nil, false } if r == nil || r.regex == nil { return fallbackProductMatch(path) } matches := r.regex.FindStringSubmatch(path) if matches != nil { out := &ProductMatch{} for idx, name := range r.regex.SubexpNames() { if idx >= len(matches) || matches[idx] == "" { continue } switch name { case "rewrite": out.Slug = matches[idx] case "id", "id_product": out.ID = parseInt64(matches[idx]) } } if out.ID != 0 { return out, true } } return fallbackProductMatch(path) } func (r *ProductRoute) Owns(path string) bool { match, ok := r.MatchInfo(path) return ok && match.ID != 0 } func (r *ProductRoute) BuildPath(data ProductURLData) string { rule := defaultProductRule if r != nil { rule = normalizeRule(r.Rule, defaultProductRule) } var path strings.Builder path.Grow(len(rule) + len(data.Slug) + len(data.CategoryPath) + len(data.EAN13) + 16) path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix)) for i := 0; i < len(rule); { if rule[i] != '{' { path.WriteByte(rule[i]) i++ continue } end := strings.IndexByte(rule[i:], '}') if end < 0 { break } end += i token := rule[i+1 : end] name, before, after := parseToken(token) value := productTokenValue(name, data) if value == "" { i = end + 1 continue } path.WriteString(before) path.WriteString(value) path.WriteString(after) i = end + 1 } result := path.String() if result == "" { return "/" } if !strings.HasPrefix(result, "/") { return "/" + result } return result } func normalizeRule(rule, fallback string) string { rule = strings.TrimSpace(rule) if rule == "" { rule = fallback } if !strings.HasPrefix(rule, "/") { rule = "/" + rule } return rule } func staticPrefix(rule string) string { rule = strings.TrimSpace(rule) if rule == "" { return "/" } if !strings.HasPrefix(rule, "/") { rule = "/" + rule } if idx := strings.IndexByte(rule, '{'); idx >= 0 { prefix := rule[:idx] if prefix == "" { return "/" } return prefix } return rule } func tokenRegex(token string) string { name, before, after := parseToken(token) if name == "category" && after == "/" { return "(?:[^/]+/)+" } if name == "ean13" { pattern := regexp.QuoteMeta(before) + "[^/]*" + regexp.QuoteMeta(after) return "(?:" + pattern + ")?" } pattern := tokenPattern(name) fragment := regexp.QuoteMeta(before) + pattern + regexp.QuoteMeta(after) if name != "rewrite" && strings.Contains(token, ":") { return "(?:" + fragment + ")?" } return fragment } func parseToken(token string) (name, before, after string) { known := []string{ "id_product_attribute", "id_product", "id_category", "id_manufacturer", "id_supplier", "id_shop", "id_lang", "categories", "category", "rewrite", "ean13", "reference", "meta_title", "manufacturer", "supplier", "price", "id", } sort.SliceStable(known, func(i, j int) bool { return len(known[i]) > len(known[j]) }) for _, candidate := range known { if idx := strings.Index(token, candidate); idx >= 0 { before = trimTokenAffix(token[:idx]) after = trimTokenAffix(token[idx+len(candidate):]) return candidate, before, after } } return strings.Trim(token, ":"), "", "" } func trimTokenAffix(value string) string { return strings.Trim(value, ":") } func tokenPattern(name string) string { switch name { case "rewrite": return "(?P[^/]+)" case "category", "manufacturer", "supplier", "reference", "meta_title": return "[^/]+" case "categories": return "(?:.+?/)?" case "id", "id_product", "id_category", "id_manufacturer", "id_supplier", "id_shop", "id_lang", "id_product_attribute": return "[0-9]+" case "ean13", "price": return "[^/]+" default: return "[^/]+" } } func productTokenValue(name string, data ProductURLData) string { switch name { case "id", "id_product": if data.ID == 0 { return "" } return fmt.Sprintf("%d", data.ID) case "rewrite": return strings.Trim(data.Slug, "/") case "category", "categories": value := strings.Trim(data.CategoryPath, "/") if value == "" { return "" } return value case "id_product_attribute": if data.ProductAttributeID == 0 { return "" } return fmt.Sprintf("%d", data.ProductAttributeID) case "ean13": return strings.TrimSpace(data.EAN13) default: return "" } } func categoryTokenValue(name string, data CategoryURLData) string { switch name { case "id", "id_category": if data.ID == 0 { return "" } return fmt.Sprintf("%d", data.ID) case "rewrite": return strings.Trim(data.Slug, "/") default: return "" } } func normalizeLanguagePrefix(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if !strings.HasPrefix(value, "/") { value = "/" + value } return strings.TrimRight(value, "/") } func fallbackProductSlug(path string) (string, bool) { match, ok := fallbackProductMatch(path) if !ok { return "", false } return match.Slug, true } func fallbackProductMatch(path string) (*ProductMatch, bool) { path = strings.TrimSpace(path) if path == "" { return nil, false } if hasExcludedStaticSegment(path) { return nil, false } if hasExcludedContentSegment(path) { return nil, false } lastSlash := strings.LastIndex(path, "/") segment := path if lastSlash >= 0 { segment = path[lastSlash+1:] } matches := fallbackProductSegment.FindStringSubmatch(segment) if matches == nil { return nil, false } out := &ProductMatch{} for idx, name := range fallbackProductSegment.SubexpNames() { if idx >= len(matches) || matches[idx] == "" { continue } switch name { case "rewrite": out.Slug = matches[idx] case "id": out.ID = parseInt64(matches[idx]) } } if out.ID == 0 { return nil, false } return out, true } func fallbackCategorySlug(path string) (string, bool) { match, ok := fallbackCategoryMatch(path) if !ok { return "", false } return match.Slug, true } func fallbackCategoryMatch(path string) (*CategoryMatch, bool) { path = strings.TrimSpace(path) if path == "" { return nil, false } if hasExcludedStaticSegment(path) { return nil, false } if hasExcludedContentSegment(path) { return nil, false } lastSlash := strings.LastIndex(path, "/") segment := path if lastSlash >= 0 { segment = path[lastSlash+1:] } matches := fallbackCategorySegment.FindStringSubmatch(segment) if matches == nil { return nil, false } out := &CategoryMatch{} for idx, name := range fallbackCategorySegment.SubexpNames() { if idx >= len(matches) || matches[idx] == "" { continue } switch name { case "rewrite": out.Slug = matches[idx] case "id": out.ID = parseInt64(matches[idx]) } } if out.ID == 0 { return nil, false } return out, true } func parseInt64(value string) int64 { var n int64 for _, r := range value { if r < '0' || r > '9' { return 0 } n = n*10 + int64(r-'0') } return n } func hasExcludedContentSegment(path string) bool { path = strings.Trim(path, "/") if path == "" { return false } segments := strings.Split(path, "/") start := 0 if len(segments) > 0 && len(segments[0]) >= 2 && len(segments[0]) <= 5 { start = 1 } for i := start; i < len(segments); i++ { if strings.EqualFold(strings.TrimSpace(segments[i]), "content") { return true } } return false } func hasExcludedStaticSegment(path string) bool { path = strings.TrimSpace(path) if path == "" { return false } path = strings.Trim(path, "/") if path == "" { return false } first := path if idx := strings.IndexByte(first, '/'); idx >= 0 { first = first[:idx] } return strings.EqualFold(strings.TrimSpace(first), "img") }