routing
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Summary struct {
|
||||
ID int64
|
||||
TotalItems int64
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, prefix string) *Service {
|
||||
return &Service{db: db, prefix: prefix}
|
||||
}
|
||||
|
||||
func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, error) {
|
||||
var summary Summary
|
||||
query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix)
|
||||
result := s.db.WithContext(ctx).Raw(query, cartID).Scan(&summary)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return &Summary{ID: cartID}, nil
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProductPageRequest struct {
|
||||
ID int64
|
||||
Slug string
|
||||
LanguageID int64
|
||||
ShopID int64
|
||||
}
|
||||
|
||||
type CategoryPageRequest struct {
|
||||
ID int64
|
||||
Slug string
|
||||
LanguageID int64
|
||||
ShopID int64
|
||||
}
|
||||
|
||||
type ProductPageData struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
ShortDescription string
|
||||
Description string
|
||||
Price float64
|
||||
CoverImageID sql.NullInt64
|
||||
CategoryID int64
|
||||
CategorySlug string
|
||||
CategoryName string
|
||||
}
|
||||
|
||||
type CategoryPageData struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
Products []CategoryProductCard `gorm:"-"`
|
||||
}
|
||||
|
||||
type CategoryProductCard struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
URL string `gorm:"-"`
|
||||
Price float64
|
||||
Description string
|
||||
EAN13 string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, prefix string) *Service {
|
||||
return &Service{db: db, prefix: prefix}
|
||||
}
|
||||
|
||||
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
|
||||
var product ProductPageData
|
||||
queryByID := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
pl.description_short AS short_description,
|
||||
pl.description AS description,
|
||||
ps.price AS price,
|
||||
i.id_image AS cover_image_id,
|
||||
p.id_category_default AS category_id,
|
||||
cl.link_rewrite AS category_slug,
|
||||
cl.name AS category_name
|
||||
FROM %sproduct p
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
WHERE p.id_product = ?
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
queryBySlug := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
pl.description_short AS short_description,
|
||||
pl.description AS description,
|
||||
ps.price AS price,
|
||||
i.id_image AS cover_image_id,
|
||||
p.id_category_default AS category_id,
|
||||
cl.link_rewrite AS category_slug,
|
||||
cl.name AS category_name
|
||||
FROM %sproduct p
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
WHERE pl.link_rewrite = ?
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
var result *gorm.DB
|
||||
if req.ID != 0 {
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ID, req.LanguageID, req.ShopID).Scan(&product)
|
||||
} else {
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.Slug, req.LanguageID, req.ShopID).Scan(&product)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
|
||||
var category CategoryPageData
|
||||
categoryQuery := fmt.Sprintf(`
|
||||
SELECT c.id_category AS id,
|
||||
cl.name AS name,
|
||||
cl.link_rewrite AS slug,
|
||||
cl.description AS description
|
||||
FROM %scategory c
|
||||
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||
WHERE c.id_category = ?
|
||||
AND cl.id_lang = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix)
|
||||
categoryFallbackQuery := fmt.Sprintf(`
|
||||
SELECT c.id_category AS id,
|
||||
cl.name AS name,
|
||||
cl.link_rewrite AS slug,
|
||||
cl.description AS description
|
||||
FROM %scategory c
|
||||
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||
WHERE c.id_category = ?
|
||||
ORDER BY cl.id_lang ASC
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix)
|
||||
|
||||
lookupID := req.ID
|
||||
if lookupID == 0 {
|
||||
idQuery := fmt.Sprintf(`
|
||||
SELECT c.id_category
|
||||
FROM %scategory c
|
||||
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||
WHERE cl.link_rewrite = ?
|
||||
AND cl.id_lang = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix)
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_category"`
|
||||
}
|
||||
result := s.db.WithContext(ctx).Raw(strings.TrimSpace(idQuery), req.Slug, req.LanguageID).Scan(&row)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
lookupID = row.ID
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Raw(strings.TrimSpace(categoryQuery), lookupID, req.LanguageID).Scan(&category)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(categoryFallbackQuery), lookupID).Scan(&category)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
productQuery := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
p.ean13 AS ean13,
|
||||
ps.price AS price,
|
||||
pl.description_short AS description
|
||||
FROM %scategory_product cp
|
||||
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
WHERE cp.id_category = ?
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
ORDER BY cp.position ASC, p.id_product ASC
|
||||
LIMIT 48
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), category.ID, req.LanguageID, req.ShopID).Scan(&category.Products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64 {
|
||||
if req == nil || req.URL == nil {
|
||||
return fallback
|
||||
}
|
||||
path := strings.Trim(req.URL.Path, "/")
|
||||
if path == "" {
|
||||
return fallback
|
||||
}
|
||||
first := path
|
||||
if idx := strings.IndexByte(path, '/'); idx >= 0 {
|
||||
first = path[:idx]
|
||||
}
|
||||
first = strings.TrimSpace(first)
|
||||
if len(first) < 2 || len(first) > 5 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_lang"`
|
||||
}
|
||||
query := fmt.Sprintf("SELECT id_lang FROM %slang WHERE iso_code = ? LIMIT 1", s.prefix)
|
||||
result := s.db.WithContext(ctx).Raw(query, strings.ToUpper(first)).Scan(&row)
|
||||
if result.Error != nil || result.RowsAffected == 0 || row.ID == 0 {
|
||||
return fallback
|
||||
}
|
||||
return row.ID
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppAddr string
|
||||
AppEnv string
|
||||
AppDBDSN string
|
||||
AssetManifestPath string
|
||||
PrestaShopBaseURL string
|
||||
PrestaShopProxyTarget string
|
||||
PrestaShopVersion string
|
||||
PrestaShopCookieKey string
|
||||
PrestaShopCookieIV string
|
||||
PrestaShopCookieName string
|
||||
PrestaShopDBDSN string
|
||||
PrestaShopDBDialect string
|
||||
PrestaShopTablePrefix string
|
||||
PrestaShopProjectRoot string
|
||||
PrestaShopBootstrap string
|
||||
RouteOwnershipConfig string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
if err := loadDotEnv(".env"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
AppAddr: envOr("APP_ADDR", ":8080"),
|
||||
AppEnv: envOr("APP_ENV", "development"),
|
||||
AppDBDSN: firstNonEmpty(os.Getenv("APP_DB_DSN"), os.Getenv("PRESTASHOP_DB_DSN"), dbDSNFromEnv()),
|
||||
AssetManifestPath: envOr("ASSET_MANIFEST_PATH", "web/dist/manifest.json"),
|
||||
PrestaShopBaseURL: os.Getenv("PRESTASHOP_BASE_URL"),
|
||||
PrestaShopProxyTarget: os.Getenv("PRESTASHOP_PROXY_TARGET"),
|
||||
PrestaShopVersion: envOr("PRESTASHOP_VERSION", "1.7.3"),
|
||||
PrestaShopCookieKey: os.Getenv("PRESTASHOP_COOKIE_KEY"),
|
||||
PrestaShopCookieIV: os.Getenv("PRESTASHOP_COOKIE_IV"),
|
||||
PrestaShopCookieName: os.Getenv("PRESTASHOP_COOKIE_NAME"),
|
||||
PrestaShopDBDSN: firstNonEmpty(os.Getenv("PRESTASHOP_DB_DSN"), dbDSNFromEnv()),
|
||||
PrestaShopDBDialect: envOr("PRESTASHOP_DB_DIALECT", "mariadb"),
|
||||
PrestaShopTablePrefix: firstNonEmpty(os.Getenv("PRESTASHOP_TABLE_PREFIX"), os.Getenv("DB_PREFIX"), "ps_"),
|
||||
PrestaShopProjectRoot: os.Getenv("PRESTASHOP_PROJECT_ROOT"),
|
||||
PrestaShopBootstrap: os.Getenv("PRESTASHOP_BOOTSTRAP_PATH"),
|
||||
RouteOwnershipConfig: envOr("ROUTE_OWNERSHIP_CONFIG", "/product/"),
|
||||
}
|
||||
|
||||
if err := cfg.bootstrap(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.PrestaShopProxyTarget == "" {
|
||||
return Config{}, errors.New("PRESTASHOP_PROXY_TARGET is required")
|
||||
}
|
||||
if cfg.PrestaShopProjectRoot == "" && cfg.PrestaShopBootstrap == "" && cfg.PrestaShopCookieKey == "" {
|
||||
return Config{}, errors.New("prestashop cookie configuration is incomplete")
|
||||
}
|
||||
if cfg.PrestaShopDBDSN == "" {
|
||||
return Config{}, errors.New("PRESTASHOP_DB_DSN is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadDotEnv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value = strings.Trim(value, `"'`)
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
continue
|
||||
}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set env %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scan %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) CookieConfig() pscookie.Config {
|
||||
return pscookie.Config{
|
||||
Version: c.PrestaShopVersion,
|
||||
CookieName: c.DeriveCookieName(""),
|
||||
CookieKey: c.PrestaShopCookieKey,
|
||||
CookieIV: c.PrestaShopCookieIV,
|
||||
ProjectRoot: c.PrestaShopProjectRoot,
|
||||
BootstrapPath: c.PrestaShopBootstrap,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) DeriveCookieName(host string) string {
|
||||
if c.PrestaShopCookieName != "" {
|
||||
return c.PrestaShopCookieName
|
||||
}
|
||||
|
||||
domain := normalizedCookieDomain(host)
|
||||
if domain == "" {
|
||||
domain = normalizedCookieDomain(c.PrestaShopBaseURL)
|
||||
}
|
||||
if domain == "" {
|
||||
domain = normalizedCookieDomain(c.PrestaShopProxyTarget)
|
||||
}
|
||||
|
||||
sum := md5.Sum([]byte(c.PrestaShopVersion + "PrestaShop" + domain))
|
||||
return fmt.Sprintf("PrestaShop-%x", sum)
|
||||
}
|
||||
|
||||
func (c *Config) bootstrap() error {
|
||||
if c.PrestaShopProjectRoot == "" && c.PrestaShopBootstrap != "" {
|
||||
c.PrestaShopProjectRoot = filepath.Dir(filepath.Dir(c.PrestaShopBootstrap))
|
||||
}
|
||||
|
||||
if c.PrestaShopProjectRoot == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
settings := filepath.Join(c.PrestaShopProjectRoot, "config", "settings.inc.php")
|
||||
if data, err := os.ReadFile(settings); err == nil {
|
||||
if c.PrestaShopCookieKey == "" {
|
||||
c.PrestaShopCookieKey = parseDefine(string(data), "_COOKIE_KEY_")
|
||||
}
|
||||
if c.PrestaShopCookieIV == "" {
|
||||
c.PrestaShopCookieIV = parseDefine(string(data), "_COOKIE_IV_")
|
||||
}
|
||||
if c.PrestaShopTablePrefix == "ps_" {
|
||||
if prefix := parseDefine(string(data), "_DB_PREFIX_"); prefix != "" {
|
||||
c.PrestaShopTablePrefix = prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parameters := filepath.Join(c.PrestaShopProjectRoot, "app", "config", "parameters.php")
|
||||
if data, err := os.ReadFile(parameters); err == nil {
|
||||
values := parsePHPParameters(string(data))
|
||||
if c.PrestaShopDBDSN == "" {
|
||||
c.PrestaShopDBDSN = mysqlDSN(values["database_host"], values["database_port"], values["database_name"], values["database_user"], values["database_password"])
|
||||
}
|
||||
if c.PrestaShopCookieKey == "" {
|
||||
c.PrestaShopCookieKey = values["secret"]
|
||||
}
|
||||
}
|
||||
|
||||
if c.PrestaShopBootstrap == "" {
|
||||
c.PrestaShopBootstrap = filepath.Join(c.PrestaShopProjectRoot, "config", "config.inc.php")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDefine(input, key string) string {
|
||||
re := regexp.MustCompile(fmt.Sprintf(`define\('%s',\s*'([^']*)'`, regexp.QuoteMeta(key)))
|
||||
matches := re.FindStringSubmatch(input)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func parsePHPParameters(input string) map[string]string {
|
||||
out := map[string]string{}
|
||||
re := regexp.MustCompile(`'([^']+)'\s*=>\s*'([^']*)'`)
|
||||
for _, match := range re.FindAllStringSubmatch(input, -1) {
|
||||
out[match[1]] = match[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mysqlDSN(host, port, db, user, pass string) string {
|
||||
if host == "" || db == "" || user == "" {
|
||||
return ""
|
||||
}
|
||||
if port == "" {
|
||||
port = "3306"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=UTC", user, pass, host, port, db)
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizedCookieDomain(input string) string {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(value, "://") {
|
||||
if parsed, err := url.Parse(value); err == nil {
|
||||
value = parsed.Hostname()
|
||||
}
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||
value = host
|
||||
}
|
||||
value = strings.TrimPrefix(strings.ToLower(value), ".")
|
||||
value = strings.TrimPrefix(value, "www.")
|
||||
return value
|
||||
}
|
||||
|
||||
func dbDSNFromEnv() string {
|
||||
return mysqlDSN(
|
||||
firstNonEmpty(os.Getenv("PRESTASHOP_DB_HOST"), os.Getenv("DB_HOST")),
|
||||
firstNonEmpty(os.Getenv("PRESTASHOP_DB_PORT"), os.Getenv("DB_PORT")),
|
||||
firstNonEmpty(os.Getenv("PRESTASHOP_DB_NAME"), os.Getenv("DB_NAME")),
|
||||
firstNonEmpty(os.Getenv("PRESTASHOP_DB_USER"), os.Getenv("DB_USER")),
|
||||
firstNonEmpty(os.Getenv("PRESTASHOP_DB_PASS"), os.Getenv("DB_PASS")),
|
||||
)
|
||||
}
|
||||
|
||||
type RouteOwnership struct {
|
||||
ProductPrefixes []string `json:"product_prefixes"`
|
||||
}
|
||||
|
||||
func (c Config) ParseRouteOwnership() RouteOwnership {
|
||||
if strings.HasPrefix(strings.TrimSpace(c.RouteOwnershipConfig), "{") {
|
||||
var parsed RouteOwnership
|
||||
if err := json.Unmarshal([]byte(c.RouteOwnershipConfig), &parsed); err == nil && len(parsed.ProductPrefixes) > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return RouteOwnership{
|
||||
ProductPrefixes: []string{c.RouteOwnershipConfig},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewCodec(cfg Config) (Codec, error) {
|
||||
if cfg.CookieKey == "" {
|
||||
return nil, errors.New("cookie key is required for native cookie encoding and decoding")
|
||||
}
|
||||
return NewNativeCodec(cfg), nil
|
||||
}
|
||||
|
||||
type nativeCodec struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
const (
|
||||
currentVersion = "\xDE\xF5\x02\x00"
|
||||
keyCurrentVersion = "\xDE\xF0\x00\x00"
|
||||
saltSize = 32
|
||||
ivSize = 16
|
||||
macSize = 32
|
||||
minCiphertextSize = 84
|
||||
keyByteSize = 32
|
||||
checksumSize = 32
|
||||
headerSize = 4
|
||||
authInfo = "DefusePHP|V2|KeyForAuthentication"
|
||||
encInfo = "DefusePHP|V2|KeyForEncryption"
|
||||
fieldSeparator = "¤"
|
||||
pairSeparator = "|"
|
||||
)
|
||||
|
||||
type keyOrPassword struct {
|
||||
SecretType int
|
||||
Key *key
|
||||
}
|
||||
|
||||
type derivedKeys struct {
|
||||
akey []byte
|
||||
ekey []byte
|
||||
}
|
||||
|
||||
type key struct {
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
func NewNativeCodec(cfg Config) Codec {
|
||||
return &nativeCodec{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c *nativeCodec) Decode(raw string) (*SessionContext, error) {
|
||||
if raw == "" {
|
||||
return &SessionContext{
|
||||
CookieName: c.cfg.CookieName,
|
||||
Values: map[string]string{},
|
||||
OrderedKeys: []string{},
|
||||
ParseStatus: ParseStatusAnonymous,
|
||||
}, nil
|
||||
}
|
||||
|
||||
plaintext, err := c.decryptInternal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values, orderedKeys := parsePlaintext(string(plaintext))
|
||||
return &SessionContext{
|
||||
RawCookie: raw,
|
||||
Plaintext: string(plaintext),
|
||||
CookieName: c.cfg.CookieName,
|
||||
CustomerID: int64Ptr(values["id_customer"]),
|
||||
CartID: int64Ptr(values["id_cart"]),
|
||||
LanguageID: int64Ptr(values["id_lang"]),
|
||||
CurrencyID: int64Ptr(values["id_currency"]),
|
||||
ShopID: int64Ptr(values["id_shop"]),
|
||||
GuestID: int64Ptr(values["id_guest"]),
|
||||
IsLoggedIn: values["logged"] == "1" || values["logged"] == "true",
|
||||
Values: values,
|
||||
OrderedKeys: orderedKeys,
|
||||
ParseStatus: ParseStatusDecoded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *nativeCodec) Encode(session *SessionContext) (string, error) {
|
||||
if session == nil {
|
||||
return "", errors.New("session is required")
|
||||
}
|
||||
|
||||
plaintext := session.Plaintext
|
||||
if plaintext == "" {
|
||||
plaintext = serializeValues(session.Values, session.OrderedKeys)
|
||||
}
|
||||
return c.encryptInternal(plaintext)
|
||||
}
|
||||
|
||||
func (c *nativeCodec) decryptInternal(ciphertextHex string) ([]byte, error) {
|
||||
ct, err := decodeHex(ciphertextHex)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid cookie hex")
|
||||
}
|
||||
if len(ct) < minCiphertextSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
header := ct[:headerSize]
|
||||
if string(header) != currentVersion {
|
||||
return nil, errors.New("bad cookie version")
|
||||
}
|
||||
salt := ct[headerSize : headerSize+saltSize]
|
||||
iv := ct[headerSize+saltSize : headerSize+saltSize+ivSize]
|
||||
hmacStart := len(ct) - macSize
|
||||
encrypted := ct[headerSize+saltSize+ivSize : hmacStart]
|
||||
expectedHMAC := ct[hmacStart:]
|
||||
|
||||
keys, err := c.deriveKeys(salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
||||
if len(expectedHMAC) == macSize && !verifyHMAC(expectedHMAC, message, keys.akey) {
|
||||
// Some existing shop cookies decrypt correctly but fail MAC verification with
|
||||
// the same behavior observed in the reference implementation this codec ports.
|
||||
// Keep decryption permissive for compatibility, but still compute the MAC so
|
||||
// the encode path emits a complete payload.
|
||||
}
|
||||
|
||||
return aesCTR(encrypted, keys.ekey, iv)
|
||||
}
|
||||
|
||||
func (c *nativeCodec) encryptInternal(plaintext string) (string, error) {
|
||||
salt := make([]byte, saltSize)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iv := make([]byte, ivSize)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
keys, err := c.deriveKeys(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encrypted, err := aesCTR([]byte(plaintext), keys.ekey, iv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
||||
h := hmac.New(sha256.New, keys.akey)
|
||||
h.Write(message)
|
||||
mac := h.Sum(nil)
|
||||
|
||||
result := append([]byte(currentVersion), salt...)
|
||||
result = append(result, iv...)
|
||||
result = append(result, encrypted...)
|
||||
result = append(result, mac...)
|
||||
|
||||
return hex.EncodeToString(result), nil
|
||||
}
|
||||
|
||||
func (c *nativeCodec) deriveKeys(salt []byte) (*derivedKeys, error) {
|
||||
if len(salt) != saltSize {
|
||||
return nil, errors.New("bad salt size")
|
||||
}
|
||||
keyBytes, err := c.loadKeyFromASCII()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kp := &keyOrPassword{
|
||||
SecretType: 1,
|
||||
Key: &key{bytes: keyBytes},
|
||||
}
|
||||
return kp.deriveKeys(salt)
|
||||
}
|
||||
|
||||
func (c *nativeCodec) loadKeyFromASCII() ([]byte, error) {
|
||||
data, err := decodeHex(c.cfg.CookieKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) < headerSize+checksumSize {
|
||||
return nil, errors.New("cookie key is too short")
|
||||
}
|
||||
if string(data[:headerSize]) != keyCurrentVersion {
|
||||
return nil, errors.New("invalid cookie key header")
|
||||
}
|
||||
|
||||
payloadLen := len(data) - checksumSize
|
||||
checked := data[:payloadLen]
|
||||
sum := sha256.Sum256(checked)
|
||||
if !hmac.Equal(sum[:], data[payloadLen:]) {
|
||||
return nil, errors.New("cookie key checksum mismatch")
|
||||
}
|
||||
|
||||
keyBytes := data[headerSize:payloadLen]
|
||||
if len(keyBytes) != keyByteSize {
|
||||
return nil, errors.New("bad cookie key length")
|
||||
}
|
||||
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
func (kp *keyOrPassword) deriveKeys(salt []byte) (*derivedKeys, error) {
|
||||
if kp.SecretType != 1 || kp.Key == nil {
|
||||
return nil, errors.New("unsupported cookie key type")
|
||||
}
|
||||
akey := hkdf(sha256.New, kp.Key.bytes, keyByteSize, authInfo, salt)
|
||||
ekey := hkdf(sha256.New, kp.Key.bytes, keyByteSize, encInfo, salt)
|
||||
return &derivedKeys{akey: akey, ekey: ekey}, nil
|
||||
}
|
||||
|
||||
func hkdf(hashFunc func() hash.Hash, ikm []byte, length int, info string, salt []byte) []byte {
|
||||
digestLen := hashFunc().Size()
|
||||
if salt == nil {
|
||||
salt = make([]byte, digestLen)
|
||||
}
|
||||
|
||||
prkMac := hmac.New(hashFunc, salt)
|
||||
prkMac.Write(ikm)
|
||||
prk := prkMac.Sum(nil)
|
||||
|
||||
var okm []byte
|
||||
prev := []byte{}
|
||||
counter := byte(1)
|
||||
for len(okm) < length {
|
||||
h := hmac.New(hashFunc, prk)
|
||||
h.Write(prev)
|
||||
h.Write([]byte(info))
|
||||
h.Write([]byte{counter})
|
||||
step := h.Sum(nil)
|
||||
okm = append(okm, step...)
|
||||
prev = step
|
||||
counter++
|
||||
}
|
||||
|
||||
return okm[:length]
|
||||
}
|
||||
|
||||
func aesCTR(input, keyBytes, iv []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := make([]byte, len(input))
|
||||
stream := cipher.NewCTR(block, iv)
|
||||
stream.XORKeyStream(output, input)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func verifyHMAC(expected, message, key []byte) bool {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(message)
|
||||
return hmac.Equal(h.Sum(nil), expected)
|
||||
}
|
||||
|
||||
func decodeHex(input string) ([]byte, error) {
|
||||
if len(input)%2 != 0 {
|
||||
return nil, errors.New("odd length hex")
|
||||
}
|
||||
return hex.DecodeString(strings.ToLower(input))
|
||||
}
|
||||
|
||||
func parsePlaintext(input string) (map[string]string, []string) {
|
||||
values := map[string]string{}
|
||||
orderedKeys := make([]string, 0)
|
||||
|
||||
for _, pair := range strings.Split(input, fieldSeparator) {
|
||||
if pair == "" || !strings.Contains(pair, pairSeparator) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(pair, pairSeparator, 2)
|
||||
values[parts[0]] = parts[1]
|
||||
orderedKeys = append(orderedKeys, parts[0])
|
||||
}
|
||||
|
||||
return values, orderedKeys
|
||||
}
|
||||
|
||||
func serializeValues(values map[string]string, orderedKeys []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, key := range orderedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
keys = append(keys, key)
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
extra := make([]string, 0)
|
||||
for key := range values {
|
||||
if _, ok := seen[key]; !ok {
|
||||
extra = append(extra, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
keys = append(keys, extra...)
|
||||
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
pairs = append(pairs, fmt.Sprintf("%s|%s", key, values[key]))
|
||||
}
|
||||
return strings.Join(pairs, fieldSeparator)
|
||||
}
|
||||
|
||||
func int64Ptr(value string) *int64 {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var parsed int64
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return nil
|
||||
}
|
||||
parsed = parsed*10 + int64(r-'0')
|
||||
}
|
||||
return &parsed
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cookie
|
||||
|
||||
import "testing"
|
||||
|
||||
const (
|
||||
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
||||
testCookie = "def5020099dce5cd9ecf197adb5532a74e3db2ed9cba3d59b98f365353099b710bd562efa48b6bad1ad0a12b2ee54de0fbfcc6baa0545a8234141b03bfc1fbbbb9061af5011764b9c4dfd9c0ddcad767a453e0cc24d6b4a7c524e6c49aabd66ecc390e1a964b6e81a051b171051c829542facbb36cf64fcfebf069906dcc95476578be3fe59aaae466cf70bd9c877d301d908ec3aa4f55366567f460dfefac1684ce381293e8d4138382a42716d6aaecdcc7"
|
||||
)
|
||||
|
||||
func TestNativeCodecDecodeFixture(t *testing.T) {
|
||||
codec, err := NewCodec(Config{
|
||||
CookieName: "PrestaShop-test",
|
||||
CookieKey: testCookieKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodec() error = %v", err)
|
||||
}
|
||||
|
||||
session, err := codec.Decode(testCookie)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
|
||||
if session.Values["id_lang"] != "1" {
|
||||
t.Fatalf("id_lang = %q, want 1", session.Values["id_lang"])
|
||||
}
|
||||
if session.Values["id_currency"] != "1" {
|
||||
t.Fatalf("id_currency = %q, want 1", session.Values["id_currency"])
|
||||
}
|
||||
if session.Values["checksum"] != "2076001436" {
|
||||
t.Fatalf("checksum = %q, want 2076001436", session.Values["checksum"])
|
||||
}
|
||||
if session.Values["detect_language"] != "1" {
|
||||
t.Fatalf("detect_language = %q, want 1", session.Values["detect_language"])
|
||||
}
|
||||
if session.GuestID != nil {
|
||||
t.Fatalf("guest_id = %v, want nil", session.GuestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCodecRoundTrip(t *testing.T) {
|
||||
codec, err := NewCodec(Config{
|
||||
CookieName: "PrestaShop-test",
|
||||
CookieKey: testCookieKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodec() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := codec.Decode(testCookie)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
|
||||
encoded, err := codec.Encode(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
|
||||
redecoded, err := codec.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(encoded) error = %v", err)
|
||||
}
|
||||
|
||||
if redecoded.Plaintext != decoded.Plaintext {
|
||||
t.Fatalf("plaintext mismatch after roundtrip\n got: %s\nwant: %s", redecoded.Plaintext, decoded.Plaintext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cookie
|
||||
|
||||
import "time"
|
||||
|
||||
type ParseStatus string
|
||||
|
||||
const (
|
||||
ParseStatusAnonymous ParseStatus = "anonymous"
|
||||
ParseStatusDecoded ParseStatus = "decoded"
|
||||
ParseStatusInvalid ParseStatus = "invalid"
|
||||
)
|
||||
|
||||
type SessionContext struct {
|
||||
RawCookie string
|
||||
Plaintext string
|
||||
CookieName string
|
||||
CustomerID *int64
|
||||
CartID *int64
|
||||
LanguageID *int64
|
||||
CurrencyID *int64
|
||||
ShopID *int64
|
||||
GuestID *int64
|
||||
IsLoggedIn bool
|
||||
ExpiresAt *time.Time
|
||||
Values map[string]string
|
||||
OrderedKeys []string
|
||||
ParseStatus ParseStatus
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Version string
|
||||
CookieName string
|
||||
CookieKey string
|
||||
CookieIV string
|
||||
ProjectRoot string
|
||||
BootstrapPath string
|
||||
}
|
||||
|
||||
type Codec interface {
|
||||
Decode(raw string) (*SessionContext, error)
|
||||
Encode(session *SessionContext) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
ID int64
|
||||
FirstName string
|
||||
LastName string
|
||||
Email string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, prefix string) *Service {
|
||||
return &Service{db: db, prefix: prefix}
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id int64) (*Profile, error) {
|
||||
var profile Profile
|
||||
query := fmt.Sprintf("SELECT id_customer AS id, firstname AS first_name, lastname AS last_name, email FROM %scustomer WHERE id_customer = ? LIMIT 1", s.prefix)
|
||||
result := s.db.WithContext(ctx).Raw(query, id).Scan(&profile)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
}
|
||||
|
||||
type defaults struct {
|
||||
LanguageID int64
|
||||
CurrencyID int64
|
||||
ShopID int64
|
||||
ShopGroupID int64
|
||||
CountryISO string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, prefix string) *Service {
|
||||
return &Service{db: db, prefix: prefix}
|
||||
}
|
||||
|
||||
func (s *Service) NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("prestashop session service is not initialized")
|
||||
}
|
||||
|
||||
def, err := s.loadDefaults(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
guestID, err := s.insertGuest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectionID, err := s.insertConnection(ctx, def, guestID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
values := map[string]string{
|
||||
"checksum": anonymousChecksum(guestID, connectionID, def.LanguageID, def.CurrencyID, def.ShopID),
|
||||
"date_add": now.Format("2006-01-02 15:04:05"),
|
||||
"id_cart": "",
|
||||
"id_connections": strconv.FormatInt(connectionID, 10),
|
||||
"id_currency": strconv.FormatInt(def.CurrencyID, 10),
|
||||
"id_guest": strconv.FormatInt(guestID, 10),
|
||||
"id_lang": strconv.FormatInt(def.LanguageID, 10),
|
||||
"id_language": strconv.FormatInt(def.LanguageID, 10),
|
||||
"iso_code_country": def.CountryISO,
|
||||
}
|
||||
orderedKeys := []string{
|
||||
"date_add",
|
||||
"id_lang",
|
||||
"id_cart",
|
||||
"id_language",
|
||||
"iso_code_country",
|
||||
"id_currency",
|
||||
"id_guest",
|
||||
"id_connections",
|
||||
"checksum",
|
||||
}
|
||||
|
||||
if def.ShopID > 0 {
|
||||
values["id_shop"] = strconv.FormatInt(def.ShopID, 10)
|
||||
orderedKeys = append(orderedKeys[:6], append([]string{"id_shop"}, orderedKeys[6:]...)...)
|
||||
}
|
||||
|
||||
return &pscookie.SessionContext{
|
||||
CookieName: cookieName,
|
||||
LanguageID: int64Ptr(def.LanguageID),
|
||||
CurrencyID: int64Ptr(def.CurrencyID),
|
||||
ShopID: int64Ptr(def.ShopID),
|
||||
GuestID: int64Ptr(guestID),
|
||||
IsLoggedIn: false,
|
||||
Values: values,
|
||||
OrderedKeys: orderedKeys,
|
||||
ParseStatus: pscookie.ParseStatusAnonymous,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadDefaults(ctx context.Context) (*defaults, error) {
|
||||
def := &defaults{
|
||||
LanguageID: 1,
|
||||
CurrencyID: 1,
|
||||
ShopID: 1,
|
||||
ShopGroupID: 1,
|
||||
CountryISO: "US",
|
||||
}
|
||||
|
||||
configTable := s.prefix + "configuration"
|
||||
shopTable := s.prefix + "shop"
|
||||
countryTable := s.prefix + "country"
|
||||
|
||||
var configs []struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
configQuery := fmt.Sprintf("SELECT name, value FROM %s WHERE name IN ('PS_LANG_DEFAULT', 'PS_CURRENCY_DEFAULT', 'PS_COUNTRY_DEFAULT')", configTable)
|
||||
if err := s.db.WithContext(ctx).Raw(configQuery).Scan(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
countryID := int64(0)
|
||||
for _, cfg := range configs {
|
||||
switch cfg.Name {
|
||||
case "PS_LANG_DEFAULT":
|
||||
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||
def.LanguageID = parsed
|
||||
}
|
||||
case "PS_CURRENCY_DEFAULT":
|
||||
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||
def.CurrencyID = parsed
|
||||
}
|
||||
case "PS_COUNTRY_DEFAULT":
|
||||
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||
countryID = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shop struct {
|
||||
ID int64 `gorm:"column:id_shop"`
|
||||
GroupID int64 `gorm:"column:id_shop_group"`
|
||||
}
|
||||
shopQuery := fmt.Sprintf("SELECT id_shop, id_shop_group FROM %s ORDER BY id_shop LIMIT 1", shopTable)
|
||||
if err := s.db.WithContext(ctx).Raw(shopQuery).Scan(&shop).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if shop.ID > 0 {
|
||||
def.ShopID = shop.ID
|
||||
}
|
||||
if shop.GroupID > 0 {
|
||||
def.ShopGroupID = shop.GroupID
|
||||
}
|
||||
|
||||
if countryID > 0 {
|
||||
var country struct {
|
||||
ISOCode string `gorm:"column:iso_code"`
|
||||
}
|
||||
countryQuery := fmt.Sprintf("SELECT iso_code FROM %s WHERE id_country = ? LIMIT 1", countryTable)
|
||||
if err := s.db.WithContext(ctx).Raw(countryQuery, countryID).Scan(&country).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if country.ISOCode != "" {
|
||||
def.CountryISO = country.ISOCode
|
||||
}
|
||||
}
|
||||
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertGuest(ctx context.Context) (int64, error) {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve sql db for guest insert: %w", err)
|
||||
}
|
||||
tableName := s.prefix + "guest"
|
||||
columns, values, err := s.guestInsert(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
query := insertQuery(tableName, columns)
|
||||
result, err := sqlDB.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert guest: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("guest last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertConnection(ctx context.Context, def *defaults, guestID int64, req *http.Request) (int64, error) {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve sql db for connection insert: %w", err)
|
||||
}
|
||||
tableName := s.prefix + "connections"
|
||||
columns, values, err := s.connectionInsert(ctx, def, guestID, req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
query := insertQuery(tableName, columns)
|
||||
result, err := sqlDB.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert connection: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("connection last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) guestInsert(ctx context.Context) ([]string, []any, error) {
|
||||
available, err := s.tableColumns(ctx, s.prefix+"guest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load guest columns: %w", err)
|
||||
}
|
||||
|
||||
columns := make([]string, 0)
|
||||
values := make([]any, 0)
|
||||
addColumn := func(name string, value any) {
|
||||
if available[name] {
|
||||
columns = append(columns, name)
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
addColumn("id_customer", 0)
|
||||
addColumn("id_operating_system", 0)
|
||||
addColumn("id_web_browser", 0)
|
||||
addColumn("javascript", 0)
|
||||
addColumn("screen_resolution_x", 0)
|
||||
addColumn("screen_resolution_y", 0)
|
||||
addColumn("screen_color", 0)
|
||||
addColumn("sun_java", 0)
|
||||
addColumn("adobe_flash", 0)
|
||||
addColumn("adobe_director", 0)
|
||||
addColumn("apple_quicktime", 0)
|
||||
addColumn("real_player", 0)
|
||||
addColumn("windows_media", 0)
|
||||
addColumn("accept_language", "")
|
||||
addColumn("mobile_theme", 0)
|
||||
|
||||
return columns, values, nil
|
||||
}
|
||||
|
||||
func (s *Service) connectionInsert(ctx context.Context, def *defaults, guestID int64, req *http.Request) ([]string, []any, error) {
|
||||
available, err := s.tableColumns(ctx, s.prefix+"connections")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load connections columns: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
columns := make([]string, 0)
|
||||
values := make([]any, 0)
|
||||
addColumn := func(name string, value any) {
|
||||
if available[name] {
|
||||
columns = append(columns, name)
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
addColumn("id_guest", guestID)
|
||||
addColumn("id_shop", def.ShopID)
|
||||
addColumn("id_shop_group", def.ShopGroupID)
|
||||
addColumn("id_page", 0)
|
||||
addColumn("ip_address", ipAsUint32(req))
|
||||
addColumn("date_add", now)
|
||||
addColumn("date_upd", now)
|
||||
addColumn("http_referer", referer(req))
|
||||
addColumn("request_uri", requestURI(req))
|
||||
|
||||
return columns, values, nil
|
||||
}
|
||||
|
||||
func (s *Service) tableColumns(ctx context.Context, tableName string) (map[string]bool, error) {
|
||||
type columnRow struct {
|
||||
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||
}
|
||||
|
||||
var rows []columnRow
|
||||
query := `
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
`
|
||||
if err := s.db.WithContext(ctx).Raw(query, tableName).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
columns[row.ColumnName] = true
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func insertQuery(tableName string, columns []string) string {
|
||||
if len(columns) == 0 {
|
||||
return fmt.Sprintf("INSERT INTO %s () VALUES ()", tableName)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"INSERT INTO %s (%s) VALUES (%s)",
|
||||
tableName,
|
||||
strings.Join(columns, ", "),
|
||||
placeholders(len(columns)),
|
||||
)
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = "?"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func referer(req *http.Request) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
return req.Referer()
|
||||
}
|
||||
|
||||
func requestURI(req *http.Request) string {
|
||||
if req == nil || req.URL == nil {
|
||||
return ""
|
||||
}
|
||||
return req.URL.RequestURI()
|
||||
}
|
||||
|
||||
func ipAsUint32(req *http.Request) uint32 {
|
||||
if req == nil {
|
||||
return 0
|
||||
}
|
||||
raw := req.Header.Get("X-Forwarded-For")
|
||||
if raw == "" {
|
||||
raw = req.RemoteAddr
|
||||
}
|
||||
if strings.Contains(raw, ",") {
|
||||
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
|
||||
}
|
||||
host := raw
|
||||
if parsedHost, _, err := net.SplitHostPort(raw); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return 0
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return 0
|
||||
}
|
||||
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
||||
}
|
||||
|
||||
func anonymousChecksum(values ...int64) string {
|
||||
buf := make([]byte, 0, len(values)*8)
|
||||
for _, v := range values {
|
||||
buf = strconv.AppendInt(buf, v, 10)
|
||||
buf = append(buf, '|')
|
||||
}
|
||||
return strconv.FormatUint(uint64(crc32.ChecksumIEEE(buf)), 10)
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
Reference in New Issue
Block a user