632 lines
13 KiB
Go
632 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 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<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 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")
|
|
}
|