Files
ps_shop/internal/prestashop/routes/service.go
T
2026-05-12 01:13:01 +02:00

604 lines
13 KiB
Go

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<id>\d+)(?:-\d+)?-(?P<rewrite>.+?)(?:-[^-/.]*)?\.html$`)
var fallbackCategorySegment = regexp.MustCompile(`^(?P<id>\d+)-(?P<rewrite>[^/]+)$`)
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 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 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<rewrite>[^/]+)"
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 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 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
}