This commit is contained in:
2026-05-12 01:13:01 +02:00
commit bf304e17c9
46 changed files with 4358 additions and 0 deletions
+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"
}