routing
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Manifest map[string]string
|
||||
|
||||
func LoadManifest(path string) (Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Manifest{
|
||||
"app.css": "/dist/app.css",
|
||||
"app.js": "/dist/app.js",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func (m Manifest) CSSPath(name string) string {
|
||||
if value, ok := m[name]; ok {
|
||||
return value
|
||||
}
|
||||
return "/dist/" + name
|
||||
}
|
||||
|
||||
func (m Manifest) JSPath(name string) string {
|
||||
if value, ok := m[name]; ok {
|
||||
return value
|
||||
}
|
||||
return "/dist/" + name
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package flags
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
appmiddleware "prestaproxy/internal/http/middleware"
|
||||
pscart "prestaproxy/internal/prestashop/cart"
|
||||
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||
psconfig "prestaproxy/internal/prestashop/config"
|
||||
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||
psroutes "prestaproxy/internal/prestashop/routes"
|
||||
"prestaproxy/internal/render"
|
||||
"prestaproxy/internal/viewmodel"
|
||||
)
|
||||
|
||||
const categorySlugContextKey = "category_slug"
|
||||
const categoryIDContextKey = "category_id"
|
||||
|
||||
type CategoryHandler struct {
|
||||
catalog *pscatalog.Service
|
||||
customers *pscustomer.Service
|
||||
carts *pscart.Service
|
||||
renderer *render.Engine
|
||||
config psconfig.Config
|
||||
products *psroutes.ProductRoute
|
||||
}
|
||||
|
||||
func NewCategoryHandler(catalog *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, products *psroutes.ProductRoute) *CategoryHandler {
|
||||
return &CategoryHandler{
|
||||
catalog: catalog,
|
||||
customers: customers,
|
||||
carts: carts,
|
||||
renderer: renderer,
|
||||
config: cfg,
|
||||
products: products,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CategoryHandler) Show(c echo.Context) error {
|
||||
session := appmiddleware.GetSession(c)
|
||||
if session == nil {
|
||||
session = appmiddleware.GetSession(c)
|
||||
}
|
||||
if h == nil || h.catalog == nil || h.renderer == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "category handler is not initialized")
|
||||
}
|
||||
|
||||
languageID := int64Default(session.LanguageID, 1)
|
||||
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||
shopID := int64Default(session.ShopID, 1)
|
||||
|
||||
category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{
|
||||
ID: categoryID(c),
|
||||
Slug: categorySlug(c),
|
||||
LanguageID: languageID,
|
||||
ShopID: shopID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "category not found")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "category query failed: "+err.Error())
|
||||
}
|
||||
|
||||
var profile *pscustomer.Profile
|
||||
if session.CustomerID != nil && h.customers != nil {
|
||||
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "customer query failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var cartSummary *pscart.Summary
|
||||
if session.CartID != nil && h.carts != nil {
|
||||
cartSummary, err = h.carts.SummaryByID(c.Request().Context(), *session.CartID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart query failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
page := viewmodel.CategoryPageData{
|
||||
Category: *category,
|
||||
Session: session,
|
||||
Customer: profile,
|
||||
CartSummary: cartSummary,
|
||||
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||
}
|
||||
assignCategoryProductLinks(c.Request(), h.products, &page)
|
||||
|
||||
if err := h.renderer.Category(c.Response(), c.Request(), page); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "category render failed: "+err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetCategorySlug(c echo.Context, slug string) {
|
||||
c.Set(categorySlugContextKey, slug)
|
||||
}
|
||||
|
||||
func SetCategoryID(c echo.Context, id int64) {
|
||||
c.Set(categoryIDContextKey, id)
|
||||
}
|
||||
|
||||
func categorySlug(c echo.Context) string {
|
||||
if value := c.Get(categorySlugContextKey); value != nil {
|
||||
if slug, ok := value.(string); ok && slug != "" {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Param("slug"))
|
||||
}
|
||||
|
||||
func categoryID(c echo.Context) int64 {
|
||||
if value := c.Get(categoryIDContextKey); value != nil {
|
||||
if id, ok := value.(int64); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute, page *viewmodel.CategoryPageData) {
|
||||
if page == nil {
|
||||
return
|
||||
}
|
||||
langPrefix := requestLanguagePrefix(req)
|
||||
categoryPath := page.Category.Slug
|
||||
for i := range page.Category.Products {
|
||||
product := &page.Category.Products[i]
|
||||
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||
ID: product.ID,
|
||||
Slug: product.Slug,
|
||||
CategoryPath: categoryPath,
|
||||
EAN13: product.EAN13,
|
||||
LanguagePrefix: langPrefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requestLanguagePrefix(req *http.Request) string {
|
||||
if req == nil || req.URL == nil {
|
||||
return ""
|
||||
}
|
||||
path := strings.Trim(req.URL.Path, "/")
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
first := path
|
||||
if idx := strings.IndexByte(path, '/'); idx >= 0 {
|
||||
first = path[:idx]
|
||||
}
|
||||
first = strings.TrimSpace(first)
|
||||
if len(first) < 2 || len(first) > 5 {
|
||||
return ""
|
||||
}
|
||||
return "/" + first
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Healthz() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func Readyz(appDB, prestaDB *gorm.DB, proxyTarget string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := pingDB(ctx, appDB); err != nil {
|
||||
return echo.NewHTTPError(http.StatusServiceUnavailable, "app db unavailable")
|
||||
}
|
||||
if err := pingDB(ctx, prestaDB); err != nil {
|
||||
return echo.NewHTTPError(http.StatusServiceUnavailable, "prestashop db unavailable")
|
||||
}
|
||||
if proxyTarget == "" {
|
||||
return echo.NewHTTPError(http.StatusServiceUnavailable, "prestashop proxy target unavailable")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
}
|
||||
|
||||
func pingDB(ctx context.Context, db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
appmiddleware "prestaproxy/internal/http/middleware"
|
||||
pscart "prestaproxy/internal/prestashop/cart"
|
||||
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||
psconfig "prestaproxy/internal/prestashop/config"
|
||||
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||
psroutes "prestaproxy/internal/prestashop/routes"
|
||||
"prestaproxy/internal/render"
|
||||
"prestaproxy/internal/viewmodel"
|
||||
)
|
||||
|
||||
const productSlugContextKey = "product_slug"
|
||||
const productIDContextKey = "product_id"
|
||||
|
||||
type ProductHandler struct {
|
||||
products *pscatalog.Service
|
||||
customers *pscustomer.Service
|
||||
carts *pscart.Service
|
||||
renderer *render.Engine
|
||||
config psconfig.Config
|
||||
categories *psroutes.CategoryRoute
|
||||
}
|
||||
|
||||
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, categories *psroutes.CategoryRoute) *ProductHandler {
|
||||
return &ProductHandler{
|
||||
products: products,
|
||||
customers: customers,
|
||||
carts: carts,
|
||||
renderer: renderer,
|
||||
config: cfg,
|
||||
categories: categories,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProductHandler) Show(c echo.Context) error {
|
||||
session := appmiddleware.GetSession(c)
|
||||
if session == nil {
|
||||
session = appmiddleware.GetSession(c)
|
||||
}
|
||||
if h == nil || h.products == nil || h.renderer == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "product handler is not initialized")
|
||||
}
|
||||
|
||||
languageID := int64Default(session.LanguageID, 1)
|
||||
languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||
shopID := int64Default(session.ShopID, 1)
|
||||
|
||||
product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{
|
||||
ID: productID(c),
|
||||
Slug: productSlug(c),
|
||||
LanguageID: languageID,
|
||||
ShopID: shopID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "product not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var profile *pscustomer.Profile
|
||||
if session.CustomerID != nil && h.customers != nil {
|
||||
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var cartSummary *pscart.Summary
|
||||
if session.CartID != nil && h.carts != nil {
|
||||
cartSummary, err = h.carts.SummaryByID(c.Request().Context(), *session.CartID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
page := viewmodel.ProductPageData{
|
||||
Product: *product,
|
||||
CategoryURL: productCategoryURL(c.Request(), h.categories, product),
|
||||
Session: session,
|
||||
Customer: profile,
|
||||
CartSummary: cartSummary,
|
||||
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||
}
|
||||
|
||||
return h.renderer.Product(c.Response(), c.Request(), page)
|
||||
}
|
||||
|
||||
func int64Default(value *int64, fallback int64) int64 {
|
||||
if value == nil || *value == 0 {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func SetProductSlug(c echo.Context, slug string) {
|
||||
c.Set(productSlugContextKey, slug)
|
||||
}
|
||||
|
||||
func SetProductID(c echo.Context, id int64) {
|
||||
c.Set(productIDContextKey, id)
|
||||
}
|
||||
|
||||
func productSlug(c echo.Context) string {
|
||||
if value := c.Get(productSlugContextKey); value != nil {
|
||||
if slug, ok := value.(string); ok && slug != "" {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Param("slug"))
|
||||
}
|
||||
|
||||
func productID(c echo.Context) int64 {
|
||||
if value := c.Get(productIDContextKey); value != nil {
|
||||
if id, ok := value.(int64); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productCategoryURL(req *http.Request, route *psroutes.CategoryRoute, product *pscatalog.ProductPageData) string {
|
||||
if route == nil || product == nil || product.CategoryID == 0 {
|
||||
return ""
|
||||
}
|
||||
return route.BuildPath(psroutes.CategoryURLData{
|
||||
ID: product.CategoryID,
|
||||
Slug: product.CategorySlug,
|
||||
LanguagePrefix: requestLanguagePrefix(req),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"prestaproxy/internal/prestashop/cookie"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const sessionContextKey = "prestashop_session"
|
||||
|
||||
func SetSession(c echo.Context, session *cookie.SessionContext) {
|
||||
if session == nil {
|
||||
session = defaultSession()
|
||||
}
|
||||
c.Set(sessionContextKey, session)
|
||||
}
|
||||
|
||||
func GetSession(c echo.Context) *cookie.SessionContext {
|
||||
if value := c.Get(sessionContextKey); value != nil {
|
||||
if session, ok := value.(*cookie.SessionContext); ok {
|
||||
if session != nil {
|
||||
return session
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultSession()
|
||||
}
|
||||
|
||||
func defaultSession() *cookie.SessionContext {
|
||||
return &cookie.SessionContext{
|
||||
Values: map[string]string{},
|
||||
ParseStatus: cookie.ParseStatusAnonymous,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func RequestID() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
id := c.Request().Header.Get(echo.HeaderXRequestID)
|
||||
if id == "" {
|
||||
id = newRequestID()
|
||||
}
|
||||
c.Response().Header().Set(echo.HeaderXRequestID, id)
|
||||
c.Set(echo.HeaderXRequestID, id)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
func RealIP() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if forwarded := c.Request().Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
c.Set("real_ip", forwarded)
|
||||
} else if host, _, err := net.SplitHostPort(c.Request().RemoteAddr); err == nil {
|
||||
c.Set("real_ip", host)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func AccessLog(logger *slog.Logger) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
start := time.Now()
|
||||
err := next(c)
|
||||
session := GetSession(c)
|
||||
customerID := int64Value(session.CustomerID)
|
||||
cartID := int64Value(session.CartID)
|
||||
|
||||
logger.Info("request complete",
|
||||
"method", c.Request().Method,
|
||||
"path", c.Request().URL.Path,
|
||||
"status", c.Response().Status,
|
||||
"latency_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
|
||||
"parse_status", session.ParseStatus,
|
||||
"customer_id", customerID,
|
||||
"cart_id", cartID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Recover(logger *slog.Logger) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("panic recovered", "error", recovered, "stack", string(debug.Stack()))
|
||||
_ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
}()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HTTPErrorHandler(logger *slog.Logger) echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
if c.Response().Committed {
|
||||
return
|
||||
}
|
||||
|
||||
var httpErr *echo.HTTPError
|
||||
code := http.StatusInternalServerError
|
||||
message := map[string]string{"error": "internal server error"}
|
||||
|
||||
if errors.As(err, &httpErr) {
|
||||
code = httpErr.Code
|
||||
if msg, ok := httpErr.Message.(string); ok {
|
||||
message = map[string]string{"error": msg}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error("request failed", "status", code, "error", err)
|
||||
_ = c.JSON(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
func int64Value(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
psconfig "prestaproxy/internal/prestashop/config"
|
||||
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||
)
|
||||
|
||||
type AnonymousSessionInitializer interface {
|
||||
NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error)
|
||||
}
|
||||
|
||||
type LanguageResolver interface {
|
||||
ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64
|
||||
}
|
||||
|
||||
type ProductRouteMatcher interface {
|
||||
Owns(path string) bool
|
||||
}
|
||||
|
||||
func Session(cfg psconfig.Config, codec pscookie.Codec, initializer AnonymousSessionInitializer, languageResolver LanguageResolver, matcher ProductRouteMatcher) echo.MiddlewareFunc {
|
||||
ownership := cfg.ParseRouteOwnership()
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ownedRoute := ownsProductRoute(ownership.ProductPrefixes, c.Request().URL.Path, matcher)
|
||||
configuredCookieName := cfg.DeriveCookieName(requestCookieHost(c.Request()))
|
||||
cookieName, rawCookie := findPrestaShopCookie(c.Request(), configuredCookieName)
|
||||
if cookieName == "" {
|
||||
cookieName = configuredCookieName
|
||||
}
|
||||
|
||||
session, err := codec.Decode(rawCookie)
|
||||
if err != nil {
|
||||
if ownedRoute {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie decode failed")
|
||||
}
|
||||
SetSession(c, &pscookie.SessionContext{
|
||||
CookieName: cookieName,
|
||||
RawCookie: rawCookie,
|
||||
Values: map[string]string{},
|
||||
ParseStatus: pscookie.ParseStatusInvalid,
|
||||
})
|
||||
return next(c)
|
||||
}
|
||||
session.CookieName = cookieName
|
||||
if ownedRoute && initializer != nil && shouldBootstrapAnonymousSession(rawCookie, session) {
|
||||
session, err = initializer.NewAnonymous(c.Request().Context(), c.Request(), cookieName)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("prestashop session bootstrap failed: %v", err))
|
||||
}
|
||||
}
|
||||
if ownedRoute {
|
||||
applyRequestLanguage(session, resolveRequestLanguageID(c.Request().Context(), c.Request(), session, languageResolver))
|
||||
}
|
||||
if ownedRoute && shouldSetSessionCookie(rawCookie, session) {
|
||||
encoded, err := codec.Encode(session)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie encode failed")
|
||||
}
|
||||
session.RawCookie = encoded
|
||||
setPrestaShopCookie(c.Request(), c.Response(), ownership.ProductPrefixes, cookieName, encoded)
|
||||
}
|
||||
|
||||
SetSession(c, session)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRequestLanguageID(ctx context.Context, req *http.Request, session *pscookie.SessionContext, resolver LanguageResolver) int64 {
|
||||
if resolver == nil {
|
||||
return 0
|
||||
}
|
||||
return resolver.ResolveLanguageID(ctx, req, sessionLanguageID(session))
|
||||
}
|
||||
|
||||
func findPrestaShopCookie(req *http.Request, configuredName string) (name, value string) {
|
||||
cookies := req.Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == configuredName {
|
||||
return cookie.Name, cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
prefix := cookiePrefix(configuredName)
|
||||
if prefix == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
if strings.HasPrefix(cookie.Name, prefix) {
|
||||
return cookie.Name, cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func cookiePrefix(configuredName string) string {
|
||||
if configuredName == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(configuredName, "PrestaShop-") {
|
||||
return "PrestaShop-"
|
||||
}
|
||||
if idx := strings.Index(configuredName, "-"); idx >= 0 {
|
||||
return configuredName[:idx+1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shouldBootstrapAnonymousSession(rawCookie string, session *pscookie.SessionContext) bool {
|
||||
if session == nil {
|
||||
return true
|
||||
}
|
||||
if rawCookie == "" {
|
||||
return true
|
||||
}
|
||||
if session.IsLoggedIn {
|
||||
return false
|
||||
}
|
||||
return session.GuestID == nil ||
|
||||
session.CurrencyID == nil ||
|
||||
session.LanguageID == nil ||
|
||||
session.Values["id_connections"] == "" ||
|
||||
session.Values["iso_code_country"] == ""
|
||||
}
|
||||
|
||||
func shouldSetSessionCookie(rawCookie string, session *pscookie.SessionContext) bool {
|
||||
if session == nil {
|
||||
return false
|
||||
}
|
||||
if rawCookie == "" {
|
||||
return true
|
||||
}
|
||||
return rawCookie != session.RawCookie
|
||||
}
|
||||
|
||||
func applyRequestLanguage(session *pscookie.SessionContext, languageID int64) {
|
||||
if session == nil || languageID == 0 {
|
||||
return
|
||||
}
|
||||
if current := sessionLanguageID(session); current == languageID {
|
||||
return
|
||||
}
|
||||
|
||||
if session.Values == nil {
|
||||
session.Values = map[string]string{}
|
||||
}
|
||||
|
||||
value := strconv.FormatInt(languageID, 10)
|
||||
session.LanguageID = int64Ptr(languageID)
|
||||
session.Values["id_lang"] = value
|
||||
session.Values["id_language"] = value
|
||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_lang", 1)
|
||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_language", 3)
|
||||
|
||||
if !session.IsLoggedIn {
|
||||
if checksum := anonymousSessionChecksum(session, languageID); checksum != "" {
|
||||
session.Values["checksum"] = checksum
|
||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "checksum", len(session.OrderedKeys))
|
||||
}
|
||||
}
|
||||
|
||||
session.Plaintext = ""
|
||||
session.RawCookie = ""
|
||||
}
|
||||
|
||||
func sessionLanguageID(session *pscookie.SessionContext) int64 {
|
||||
if session == nil || session.LanguageID == nil {
|
||||
return 0
|
||||
}
|
||||
return *session.LanguageID
|
||||
}
|
||||
|
||||
func anonymousSessionChecksum(session *pscookie.SessionContext, languageID int64) string {
|
||||
if session == nil || session.Values == nil {
|
||||
return ""
|
||||
}
|
||||
guestID, _ := strconv.ParseInt(session.Values["id_guest"], 10, 64)
|
||||
connectionID, _ := strconv.ParseInt(session.Values["id_connections"], 10, 64)
|
||||
currencyID, _ := strconv.ParseInt(session.Values["id_currency"], 10, 64)
|
||||
shopID, _ := strconv.ParseInt(session.Values["id_shop"], 10, 64)
|
||||
if guestID == 0 || connectionID == 0 || currencyID == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, 32)
|
||||
for _, value := range []int64{guestID, connectionID, languageID, currencyID, shopID} {
|
||||
buf = strconv.AppendInt(buf, value, 10)
|
||||
buf = append(buf, '|')
|
||||
}
|
||||
return strconv.FormatUint(uint64(crc32.ChecksumIEEE(buf)), 10)
|
||||
}
|
||||
|
||||
func ensureOrderedKey(keys []string, key string, index int) []string {
|
||||
for i, existing := range keys {
|
||||
if existing != key {
|
||||
continue
|
||||
}
|
||||
if i == index || index >= len(keys) {
|
||||
return keys
|
||||
}
|
||||
keys = append(keys[:i], keys[i+1:]...)
|
||||
break
|
||||
}
|
||||
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(keys) {
|
||||
return append(keys, key)
|
||||
}
|
||||
|
||||
keys = append(keys, "")
|
||||
copy(keys[index+1:], keys[index:])
|
||||
keys[index] = key
|
||||
return keys
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func setPrestaShopCookie(req *http.Request, res *echo.Response, ownedPrefixes []string, name, value string) {
|
||||
http.SetCookie(res.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: requestCookiePath(req.URL.Path, ownedPrefixes),
|
||||
Domain: requestCookieDomain(req),
|
||||
Secure: requestCookieSecure(req),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func requestCookiePath(requestPath string, ownedPrefixes []string) string {
|
||||
for _, prefix := range ownedPrefixes {
|
||||
if prefix == "" || !strings.HasPrefix(requestPath, prefix) {
|
||||
continue
|
||||
}
|
||||
base := path.Clean(strings.TrimSuffix(prefix, "/"))
|
||||
if base == "." || base == "/" {
|
||||
return "/"
|
||||
}
|
||||
parent := path.Dir(base)
|
||||
if parent == "." {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasSuffix(parent, "/") {
|
||||
parent += "/"
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
return "/"
|
||||
}
|
||||
|
||||
func requestCookieDomain(req *http.Request) string {
|
||||
host := requestCookieHost(req)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := url.Parse("http://" + host); err == nil {
|
||||
return parsed.Hostname()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func requestCookieHost(req *http.Request) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
if strings.Contains(host, ",") {
|
||||
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func requestCookieSecure(req *http.Request) bool {
|
||||
if req.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if forwarded := req.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||
if strings.Contains(forwarded, ",") {
|
||||
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return strings.EqualFold(forwarded, "https")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ownsProductRoute(prefixes []string, path string, matcher ProductRouteMatcher) bool {
|
||||
if matcher != nil {
|
||||
return matcher.Owns(path)
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if prefix != "" && len(path) >= len(prefix) && path[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
target *url.URL
|
||||
proxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func New(target string) (*Handler, error) {
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = parsed.Host
|
||||
req.Header.Set("X-Forwarded-Host", req.Header.Get("Host"))
|
||||
req.Header.Set("X-Forwarded-Proto", scheme(req))
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
target: parsed,
|
||||
proxy: proxy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Handle(c echo.Context) error {
|
||||
h.proxy.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
func scheme(req *http.Request) string {
|
||||
if req.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if header := req.Header.Get("X-Forwarded-Proto"); header != "" {
|
||||
return header
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"prestaproxy/internal/assets"
|
||||
"prestaproxy/internal/viewmodel"
|
||||
"prestaproxy/templates"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
assets assets.Manifest
|
||||
}
|
||||
|
||||
func New(manifest assets.Manifest) *Engine {
|
||||
return &Engine{assets: manifest}
|
||||
}
|
||||
|
||||
func (e *Engine) Product(w http.ResponseWriter, r *http.Request, data viewmodel.ProductPageData) error {
|
||||
startHTMLStream(w)
|
||||
component := templates.ProductPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||
return component.Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel.CategoryPageData) error {
|
||||
startHTMLStream(w)
|
||||
component := templates.CategoryPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||
return component.Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func startHTMLStream(w http.ResponseWriter) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
headers := w.Header()
|
||||
if headers.Get("Content-Type") == "" {
|
||||
headers.Set("Content-Type", "text/html; charset=utf-8")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FeatureFlag struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"uniqueIndex;size:191"`
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func Open(dialect, dsn string) (*gorm.DB, error) {
|
||||
switch dialect {
|
||||
case "mysql", "mariadb":
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&FeatureFlag{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported app db dialect")
|
||||
}
|
||||
}
|
||||
|
||||
func OpenPresta(dialect, dsn string) (*gorm.DB, error) {
|
||||
switch dialect {
|
||||
case "mysql", "mariadb":
|
||||
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
default:
|
||||
return nil, errors.New("unsupported prestashop db dialect")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
pscart "prestaproxy/internal/prestashop/cart"
|
||||
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||
)
|
||||
|
||||
type CategoryPageData struct {
|
||||
Category pscatalog.CategoryPageData
|
||||
Session *pscookie.SessionContext
|
||||
Customer *pscustomer.Profile
|
||||
CartSummary *pscart.Summary
|
||||
ShopBaseURL string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
pscart "prestaproxy/internal/prestashop/cart"
|
||||
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||
)
|
||||
|
||||
type ProductPageData struct {
|
||||
Product pscatalog.ProductPageData
|
||||
CategoryURL string
|
||||
Session *pscookie.SessionContext
|
||||
Customer *pscustomer.Profile
|
||||
CartSummary *pscart.Summary
|
||||
ShopBaseURL string
|
||||
}
|
||||
Reference in New Issue
Block a user