commit bf304e17c98d4731ddd175f4bfc29c19d858b59d Author: Marek Goc Date: Tue May 12 01:13:01 2026 +0200 routing diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..e8baca0 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main cmd/proxy/main.go" + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env b/.env new file mode 100644 index 0000000..9dcb631 --- /dev/null +++ b/.env @@ -0,0 +1,37 @@ +# App +APP_ADDR=:8080 +APP_ENV=development +ASSET_MANIFEST_PATH=web/dist/manifest.json + +# Public shop URL and upstream proxy target +PRESTASHOP_BASE_URL=http://localhost +PRESTASHOP_PROXY_TARGET=http://localhost +PRESTASHOP_VERSION=1.7.3 + +# Cookie settings +# Optional explicit override. If omitted, the app derives the cookie name from +# PrestaShop version and normalized request domain using PrestaShop's naming rule. +# PRESTASHOP_COOKIE_NAME= +PRESTASHOP_COOKIE_KEY=def00000cecd7a19e52c6ae0ca758f54dd6e682c8fe4c657b8441974a33c6d11a0fc238a02c0f2de4a46fed7a57e2db8d6f6c4c615a937a26af5163293ae6702bc5d18f4 +PRESTASHOP_COOKIE_IV=vfRFMV42 + +# PrestaShop DB +PRESTASHOP_DB_DIALECT=mariadb +DB_USER=presta +DB_PASS=presta +DB_NAME=presta +DB_PREFIX=ps_ +DB_HOST=localhost +DB_PORT=3306 + +# Optional explicit overrides if you want to bypass the split DB_* settings above. +PRESTASHOP_DB_DSN= +PRESTASHOP_TABLE_PREFIX= + +# Optional bootstrap from an existing PrestaShop install. +# If set correctly, cookie key, DB DSN and table prefix can be discovered automatically. +PRESTASHOP_PROJECT_ROOT= +PRESTASHOP_BOOTSTRAP_PATH= + +# Go-owned route prefixes +ROUTE_OWNERSHIP_CONFIG=/product/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..587cf7c --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# PrestaProxy + +Go reverse proxy in front of PrestaShop `1.7.3` with: + +- `Echo` as the HTTP server +- native Go PrestaShop cookie decode/encode +- `templ` for HTML rendering +- Bun for JS and Tailwind builds +- `GORM` with SQLite for app-local state + +## Current scope + +- Go owns `GET /product/:slug` +- all other routes proxy to the upstream PrestaShop instance +- product data, customer data, and cart summary are read from the PrestaShop database +- session state is derived from the live PrestaShop cookie + +## Requirements + +- Go +- Bun +- `templ` +- access to the PrestaShop database +- PrestaShop cookie key, or access to the PrestaShop install root + +## Configuration + +The service now loads `.env` automatically from the project root at startup. + +Important variables: + +- `PRESTASHOP_PROXY_TARGET`: upstream PrestaShop origin, required +- `PRESTASHOP_COOKIE_NAME`: optional explicit cookie-name override. If omitted, the app derives the standard `PrestaShop-...` name from PrestaShop version and normalized host, and still falls back to prefix matching on reads. +- `PRESTASHOP_COOKIE_KEY`: Defuse/PrestaShop cookie key, required unless bootstrap from install root is used +- `DB_USER`, `DB_PASS`, `DB_NAME`, `DB_HOST`, `DB_PORT`: preferred split MariaDB settings +- `DB_PREFIX`: PrestaShop table prefix +- `PRESTASHOP_DB_DSN`: optional full DSN override +- `PRESTASHOP_PROJECT_ROOT`: optional path to an existing PrestaShop install for bootstrap +- `ROUTE_OWNERSHIP_CONFIG`: route prefix currently handled by Go + +Example MariaDB setup: + +```env +PRESTASHOP_PROXY_TARGET=http://localhost +PRESTASHOP_COOKIE_KEY=def00000... +PRESTASHOP_DB_DIALECT=mariadb +DB_USER=presta +DB_PASS=presta +DB_NAME=presta +DB_PREFIX=ps_ +DB_HOST=mariadb +DB_PORT=3306 +``` + +If `PRESTASHOP_DB_DSN` is set, it takes precedence over the split DB settings. + +## Install + +```bash +bun install +templ generate +go mod tidy +``` + +## Build assets + +```bash +bun run build +``` + +Available Bun scripts: + +- `bun run build:js` +- `bun run build:css` +- `bun run build:manifest` +- `bun run build` +- `bun run dev` + +## Run + +```bash +go run ./cmd/proxy +``` + +Default listen address is `:8080`. + +## Health endpoints + +- `GET /healthz` +- `GET /readyz` + +## Cookie support + +Native cookie logic lives in [internal/prestashop/cookie/codec.go](/home/marek/coding/test/pp/internal/prestashop/cookie/codec.go:1). + +What it does now: + +- decrypts live PrestaShop cookies in Go +- parses plaintext key/value fields +- re-encodes cookies in Go using the same key format + +Current limitation: + +- it can round-trip and re-encode cookie content, but it does not yet recompute higher-level PrestaShop semantic fields like `checksum` for arbitrary mutations + +## Tests + +```bash +go test ./... +``` diff --git a/app.db b/app.db new file mode 100644 index 0000000..1621a7b Binary files /dev/null and b/app.db differ diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go new file mode 100644 index 0000000..4713bb8 --- /dev/null +++ b/cmd/proxy/main.go @@ -0,0 +1,219 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/labstack/echo/v4" + + "prestaproxy/internal/assets" + "prestaproxy/internal/http/handlers" + appmiddleware "prestaproxy/internal/http/middleware" + httpproxy "prestaproxy/internal/http/proxy" + pscart "prestaproxy/internal/prestashop/cart" + pscatalog "prestaproxy/internal/prestashop/catalog" + psconfig "prestaproxy/internal/prestashop/config" + pscookie "prestaproxy/internal/prestashop/cookie" + pscustomer "prestaproxy/internal/prestashop/customer" + psroutes "prestaproxy/internal/prestashop/routes" + pssession "prestaproxy/internal/prestashop/session" + "prestaproxy/internal/render" + "prestaproxy/internal/store" +) + +func main() { + if err := run(); err != nil { + slog.Error("fatal", "error", err) + os.Exit(1) + } +} + +func run() error { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + cfg, err := psconfig.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + appStore, err := store.Open(cfg.PrestaShopDBDialect, cfg.AppDBDSN) + if err != nil { + return fmt.Errorf("open app db: %w", err) + } + defer closeDB("app db", appStore) + + prestaDB, err := store.OpenPresta(cfg.PrestaShopDBDialect, cfg.PrestaShopDBDSN) + if err != nil { + return fmt.Errorf("open prestashop db: %w", err) + } + defer closeDB("prestashop db", prestaDB) + + assetManifest, err := assets.LoadManifest(cfg.AssetManifestPath) + if err != nil { + return fmt.Errorf("load asset manifest: %w", err) + } + + cookieCodec, err := pscookie.NewCodec(cfg.CookieConfig()) + if err != nil { + return fmt.Errorf("create cookie codec: %w", err) + } + + productService := pscatalog.NewService(prestaDB, cfg.PrestaShopTablePrefix) + customerService := pscustomer.NewService(prestaDB, cfg.PrestaShopTablePrefix) + cartService := pscart.NewService(prestaDB, cfg.PrestaShopTablePrefix) + routeService := psroutes.NewService(prestaDB, cfg.PrestaShopTablePrefix) + sessionService := pssession.NewService(prestaDB, cfg.PrestaShopTablePrefix) + productRoute, err := routeService.LoadProductRoute(context.Background()) + if err != nil { + return fmt.Errorf("load product route rule: %w", err) + } + categoryRoute, err := routeService.LoadCategoryRoute(context.Background()) + if err != nil { + return fmt.Errorf("load category route rule: %w", err) + } + productHandler := handlers.NewProductHandler( + productService, + customerService, + cartService, + render.New(assetManifest), + cfg, + categoryRoute, + ) + categoryHandler := handlers.NewCategoryHandler( + productService, + customerService, + cartService, + render.New(assetManifest), + cfg, + productRoute, + ) + + proxyHandler, err := httpproxy.New(cfg.PrestaShopProxyTarget) + if err != nil { + return fmt.Errorf("create reverse proxy: %w", err) + } + + e := echo.New() + e.HideBanner = true + e.HidePort = true + e.HTTPErrorHandler = appmiddleware.HTTPErrorHandler(logger) + e.Use( + appmiddleware.RequestID(), + appmiddleware.RealIP(), + appmiddleware.AccessLog(logger), + appmiddleware.Recover(logger), + appmiddleware.Session(cfg, cookieCodec, sessionService, productService, psroutes.CombineMatchers(productRoute, categoryRoute)), + ) + + e.Static("/dist", "web/dist") + e.GET("/healthz", handlers.Healthz()) + e.GET("/readyz", handlers.Readyz(appStore, prestaDB, cfg.PrestaShopProxyTarget)) + e.GET("/*", func(c echo.Context) error { + productMatch, productOK := productRoute.MatchInfo(c.Request().URL.Path) + categoryMatch, categoryOK := categoryRoute.MatchInfo(c.Request().URL.Path) + productSlug := "" + productID := int64(0) + if productOK { + productSlug = productMatch.Slug + productID = productMatch.ID + } + categorySlug := "" + categoryID := int64(0) + if categoryOK { + categorySlug = categoryMatch.Slug + categoryID = categoryMatch.ID + } + slog.Info("route dispatch probe", + "method", c.Request().Method, + "path", c.Request().URL.Path, + "product_match", productOK, + "product_id", productID, + "product_slug", productSlug, + "category_match", categoryOK, + "category_id", categoryID, + "category_slug", categorySlug, + ) + if !productOK { + if categoryOK { + slog.Info("route dispatch", "owner", "go-category", "method", c.Request().Method, "path", c.Request().URL.Path, "slug", categorySlug, "id", categoryID) + handlers.SetCategoryID(c, categoryID) + handlers.SetCategorySlug(c, categorySlug) + return categoryHandler.Show(c) + } + slog.Info("route dispatch", "owner", "prestashop", "method", c.Request().Method, "path", c.Request().URL.Path) + return proxyHandler.Handle(c) + } + slog.Info("route dispatch", "owner", "go", "method", c.Request().Method, "path", c.Request().URL.Path, "id", productID, "slug", productSlug) + handlers.SetProductID(c, productID) + handlers.SetProductSlug(c, productSlug) + return productHandler.Show(c) + }) + e.Match([]string{ + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodHead, + http.MethodOptions, + http.MethodConnect, + http.MethodTrace, + }, "/*", func(c echo.Context) error { + slog.Info("route dispatch", "owner", "prestashop", "method", c.Request().Method, "path", c.Request().URL.Path) + return proxyHandler.Handle(c) + }) + + server := &http.Server{ + Addr: cfg.AppAddr, + Handler: e, + ReadHeaderTimeout: 5 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + shutdownErrCh := make(chan error, 1) + go func() { + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := e.Shutdown(shutdownCtx) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + shutdownErrCh <- err + }() + + slog.Info("starting server", "addr", cfg.AppAddr, "proxy_target", cfg.PrestaShopProxyTarget) + if err := e.StartServer(server); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + if ctx.Err() != nil { + if err := <-shutdownErrCh; err != nil { + return fmt.Errorf("shutdown server: %w", err) + } + } + + return nil +} + +func closeDB(name string, db interface{ DB() (*sql.DB, error) }) { + sqlDB, err := db.DB() + if err != nil { + slog.Error("resolve db handle", "name", name, "error", err) + return + } + if err := sqlDB.Close(); err != nil { + slog.Error("close db", "name", name, "error", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..69cd708 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module prestaproxy + +go 1.25.0 + +require ( + github.com/a-h/templ v0.3.1001 + github.com/labstack/echo/v4 v4.15.2 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/gommon v0.5.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cfa79d4 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= +github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/assets/manifest.go b/internal/assets/manifest.go new file mode 100644 index 0000000..94a00c6 --- /dev/null +++ b/internal/assets/manifest.go @@ -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 +} diff --git a/internal/flags/service.go b/internal/flags/service.go new file mode 100644 index 0000000..a7aee2c --- /dev/null +++ b/internal/flags/service.go @@ -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} +} diff --git a/internal/http/handlers/category.go b/internal/http/handlers/category.go new file mode 100644 index 0000000..a33d2ec --- /dev/null +++ b/internal/http/handlers/category.go @@ -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 +} diff --git a/internal/http/handlers/health.go b/internal/http/handlers/health.go new file mode 100644 index 0000000..72791f1 --- /dev/null +++ b/internal/http/handlers/health.go @@ -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) +} diff --git a/internal/http/handlers/product.go b/internal/http/handlers/product.go new file mode 100644 index 0000000..f384abc --- /dev/null +++ b/internal/http/handlers/product.go @@ -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), + }) +} diff --git a/internal/http/middleware/context.go b/internal/http/middleware/context.go new file mode 100644 index 0000000..cd8fa5f --- /dev/null +++ b/internal/http/middleware/context.go @@ -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, + } +} diff --git a/internal/http/middleware/http.go b/internal/http/middleware/http.go new file mode 100644 index 0000000..6eb80bd --- /dev/null +++ b/internal/http/middleware/http.go @@ -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 +} diff --git a/internal/http/middleware/session.go b/internal/http/middleware/session.go new file mode 100644 index 0000000..867a28b --- /dev/null +++ b/internal/http/middleware/session.go @@ -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 +} diff --git a/internal/http/proxy/proxy.go b/internal/http/proxy/proxy.go new file mode 100644 index 0000000..64f0f19 --- /dev/null +++ b/internal/http/proxy/proxy.go @@ -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" +} diff --git a/internal/prestashop/cart/service.go b/internal/prestashop/cart/service.go new file mode 100644 index 0000000..bbcde99 --- /dev/null +++ b/internal/prestashop/cart/service.go @@ -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 +} diff --git a/internal/prestashop/catalog/service.go b/internal/prestashop/catalog/service.go new file mode 100644 index 0000000..592b5a5 --- /dev/null +++ b/internal/prestashop/catalog/service.go @@ -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 +} diff --git a/internal/prestashop/config/config.go b/internal/prestashop/config/config.go new file mode 100644 index 0000000..e917b1c --- /dev/null +++ b/internal/prestashop/config/config.go @@ -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}, + } +} diff --git a/internal/prestashop/cookie/codec.go b/internal/prestashop/cookie/codec.go new file mode 100644 index 0000000..08593ef --- /dev/null +++ b/internal/prestashop/cookie/codec.go @@ -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 +} diff --git a/internal/prestashop/cookie/codec_test.go b/internal/prestashop/cookie/codec_test.go new file mode 100644 index 0000000..29a10fd --- /dev/null +++ b/internal/prestashop/cookie/codec_test.go @@ -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) + } +} diff --git a/internal/prestashop/cookie/types.go b/internal/prestashop/cookie/types.go new file mode 100644 index 0000000..85f2098 --- /dev/null +++ b/internal/prestashop/cookie/types.go @@ -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) +} diff --git a/internal/prestashop/customer/service.go b/internal/prestashop/customer/service.go new file mode 100644 index 0000000..7e48263 --- /dev/null +++ b/internal/prestashop/customer/service.go @@ -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 +} diff --git a/internal/prestashop/routes/service.go b/internal/prestashop/routes/service.go new file mode 100644 index 0000000..a81fc32 --- /dev/null +++ b/internal/prestashop/routes/service.go @@ -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\d+)(?:-\d+)?-(?P.+?)(?:-[^-/.]*)?\.html$`) +var fallbackCategorySegment = regexp.MustCompile(`^(?P\d+)-(?P[^/]+)$`) + +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[^/]+)" + 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 +} diff --git a/internal/prestashop/session/service.go b/internal/prestashop/session/service.go new file mode 100644 index 0000000..d2b3c7e --- /dev/null +++ b/internal/prestashop/session/service.go @@ -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 +} diff --git a/internal/render/engine.go b/internal/render/engine.go new file mode 100644 index 0000000..a933732 --- /dev/null +++ b/internal/render/engine.go @@ -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() + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..fba2278 --- /dev/null +++ b/internal/store/store.go @@ -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") + } +} diff --git a/internal/viewmodel/category.go b/internal/viewmodel/category.go new file mode 100644 index 0000000..ae40676 --- /dev/null +++ b/internal/viewmodel/category.go @@ -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 +} diff --git a/internal/viewmodel/product.go b/internal/viewmodel/product.go new file mode 100644 index 0000000..e88557f --- /dev/null +++ b/internal/viewmodel/product.go @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fe2d19 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "prestaproxy", + "private": true, + "scripts": { + "build:js": "bun build ./web/src/app.js --outdir ./web/dist --naming app.js", + "build:css": "bunx tailwindcss -i ./web/src/app.css -o ./web/dist/app.css --minify", + "build:manifest": "bun ./web/write-manifest.mjs", + "build": "bun run build:js && bun run build:css && bun run build:manifest", + "dev": "bun run build" + }, + "devDependencies": { + "tailwindcss": "^3.4.17" + } +} diff --git a/scripts/prestashop_cookie_bridge.php b/scripts/prestashop_cookie_bridge.php new file mode 100644 index 0000000..0a87448 --- /dev/null +++ b/scripts/prestashop_cookie_bridge.php @@ -0,0 +1,79 @@ + \n"); + exit(1); +} + +$mode = $argv[1]; +$bootstrap = $argv[2]; +$input = json_decode(stream_get_contents(STDIN), true); + +if (!is_array($input)) { + fwrite(STDERR, "invalid input\n"); + exit(1); +} + +if (!is_file($bootstrap)) { + fwrite(STDERR, "bootstrap not found\n"); + exit(1); +} + +$cookieName = $input['cookie_name'] ?? null; +if (!$cookieName) { + fwrite(STDERR, "cookie name missing\n"); + exit(1); +} + +$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$_SERVER['REQUEST_METHOD'] = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; +$_COOKIE[$cookieName] = $input['raw_cookie'] ?? ''; + +require_once $bootstrap; + +if (!class_exists('Cookie')) { + fwrite(STDERR, "prestashop cookie class unavailable\n"); + exit(1); +} + +$cookie = new Cookie($cookieName); + +if ($mode !== 'decode') { + fwrite(STDERR, "unsupported mode\n"); + exit(1); +} + +$reflection = new ReflectionClass($cookie); +$content = []; +if ($reflection->hasProperty('_content')) { + $property = $reflection->getProperty('_content'); + $property->setAccessible(true); + $content = $property->getValue($cookie); +} + +$response = [ + 'customer_id' => isset($content['id_customer']) ? (int) $content['id_customer'] : null, + 'cart_id' => isset($content['id_cart']) ? (int) $content['id_cart'] : null, + 'language_id' => isset($content['id_lang']) ? (int) $content['id_lang'] : null, + 'currency_id' => isset($content['id_currency']) ? (int) $content['id_currency'] : null, + 'shop_id' => isset($content['id_shop']) ? (int) $content['id_shop'] : null, + 'guest_id' => isset($content['id_guest']) ? (int) $content['id_guest'] : null, + 'is_logged_in' => !empty($content['logged']), + 'expires_at' => null, + 'values' => array_map(static function ($value): string { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_scalar($value) || $value === null) { + return (string) $value; + } + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; + }, $content), + 'raw_cookie' => $input['raw_cookie'] ?? '', +]; + +header('Content-Type: application/json'); +echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..5806181 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./templates/**/*.templ"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/templates/category.templ b/templates/category.templ new file mode 100644 index 0000000..ee81b7c --- /dev/null +++ b/templates/category.templ @@ -0,0 +1,49 @@ +package templates + +import ( + "fmt" + + "prestaproxy/internal/viewmodel" +) + +templ CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string) { + @Layout(data.Category.Name, cssPath, jsPath) { +
+
+
+

Category

+

{ data.Category.Name }

+
+

{ fmt.Sprintf("Products loaded: %d", len(data.Category.Products)) }

+ if data.Customer != nil { +

{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }

+ } else { +

Guest session

+ } +
+ if data.Category.Description != "" { +
+ @templ.Raw(data.Category.Description) +
+ } +
+ +
+ for _, product := range data.Category.Products { +
+

Product

+

{ product.Name }

+

{ product.Description }

+
+

{ fmt.Sprintf("%.2f", product.Price) }

+ + View Product + +
+
+ } +
+
+
+ } +} diff --git a/templates/category_templ.go b/templates/category_templ.go new file mode 100644 index 0000000..a8e203e --- /dev/null +++ b/templates/category_templ.go @@ -0,0 +1,198 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "prestaproxy/internal/viewmodel" +) + +func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Category

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 15, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Products loaded: %d", len(data.Category.Products))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 17, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Customer != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 19, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Guest session

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Category.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(data.Category.Description).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, product := range data.Category.Products { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Product

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 35, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(product.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 36, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", product.Price)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 38, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

View Product
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(data.Category.Name, cssPath, jsPath).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/layout.templ b/templates/layout.templ new file mode 100644 index 0000000..3be200b --- /dev/null +++ b/templates/layout.templ @@ -0,0 +1,17 @@ +package templates + +templ Layout(title string, cssPath string, jsPath string) { + + + + + + { title } + + + + + { children... } + + +} diff --git a/templates/layout_templ.go b/templates/layout_templ.go new file mode 100644 index 0000000..19f72d3 --- /dev/null +++ b/templates/layout_templ.go @@ -0,0 +1,87 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Layout(title string, cssPath string, jsPath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 9, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/product.templ b/templates/product.templ new file mode 100644 index 0000000..cd225f3 --- /dev/null +++ b/templates/product.templ @@ -0,0 +1,58 @@ +package templates + +import ( + "fmt" + + "prestaproxy/internal/viewmodel" +) + +templ ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string) { + @Layout(data.Product.Name, cssPath, jsPath) { +
+
+
+ Prestashop Proxy +
+ if data.Customer != nil { +

{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }

+ } else { +

Guest session

+ } + if data.CartSummary != nil { +

{ fmt.Sprintf("Cart items: %d", data.CartSummary.TotalItems) }

+ } +
+
+ +
+
+

Product

+ if data.CategoryURL != "" && data.Product.CategoryName != "" { + { data.Product.CategoryName } + } +

{ data.Product.Name }

+

{ data.Product.ShortDescription }

+
+ @templ.Raw(data.Product.Description) +
+
+ + +
+
+
+ } +} diff --git a/templates/product_templ.go b/templates/product_templ.go new file mode 100644 index 0000000..85d9fb2 --- /dev/null +++ b/templates/product_templ.go @@ -0,0 +1,246 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "prestaproxy/internal/viewmodel" +) + +func ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Prestashop Proxy
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Customer != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 17, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Guest session

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.CartSummary != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Cart items: %d", data.CartSummary.TotalItems)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 22, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Product

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.CategoryURL != "" && data.Product.CategoryName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.CategoryName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 31, Col: 168} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 33, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.ShortDescription) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 34, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(data.Product.Description).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(data.Product.Name, cssPath, jsPath).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..2f95017 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..be9df8f Binary files /dev/null and b/tmp/main differ diff --git a/web/dist/app.css b/web/dist/app.css new file mode 100644 index 0000000..cbc9a8f --- /dev/null +++ b/web/dist/app.css @@ -0,0 +1 @@ +body{margin:0;font-family:"IBM Plex Sans",sans-serif;background:#0c0a09;color:#f5f5f4}a{color:inherit}button{cursor:pointer} diff --git a/web/dist/app.js b/web/dist/app.js new file mode 100644 index 0000000..3df2ef7 --- /dev/null +++ b/web/dist/app.js @@ -0,0 +1 @@ +document.documentElement.dataset.js="ready"; diff --git a/web/dist/manifest.json b/web/dist/manifest.json new file mode 100644 index 0000000..e923b42 --- /dev/null +++ b/web/dist/manifest.json @@ -0,0 +1,4 @@ +{ + "app.css": "/dist/app.css", + "app.js": "/dist/app.js" +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..7d9ed4e --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,23 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --cta-glow: 0 0 0 0 rgba(0, 0, 0, 0); + } + + body { + font-family: "IBM Plex Sans", sans-serif; + } + + h1, h2, h3 { + font-family: "Cormorant Garamond", serif; + } +} + +@layer components { + button[type='submit'] { + box-shadow: var(--cta-glow); + } +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..7f0b644 --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,12 @@ +const root = document.documentElement; +root.dataset.js = "ready"; + +const cartButton = document.querySelector("button[type='submit']"); +if (cartButton) { + cartButton.addEventListener("mouseenter", () => { + root.style.setProperty("--cta-glow", "0 0 0 4px rgba(252, 211, 77, 0.12)"); + }); + cartButton.addEventListener("mouseleave", () => { + root.style.setProperty("--cta-glow", "0 0 0 0 rgba(0,0,0,0)"); + }); +} diff --git a/web/write-manifest.mjs b/web/write-manifest.mjs new file mode 100644 index 0000000..2edbd1b --- /dev/null +++ b/web/write-manifest.mjs @@ -0,0 +1,13 @@ +import { writeFileSync } from "node:fs"; + +writeFileSync( + new URL("./dist/manifest.json", import.meta.url), + JSON.stringify( + { + "app.css": "/dist/app.css", + "app.js": "/dist/app.js", + }, + null, + 2, + ), +);