This commit is contained in:
2026-05-12 01:13:01 +02:00
commit bf304e17c9
46 changed files with 4358 additions and 0 deletions
+39
View File
@@ -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
}
+11
View File
@@ -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}
}
+162
View File
@@ -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
}
+43
View File
@@ -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)
}
+140
View File
@@ -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),
})
}
+34
View File
@@ -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,
}
}
+118
View File
@@ -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
}
+322
View File
@@ -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
}
+50
View File
@@ -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"
}
+35
View File
@@ -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
}
+241
View File
@@ -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
}
+284
View File
@@ -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},
}
}
+336
View File
@@ -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
}
+68
View File
@@ -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)
}
}
+42
View File
@@ -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)
}
+37
View File
@@ -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
}
+603
View File
@@ -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
}
+373
View File
@@ -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
}
+43
View File
@@ -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()
}
}
+39
View File
@@ -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")
}
}
+16
View File
@@ -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
}
+17
View File
@@ -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
}