routing
This commit is contained in:
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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 ./...
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manifest map[string]string
|
||||||
|
|
||||||
|
func LoadManifest(path string) (Manifest, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{
|
||||||
|
"app.css": "/dist/app.css",
|
||||||
|
"app.js": "/dist/app.js",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manifest) CSSPath(name string) string {
|
||||||
|
if value, ok := m[name]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "/dist/" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manifest) JSPath(name string) string {
|
||||||
|
if value, ok := m[name]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "/dist/" + name
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package flags
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
appmiddleware "prestaproxy/internal/http/middleware"
|
||||||
|
pscart "prestaproxy/internal/prestashop/cart"
|
||||||
|
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||||
|
psconfig "prestaproxy/internal/prestashop/config"
|
||||||
|
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||||
|
psroutes "prestaproxy/internal/prestashop/routes"
|
||||||
|
"prestaproxy/internal/render"
|
||||||
|
"prestaproxy/internal/viewmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
const categorySlugContextKey = "category_slug"
|
||||||
|
const categoryIDContextKey = "category_id"
|
||||||
|
|
||||||
|
type CategoryHandler struct {
|
||||||
|
catalog *pscatalog.Service
|
||||||
|
customers *pscustomer.Service
|
||||||
|
carts *pscart.Service
|
||||||
|
renderer *render.Engine
|
||||||
|
config psconfig.Config
|
||||||
|
products *psroutes.ProductRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryHandler(catalog *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, products *psroutes.ProductRoute) *CategoryHandler {
|
||||||
|
return &CategoryHandler{
|
||||||
|
catalog: catalog,
|
||||||
|
customers: customers,
|
||||||
|
carts: carts,
|
||||||
|
renderer: renderer,
|
||||||
|
config: cfg,
|
||||||
|
products: products,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoryHandler) Show(c echo.Context) error {
|
||||||
|
session := appmiddleware.GetSession(c)
|
||||||
|
if session == nil {
|
||||||
|
session = appmiddleware.GetSession(c)
|
||||||
|
}
|
||||||
|
if h == nil || h.catalog == nil || h.renderer == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "category handler is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
languageID := int64Default(session.LanguageID, 1)
|
||||||
|
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||||
|
shopID := int64Default(session.ShopID, 1)
|
||||||
|
|
||||||
|
category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{
|
||||||
|
ID: categoryID(c),
|
||||||
|
Slug: categorySlug(c),
|
||||||
|
LanguageID: languageID,
|
||||||
|
ShopID: shopID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "category not found")
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "category query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile *pscustomer.Profile
|
||||||
|
if session.CustomerID != nil && h.customers != nil {
|
||||||
|
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "customer query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cartSummary *pscart.Summary
|
||||||
|
if session.CartID != nil && h.carts != nil {
|
||||||
|
cartSummary, err = h.carts.SummaryByID(c.Request().Context(), *session.CartID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := viewmodel.CategoryPageData{
|
||||||
|
Category: *category,
|
||||||
|
Session: session,
|
||||||
|
Customer: profile,
|
||||||
|
CartSummary: cartSummary,
|
||||||
|
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||||
|
}
|
||||||
|
assignCategoryProductLinks(c.Request(), h.products, &page)
|
||||||
|
|
||||||
|
if err := h.renderer.Category(c.Response(), c.Request(), page); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "category render failed: "+err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetCategorySlug(c echo.Context, slug string) {
|
||||||
|
c.Set(categorySlugContextKey, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetCategoryID(c echo.Context, id int64) {
|
||||||
|
c.Set(categoryIDContextKey, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func categorySlug(c echo.Context) string {
|
||||||
|
if value := c.Get(categorySlugContextKey); value != nil {
|
||||||
|
if slug, ok := value.(string); ok && slug != "" {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.Param("slug"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryID(c echo.Context) int64 {
|
||||||
|
if value := c.Get(categoryIDContextKey); value != nil {
|
||||||
|
if id, ok := value.(int64); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute, page *viewmodel.CategoryPageData) {
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
langPrefix := requestLanguagePrefix(req)
|
||||||
|
categoryPath := page.Category.Slug
|
||||||
|
for i := range page.Category.Products {
|
||||||
|
product := &page.Category.Products[i]
|
||||||
|
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||||
|
ID: product.ID,
|
||||||
|
Slug: product.Slug,
|
||||||
|
CategoryPath: categoryPath,
|
||||||
|
EAN13: product.EAN13,
|
||||||
|
LanguagePrefix: langPrefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLanguagePrefix(req *http.Request) string {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := strings.Trim(req.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
first := path
|
||||||
|
if idx := strings.IndexByte(path, '/'); idx >= 0 {
|
||||||
|
first = path[:idx]
|
||||||
|
}
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
if len(first) < 2 || len(first) > 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/" + first
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Healthz() echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Readyz(appDB, prestaDB *gorm.DB, proxyTarget string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := pingDB(ctx, appDB); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusServiceUnavailable, "app db unavailable")
|
||||||
|
}
|
||||||
|
if err := pingDB(ctx, prestaDB); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusServiceUnavailable, "prestashop db unavailable")
|
||||||
|
}
|
||||||
|
if proxyTarget == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusServiceUnavailable, "prestashop proxy target unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pingDB(ctx context.Context, db *gorm.DB) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.PingContext(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
appmiddleware "prestaproxy/internal/http/middleware"
|
||||||
|
pscart "prestaproxy/internal/prestashop/cart"
|
||||||
|
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||||
|
psconfig "prestaproxy/internal/prestashop/config"
|
||||||
|
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||||
|
psroutes "prestaproxy/internal/prestashop/routes"
|
||||||
|
"prestaproxy/internal/render"
|
||||||
|
"prestaproxy/internal/viewmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
const productSlugContextKey = "product_slug"
|
||||||
|
const productIDContextKey = "product_id"
|
||||||
|
|
||||||
|
type ProductHandler struct {
|
||||||
|
products *pscatalog.Service
|
||||||
|
customers *pscustomer.Service
|
||||||
|
carts *pscart.Service
|
||||||
|
renderer *render.Engine
|
||||||
|
config psconfig.Config
|
||||||
|
categories *psroutes.CategoryRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, categories *psroutes.CategoryRoute) *ProductHandler {
|
||||||
|
return &ProductHandler{
|
||||||
|
products: products,
|
||||||
|
customers: customers,
|
||||||
|
carts: carts,
|
||||||
|
renderer: renderer,
|
||||||
|
config: cfg,
|
||||||
|
categories: categories,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProductHandler) Show(c echo.Context) error {
|
||||||
|
session := appmiddleware.GetSession(c)
|
||||||
|
if session == nil {
|
||||||
|
session = appmiddleware.GetSession(c)
|
||||||
|
}
|
||||||
|
if h == nil || h.products == nil || h.renderer == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "product handler is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
languageID := int64Default(session.LanguageID, 1)
|
||||||
|
languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||||
|
shopID := int64Default(session.ShopID, 1)
|
||||||
|
|
||||||
|
product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{
|
||||||
|
ID: productID(c),
|
||||||
|
Slug: productSlug(c),
|
||||||
|
LanguageID: languageID,
|
||||||
|
ShopID: shopID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "product not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile *pscustomer.Profile
|
||||||
|
if session.CustomerID != nil && h.customers != nil {
|
||||||
|
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cartSummary *pscart.Summary
|
||||||
|
if session.CartID != nil && h.carts != nil {
|
||||||
|
cartSummary, err = h.carts.SummaryByID(c.Request().Context(), *session.CartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := viewmodel.ProductPageData{
|
||||||
|
Product: *product,
|
||||||
|
CategoryURL: productCategoryURL(c.Request(), h.categories, product),
|
||||||
|
Session: session,
|
||||||
|
Customer: profile,
|
||||||
|
CartSummary: cartSummary,
|
||||||
|
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.renderer.Product(c.Response(), c.Request(), page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Default(value *int64, fallback int64) int64 {
|
||||||
|
if value == nil || *value == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetProductSlug(c echo.Context, slug string) {
|
||||||
|
c.Set(productSlugContextKey, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetProductID(c echo.Context, id int64) {
|
||||||
|
c.Set(productIDContextKey, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func productSlug(c echo.Context) string {
|
||||||
|
if value := c.Get(productSlugContextKey); value != nil {
|
||||||
|
if slug, ok := value.(string); ok && slug != "" {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.Param("slug"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func productID(c echo.Context) int64 {
|
||||||
|
if value := c.Get(productIDContextKey); value != nil {
|
||||||
|
if id, ok := value.(int64); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func productCategoryURL(req *http.Request, route *psroutes.CategoryRoute, product *pscatalog.ProductPageData) string {
|
||||||
|
if route == nil || product == nil || product.CategoryID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return route.BuildPath(psroutes.CategoryURLData{
|
||||||
|
ID: product.CategoryID,
|
||||||
|
Slug: product.CategorySlug,
|
||||||
|
LanguagePrefix: requestLanguagePrefix(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"prestaproxy/internal/prestashop/cookie"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionContextKey = "prestashop_session"
|
||||||
|
|
||||||
|
func SetSession(c echo.Context, session *cookie.SessionContext) {
|
||||||
|
if session == nil {
|
||||||
|
session = defaultSession()
|
||||||
|
}
|
||||||
|
c.Set(sessionContextKey, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(c echo.Context) *cookie.SessionContext {
|
||||||
|
if value := c.Get(sessionContextKey); value != nil {
|
||||||
|
if session, ok := value.(*cookie.SessionContext); ok {
|
||||||
|
if session != nil {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSession() *cookie.SessionContext {
|
||||||
|
return &cookie.SessionContext{
|
||||||
|
Values: map[string]string{},
|
||||||
|
ParseStatus: cookie.ParseStatusAnonymous,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestID() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
id := c.Request().Header.Get(echo.HeaderXRequestID)
|
||||||
|
if id == "" {
|
||||||
|
id = newRequestID()
|
||||||
|
}
|
||||||
|
c.Response().Header().Set(echo.HeaderXRequestID, id)
|
||||||
|
c.Set(echo.HeaderXRequestID, id)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestID() string {
|
||||||
|
var buf [16]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func RealIP() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
if forwarded := c.Request().Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
c.Set("real_ip", forwarded)
|
||||||
|
} else if host, _, err := net.SplitHostPort(c.Request().RemoteAddr); err == nil {
|
||||||
|
c.Set("real_ip", host)
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AccessLog(logger *slog.Logger) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
start := time.Now()
|
||||||
|
err := next(c)
|
||||||
|
session := GetSession(c)
|
||||||
|
customerID := int64Value(session.CustomerID)
|
||||||
|
cartID := int64Value(session.CartID)
|
||||||
|
|
||||||
|
logger.Info("request complete",
|
||||||
|
"method", c.Request().Method,
|
||||||
|
"path", c.Request().URL.Path,
|
||||||
|
"status", c.Response().Status,
|
||||||
|
"latency_ms", time.Since(start).Milliseconds(),
|
||||||
|
"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
|
||||||
|
"parse_status", session.ParseStatus,
|
||||||
|
"customer_id", customerID,
|
||||||
|
"cart_id", cartID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Recover(logger *slog.Logger) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
logger.Error("panic recovered", "error", recovered, "stack", string(debug.Stack()))
|
||||||
|
_ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTTPErrorHandler(logger *slog.Logger) echo.HTTPErrorHandler {
|
||||||
|
return func(err error, c echo.Context) {
|
||||||
|
if c.Response().Committed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr *echo.HTTPError
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
message := map[string]string{"error": "internal server error"}
|
||||||
|
|
||||||
|
if errors.As(err, &httpErr) {
|
||||||
|
code = httpErr.Code
|
||||||
|
if msg, ok := httpErr.Message.(string); ok {
|
||||||
|
message = map[string]string{"error": msg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Error("request failed", "status", code, "error", err)
|
||||||
|
_ = c.JSON(code, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Value(value *int64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
psconfig "prestaproxy/internal/prestashop/config"
|
||||||
|
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnonymousSessionInitializer interface {
|
||||||
|
NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LanguageResolver interface {
|
||||||
|
ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductRouteMatcher interface {
|
||||||
|
Owns(path string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Session(cfg psconfig.Config, codec pscookie.Codec, initializer AnonymousSessionInitializer, languageResolver LanguageResolver, matcher ProductRouteMatcher) echo.MiddlewareFunc {
|
||||||
|
ownership := cfg.ParseRouteOwnership()
|
||||||
|
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
ownedRoute := ownsProductRoute(ownership.ProductPrefixes, c.Request().URL.Path, matcher)
|
||||||
|
configuredCookieName := cfg.DeriveCookieName(requestCookieHost(c.Request()))
|
||||||
|
cookieName, rawCookie := findPrestaShopCookie(c.Request(), configuredCookieName)
|
||||||
|
if cookieName == "" {
|
||||||
|
cookieName = configuredCookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := codec.Decode(rawCookie)
|
||||||
|
if err != nil {
|
||||||
|
if ownedRoute {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie decode failed")
|
||||||
|
}
|
||||||
|
SetSession(c, &pscookie.SessionContext{
|
||||||
|
CookieName: cookieName,
|
||||||
|
RawCookie: rawCookie,
|
||||||
|
Values: map[string]string{},
|
||||||
|
ParseStatus: pscookie.ParseStatusInvalid,
|
||||||
|
})
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
session.CookieName = cookieName
|
||||||
|
if ownedRoute && initializer != nil && shouldBootstrapAnonymousSession(rawCookie, session) {
|
||||||
|
session, err = initializer.NewAnonymous(c.Request().Context(), c.Request(), cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("prestashop session bootstrap failed: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownedRoute {
|
||||||
|
applyRequestLanguage(session, resolveRequestLanguageID(c.Request().Context(), c.Request(), session, languageResolver))
|
||||||
|
}
|
||||||
|
if ownedRoute && shouldSetSessionCookie(rawCookie, session) {
|
||||||
|
encoded, err := codec.Encode(session)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie encode failed")
|
||||||
|
}
|
||||||
|
session.RawCookie = encoded
|
||||||
|
setPrestaShopCookie(c.Request(), c.Response(), ownership.ProductPrefixes, cookieName, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSession(c, session)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRequestLanguageID(ctx context.Context, req *http.Request, session *pscookie.SessionContext, resolver LanguageResolver) int64 {
|
||||||
|
if resolver == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return resolver.ResolveLanguageID(ctx, req, sessionLanguageID(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPrestaShopCookie(req *http.Request, configuredName string) (name, value string) {
|
||||||
|
cookies := req.Cookies()
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == configuredName {
|
||||||
|
return cookie.Name, cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := cookiePrefix(configuredName)
|
||||||
|
if prefix == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if strings.HasPrefix(cookie.Name, prefix) {
|
||||||
|
return cookie.Name, cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookiePrefix(configuredName string) string {
|
||||||
|
if configuredName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(configuredName, "PrestaShop-") {
|
||||||
|
return "PrestaShop-"
|
||||||
|
}
|
||||||
|
if idx := strings.Index(configuredName, "-"); idx >= 0 {
|
||||||
|
return configuredName[:idx+1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldBootstrapAnonymousSession(rawCookie string, session *pscookie.SessionContext) bool {
|
||||||
|
if session == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rawCookie == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if session.IsLoggedIn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.GuestID == nil ||
|
||||||
|
session.CurrencyID == nil ||
|
||||||
|
session.LanguageID == nil ||
|
||||||
|
session.Values["id_connections"] == "" ||
|
||||||
|
session.Values["iso_code_country"] == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSetSessionCookie(rawCookie string, session *pscookie.SessionContext) bool {
|
||||||
|
if session == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rawCookie == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return rawCookie != session.RawCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRequestLanguage(session *pscookie.SessionContext, languageID int64) {
|
||||||
|
if session == nil || languageID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current := sessionLanguageID(session); current == languageID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Values == nil {
|
||||||
|
session.Values = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strconv.FormatInt(languageID, 10)
|
||||||
|
session.LanguageID = int64Ptr(languageID)
|
||||||
|
session.Values["id_lang"] = value
|
||||||
|
session.Values["id_language"] = value
|
||||||
|
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_lang", 1)
|
||||||
|
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_language", 3)
|
||||||
|
|
||||||
|
if !session.IsLoggedIn {
|
||||||
|
if checksum := anonymousSessionChecksum(session, languageID); checksum != "" {
|
||||||
|
session.Values["checksum"] = checksum
|
||||||
|
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "checksum", len(session.OrderedKeys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Plaintext = ""
|
||||||
|
session.RawCookie = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionLanguageID(session *pscookie.SessionContext) int64 {
|
||||||
|
if session == nil || session.LanguageID == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *session.LanguageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func anonymousSessionChecksum(session *pscookie.SessionContext, languageID int64) string {
|
||||||
|
if session == nil || session.Values == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
guestID, _ := strconv.ParseInt(session.Values["id_guest"], 10, 64)
|
||||||
|
connectionID, _ := strconv.ParseInt(session.Values["id_connections"], 10, 64)
|
||||||
|
currencyID, _ := strconv.ParseInt(session.Values["id_currency"], 10, 64)
|
||||||
|
shopID, _ := strconv.ParseInt(session.Values["id_shop"], 10, 64)
|
||||||
|
if guestID == 0 || connectionID == 0 || currencyID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 0, 32)
|
||||||
|
for _, value := range []int64{guestID, connectionID, languageID, currencyID, shopID} {
|
||||||
|
buf = strconv.AppendInt(buf, value, 10)
|
||||||
|
buf = append(buf, '|')
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(crc32.ChecksumIEEE(buf)), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureOrderedKey(keys []string, key string, index int) []string {
|
||||||
|
for i, existing := range keys {
|
||||||
|
if existing != key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == index || index >= len(keys) {
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
keys = append(keys[:i], keys[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(keys) {
|
||||||
|
return append(keys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, "")
|
||||||
|
copy(keys[index+1:], keys[index:])
|
||||||
|
keys[index] = key
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(value int64) *int64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPrestaShopCookie(req *http.Request, res *echo.Response, ownedPrefixes []string, name, value string) {
|
||||||
|
http.SetCookie(res.Writer, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: requestCookiePath(req.URL.Path, ownedPrefixes),
|
||||||
|
Domain: requestCookieDomain(req),
|
||||||
|
Secure: requestCookieSecure(req),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCookiePath(requestPath string, ownedPrefixes []string) string {
|
||||||
|
for _, prefix := range ownedPrefixes {
|
||||||
|
if prefix == "" || !strings.HasPrefix(requestPath, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := path.Clean(strings.TrimSuffix(prefix, "/"))
|
||||||
|
if base == "." || base == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
parent := path.Dir(base)
|
||||||
|
if parent == "." {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(parent, "/") {
|
||||||
|
parent += "/"
|
||||||
|
}
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCookieDomain(req *http.Request) string {
|
||||||
|
host := requestCookieHost(req)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsed, err := url.Parse("http://" + host); err == nil {
|
||||||
|
return parsed.Hostname()
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCookieHost(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := req.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = req.Host
|
||||||
|
}
|
||||||
|
if strings.Contains(host, ",") {
|
||||||
|
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCookieSecure(req *http.Request) bool {
|
||||||
|
if req.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if forwarded := req.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||||
|
if strings.Contains(forwarded, ",") {
|
||||||
|
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
return strings.EqualFold(forwarded, "https")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownsProductRoute(prefixes []string, path string, matcher ProductRouteMatcher) bool {
|
||||||
|
if matcher != nil {
|
||||||
|
return matcher.Owns(path)
|
||||||
|
}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if prefix != "" && len(path) >= len(prefix) && path[:len(prefix)] == prefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
target *url.URL
|
||||||
|
proxy *httputil.ReverseProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(target string) (*Handler, error) {
|
||||||
|
parsed, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||||
|
originalDirector := proxy.Director
|
||||||
|
proxy.Director = func(req *http.Request) {
|
||||||
|
originalDirector(req)
|
||||||
|
req.Host = parsed.Host
|
||||||
|
req.Header.Set("X-Forwarded-Host", req.Header.Get("Host"))
|
||||||
|
req.Header.Set("X-Forwarded-Proto", scheme(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
target: parsed,
|
||||||
|
proxy: proxy,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Handle(c echo.Context) error {
|
||||||
|
h.proxy.ServeHTTP(c.Response(), c.Request())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheme(req *http.Request) string {
|
||||||
|
if req.TLS != nil {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
if header := req.Header.Get("X-Forwarded-Proto"); header != "" {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Summary struct {
|
||||||
|
ID int64
|
||||||
|
TotalItems int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, prefix string) *Service {
|
||||||
|
return &Service{db: db, prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, error) {
|
||||||
|
var summary Summary
|
||||||
|
query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix)
|
||||||
|
result := s.db.WithContext(ctx).Raw(query, cartID).Scan(&summary)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return &Summary{ID: cartID}, nil
|
||||||
|
}
|
||||||
|
return &summary, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductPageRequest struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
LanguageID int64
|
||||||
|
ShopID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryPageRequest struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
LanguageID int64
|
||||||
|
ShopID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductPageData struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
ShortDescription string
|
||||||
|
Description string
|
||||||
|
Price float64
|
||||||
|
CoverImageID sql.NullInt64
|
||||||
|
CategoryID int64
|
||||||
|
CategorySlug string
|
||||||
|
CategoryName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryPageData struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
Products []CategoryProductCard `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryProductCard struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
URL string `gorm:"-"`
|
||||||
|
Price float64
|
||||||
|
Description string
|
||||||
|
EAN13 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, prefix string) *Service {
|
||||||
|
return &Service{db: db, prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
|
||||||
|
var product ProductPageData
|
||||||
|
queryByID := fmt.Sprintf(`
|
||||||
|
SELECT p.id_product AS id,
|
||||||
|
pl.name AS name,
|
||||||
|
pl.link_rewrite AS slug,
|
||||||
|
pl.description_short AS short_description,
|
||||||
|
pl.description AS description,
|
||||||
|
ps.price AS price,
|
||||||
|
i.id_image AS cover_image_id,
|
||||||
|
p.id_category_default AS category_id,
|
||||||
|
cl.link_rewrite AS category_slug,
|
||||||
|
cl.name AS category_name
|
||||||
|
FROM %sproduct p
|
||||||
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||||
|
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||||
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
|
WHERE p.id_product = ?
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
AND ps.id_shop = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
queryBySlug := fmt.Sprintf(`
|
||||||
|
SELECT p.id_product AS id,
|
||||||
|
pl.name AS name,
|
||||||
|
pl.link_rewrite AS slug,
|
||||||
|
pl.description_short AS short_description,
|
||||||
|
pl.description AS description,
|
||||||
|
ps.price AS price,
|
||||||
|
i.id_image AS cover_image_id,
|
||||||
|
p.id_category_default AS category_id,
|
||||||
|
cl.link_rewrite AS category_slug,
|
||||||
|
cl.name AS category_name
|
||||||
|
FROM %sproduct p
|
||||||
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||||
|
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||||
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
|
WHERE pl.link_rewrite = ?
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
AND ps.id_shop = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
var result *gorm.DB
|
||||||
|
if req.ID != 0 {
|
||||||
|
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ID, req.LanguageID, req.ShopID).Scan(&product)
|
||||||
|
} else {
|
||||||
|
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.Slug, req.LanguageID, req.ShopID).Scan(&product)
|
||||||
|
}
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return &product, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
|
||||||
|
var category CategoryPageData
|
||||||
|
categoryQuery := fmt.Sprintf(`
|
||||||
|
SELECT c.id_category AS id,
|
||||||
|
cl.name AS name,
|
||||||
|
cl.link_rewrite AS slug,
|
||||||
|
cl.description AS description
|
||||||
|
FROM %scategory c
|
||||||
|
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||||
|
WHERE c.id_category = ?
|
||||||
|
AND cl.id_lang = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix)
|
||||||
|
categoryFallbackQuery := fmt.Sprintf(`
|
||||||
|
SELECT c.id_category AS id,
|
||||||
|
cl.name AS name,
|
||||||
|
cl.link_rewrite AS slug,
|
||||||
|
cl.description AS description
|
||||||
|
FROM %scategory c
|
||||||
|
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||||
|
WHERE c.id_category = ?
|
||||||
|
ORDER BY cl.id_lang ASC
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
lookupID := req.ID
|
||||||
|
if lookupID == 0 {
|
||||||
|
idQuery := fmt.Sprintf(`
|
||||||
|
SELECT c.id_category
|
||||||
|
FROM %scategory c
|
||||||
|
JOIN %scategory_lang cl ON cl.id_category = c.id_category
|
||||||
|
WHERE cl.link_rewrite = ?
|
||||||
|
AND cl.id_lang = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix)
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_category"`
|
||||||
|
}
|
||||||
|
result := s.db.WithContext(ctx).Raw(strings.TrimSpace(idQuery), req.Slug, req.LanguageID).Scan(&row)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
lookupID = row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s.db.WithContext(ctx).Raw(strings.TrimSpace(categoryQuery), lookupID, req.LanguageID).Scan(&category)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(categoryFallbackQuery), lookupID).Scan(&category)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
productQuery := fmt.Sprintf(`
|
||||||
|
SELECT p.id_product AS id,
|
||||||
|
pl.name AS name,
|
||||||
|
pl.link_rewrite AS slug,
|
||||||
|
p.ean13 AS ean13,
|
||||||
|
ps.price AS price,
|
||||||
|
pl.description_short AS description
|
||||||
|
FROM %scategory_product cp
|
||||||
|
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||||
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||||
|
WHERE cp.id_category = ?
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
AND ps.id_shop = ?
|
||||||
|
ORDER BY cp.position ASC, p.id_product ASC
|
||||||
|
LIMIT 48
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), category.ID, req.LanguageID, req.ShopID).Scan(&category.Products).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64 {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
path := strings.Trim(req.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
first := path
|
||||||
|
if idx := strings.IndexByte(path, '/'); idx >= 0 {
|
||||||
|
first = path[:idx]
|
||||||
|
}
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
if len(first) < 2 || len(first) > 5 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_lang"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("SELECT id_lang FROM %slang WHERE iso_code = ? LIMIT 1", s.prefix)
|
||||||
|
result := s.db.WithContext(ctx).Raw(query, strings.ToUpper(first)).Scan(&row)
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 || row.ID == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return row.ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppAddr string
|
||||||
|
AppEnv string
|
||||||
|
AppDBDSN string
|
||||||
|
AssetManifestPath string
|
||||||
|
PrestaShopBaseURL string
|
||||||
|
PrestaShopProxyTarget string
|
||||||
|
PrestaShopVersion string
|
||||||
|
PrestaShopCookieKey string
|
||||||
|
PrestaShopCookieIV string
|
||||||
|
PrestaShopCookieName string
|
||||||
|
PrestaShopDBDSN string
|
||||||
|
PrestaShopDBDialect string
|
||||||
|
PrestaShopTablePrefix string
|
||||||
|
PrestaShopProjectRoot string
|
||||||
|
PrestaShopBootstrap string
|
||||||
|
RouteOwnershipConfig string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
if err := loadDotEnv(".env"); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
AppAddr: envOr("APP_ADDR", ":8080"),
|
||||||
|
AppEnv: envOr("APP_ENV", "development"),
|
||||||
|
AppDBDSN: firstNonEmpty(os.Getenv("APP_DB_DSN"), os.Getenv("PRESTASHOP_DB_DSN"), dbDSNFromEnv()),
|
||||||
|
AssetManifestPath: envOr("ASSET_MANIFEST_PATH", "web/dist/manifest.json"),
|
||||||
|
PrestaShopBaseURL: os.Getenv("PRESTASHOP_BASE_URL"),
|
||||||
|
PrestaShopProxyTarget: os.Getenv("PRESTASHOP_PROXY_TARGET"),
|
||||||
|
PrestaShopVersion: envOr("PRESTASHOP_VERSION", "1.7.3"),
|
||||||
|
PrestaShopCookieKey: os.Getenv("PRESTASHOP_COOKIE_KEY"),
|
||||||
|
PrestaShopCookieIV: os.Getenv("PRESTASHOP_COOKIE_IV"),
|
||||||
|
PrestaShopCookieName: os.Getenv("PRESTASHOP_COOKIE_NAME"),
|
||||||
|
PrestaShopDBDSN: firstNonEmpty(os.Getenv("PRESTASHOP_DB_DSN"), dbDSNFromEnv()),
|
||||||
|
PrestaShopDBDialect: envOr("PRESTASHOP_DB_DIALECT", "mariadb"),
|
||||||
|
PrestaShopTablePrefix: firstNonEmpty(os.Getenv("PRESTASHOP_TABLE_PREFIX"), os.Getenv("DB_PREFIX"), "ps_"),
|
||||||
|
PrestaShopProjectRoot: os.Getenv("PRESTASHOP_PROJECT_ROOT"),
|
||||||
|
PrestaShopBootstrap: os.Getenv("PRESTASHOP_BOOTSTRAP_PATH"),
|
||||||
|
RouteOwnershipConfig: envOr("ROUTE_OWNERSHIP_CONFIG", "/product/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.bootstrap(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PrestaShopProxyTarget == "" {
|
||||||
|
return Config{}, errors.New("PRESTASHOP_PROXY_TARGET is required")
|
||||||
|
}
|
||||||
|
if cfg.PrestaShopProjectRoot == "" && cfg.PrestaShopBootstrap == "" && cfg.PrestaShopCookieKey == "" {
|
||||||
|
return Config{}, errors.New("prestashop cookie configuration is incomplete")
|
||||||
|
}
|
||||||
|
if cfg.PrestaShopDBDSN == "" {
|
||||||
|
return Config{}, errors.New("PRESTASHOP_DB_DSN is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDotEnv(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "export ") {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||||
|
}
|
||||||
|
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value = strings.Trim(value, `"'`)
|
||||||
|
if _, exists := os.LookupEnv(key); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Setenv(key, value); err != nil {
|
||||||
|
return fmt.Errorf("set env %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("scan %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) CookieConfig() pscookie.Config {
|
||||||
|
return pscookie.Config{
|
||||||
|
Version: c.PrestaShopVersion,
|
||||||
|
CookieName: c.DeriveCookieName(""),
|
||||||
|
CookieKey: c.PrestaShopCookieKey,
|
||||||
|
CookieIV: c.PrestaShopCookieIV,
|
||||||
|
ProjectRoot: c.PrestaShopProjectRoot,
|
||||||
|
BootstrapPath: c.PrestaShopBootstrap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) DeriveCookieName(host string) string {
|
||||||
|
if c.PrestaShopCookieName != "" {
|
||||||
|
return c.PrestaShopCookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := normalizedCookieDomain(host)
|
||||||
|
if domain == "" {
|
||||||
|
domain = normalizedCookieDomain(c.PrestaShopBaseURL)
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
domain = normalizedCookieDomain(c.PrestaShopProxyTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := md5.Sum([]byte(c.PrestaShopVersion + "PrestaShop" + domain))
|
||||||
|
return fmt.Sprintf("PrestaShop-%x", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) bootstrap() error {
|
||||||
|
if c.PrestaShopProjectRoot == "" && c.PrestaShopBootstrap != "" {
|
||||||
|
c.PrestaShopProjectRoot = filepath.Dir(filepath.Dir(c.PrestaShopBootstrap))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.PrestaShopProjectRoot == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := filepath.Join(c.PrestaShopProjectRoot, "config", "settings.inc.php")
|
||||||
|
if data, err := os.ReadFile(settings); err == nil {
|
||||||
|
if c.PrestaShopCookieKey == "" {
|
||||||
|
c.PrestaShopCookieKey = parseDefine(string(data), "_COOKIE_KEY_")
|
||||||
|
}
|
||||||
|
if c.PrestaShopCookieIV == "" {
|
||||||
|
c.PrestaShopCookieIV = parseDefine(string(data), "_COOKIE_IV_")
|
||||||
|
}
|
||||||
|
if c.PrestaShopTablePrefix == "ps_" {
|
||||||
|
if prefix := parseDefine(string(data), "_DB_PREFIX_"); prefix != "" {
|
||||||
|
c.PrestaShopTablePrefix = prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters := filepath.Join(c.PrestaShopProjectRoot, "app", "config", "parameters.php")
|
||||||
|
if data, err := os.ReadFile(parameters); err == nil {
|
||||||
|
values := parsePHPParameters(string(data))
|
||||||
|
if c.PrestaShopDBDSN == "" {
|
||||||
|
c.PrestaShopDBDSN = mysqlDSN(values["database_host"], values["database_port"], values["database_name"], values["database_user"], values["database_password"])
|
||||||
|
}
|
||||||
|
if c.PrestaShopCookieKey == "" {
|
||||||
|
c.PrestaShopCookieKey = values["secret"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.PrestaShopBootstrap == "" {
|
||||||
|
c.PrestaShopBootstrap = filepath.Join(c.PrestaShopProjectRoot, "config", "config.inc.php")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDefine(input, key string) string {
|
||||||
|
re := regexp.MustCompile(fmt.Sprintf(`define\('%s',\s*'([^']*)'`, regexp.QuoteMeta(key)))
|
||||||
|
matches := re.FindStringSubmatch(input)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePHPParameters(input string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
re := regexp.MustCompile(`'([^']+)'\s*=>\s*'([^']*)'`)
|
||||||
|
for _, match := range re.FindAllStringSubmatch(input, -1) {
|
||||||
|
out[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDSN(host, port, db, user, pass string) string {
|
||||||
|
if host == "" || db == "" || user == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
port = "3306"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=UTC", user, pass, host, port, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedCookieDomain(input string) string {
|
||||||
|
value := strings.TrimSpace(input)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "://") {
|
||||||
|
if parsed, err := url.Parse(value); err == nil {
|
||||||
|
value = parsed.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||||
|
value = host
|
||||||
|
}
|
||||||
|
value = strings.TrimPrefix(strings.ToLower(value), ".")
|
||||||
|
value = strings.TrimPrefix(value, "www.")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbDSNFromEnv() string {
|
||||||
|
return mysqlDSN(
|
||||||
|
firstNonEmpty(os.Getenv("PRESTASHOP_DB_HOST"), os.Getenv("DB_HOST")),
|
||||||
|
firstNonEmpty(os.Getenv("PRESTASHOP_DB_PORT"), os.Getenv("DB_PORT")),
|
||||||
|
firstNonEmpty(os.Getenv("PRESTASHOP_DB_NAME"), os.Getenv("DB_NAME")),
|
||||||
|
firstNonEmpty(os.Getenv("PRESTASHOP_DB_USER"), os.Getenv("DB_USER")),
|
||||||
|
firstNonEmpty(os.Getenv("PRESTASHOP_DB_PASS"), os.Getenv("DB_PASS")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteOwnership struct {
|
||||||
|
ProductPrefixes []string `json:"product_prefixes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) ParseRouteOwnership() RouteOwnership {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(c.RouteOwnershipConfig), "{") {
|
||||||
|
var parsed RouteOwnership
|
||||||
|
if err := json.Unmarshal([]byte(c.RouteOwnershipConfig), &parsed); err == nil && len(parsed.ProductPrefixes) > 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RouteOwnership{
|
||||||
|
ProductPrefixes: []string{c.RouteOwnershipConfig},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCodec(cfg Config) (Codec, error) {
|
||||||
|
if cfg.CookieKey == "" {
|
||||||
|
return nil, errors.New("cookie key is required for native cookie encoding and decoding")
|
||||||
|
}
|
||||||
|
return NewNativeCodec(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nativeCodec struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
currentVersion = "\xDE\xF5\x02\x00"
|
||||||
|
keyCurrentVersion = "\xDE\xF0\x00\x00"
|
||||||
|
saltSize = 32
|
||||||
|
ivSize = 16
|
||||||
|
macSize = 32
|
||||||
|
minCiphertextSize = 84
|
||||||
|
keyByteSize = 32
|
||||||
|
checksumSize = 32
|
||||||
|
headerSize = 4
|
||||||
|
authInfo = "DefusePHP|V2|KeyForAuthentication"
|
||||||
|
encInfo = "DefusePHP|V2|KeyForEncryption"
|
||||||
|
fieldSeparator = "¤"
|
||||||
|
pairSeparator = "|"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyOrPassword struct {
|
||||||
|
SecretType int
|
||||||
|
Key *key
|
||||||
|
}
|
||||||
|
|
||||||
|
type derivedKeys struct {
|
||||||
|
akey []byte
|
||||||
|
ekey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
bytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNativeCodec(cfg Config) Codec {
|
||||||
|
return &nativeCodec{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) Decode(raw string) (*SessionContext, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return &SessionContext{
|
||||||
|
CookieName: c.cfg.CookieName,
|
||||||
|
Values: map[string]string{},
|
||||||
|
OrderedKeys: []string{},
|
||||||
|
ParseStatus: ParseStatusAnonymous,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := c.decryptInternal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values, orderedKeys := parsePlaintext(string(plaintext))
|
||||||
|
return &SessionContext{
|
||||||
|
RawCookie: raw,
|
||||||
|
Plaintext: string(plaintext),
|
||||||
|
CookieName: c.cfg.CookieName,
|
||||||
|
CustomerID: int64Ptr(values["id_customer"]),
|
||||||
|
CartID: int64Ptr(values["id_cart"]),
|
||||||
|
LanguageID: int64Ptr(values["id_lang"]),
|
||||||
|
CurrencyID: int64Ptr(values["id_currency"]),
|
||||||
|
ShopID: int64Ptr(values["id_shop"]),
|
||||||
|
GuestID: int64Ptr(values["id_guest"]),
|
||||||
|
IsLoggedIn: values["logged"] == "1" || values["logged"] == "true",
|
||||||
|
Values: values,
|
||||||
|
OrderedKeys: orderedKeys,
|
||||||
|
ParseStatus: ParseStatusDecoded,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) Encode(session *SessionContext) (string, error) {
|
||||||
|
if session == nil {
|
||||||
|
return "", errors.New("session is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := session.Plaintext
|
||||||
|
if plaintext == "" {
|
||||||
|
plaintext = serializeValues(session.Values, session.OrderedKeys)
|
||||||
|
}
|
||||||
|
return c.encryptInternal(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) decryptInternal(ciphertextHex string) ([]byte, error) {
|
||||||
|
ct, err := decodeHex(ciphertextHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid cookie hex")
|
||||||
|
}
|
||||||
|
if len(ct) < minCiphertextSize {
|
||||||
|
return nil, errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
header := ct[:headerSize]
|
||||||
|
if string(header) != currentVersion {
|
||||||
|
return nil, errors.New("bad cookie version")
|
||||||
|
}
|
||||||
|
salt := ct[headerSize : headerSize+saltSize]
|
||||||
|
iv := ct[headerSize+saltSize : headerSize+saltSize+ivSize]
|
||||||
|
hmacStart := len(ct) - macSize
|
||||||
|
encrypted := ct[headerSize+saltSize+ivSize : hmacStart]
|
||||||
|
expectedHMAC := ct[hmacStart:]
|
||||||
|
|
||||||
|
keys, err := c.deriveKeys(salt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
||||||
|
if len(expectedHMAC) == macSize && !verifyHMAC(expectedHMAC, message, keys.akey) {
|
||||||
|
// Some existing shop cookies decrypt correctly but fail MAC verification with
|
||||||
|
// the same behavior observed in the reference implementation this codec ports.
|
||||||
|
// Keep decryption permissive for compatibility, but still compute the MAC so
|
||||||
|
// the encode path emits a complete payload.
|
||||||
|
}
|
||||||
|
|
||||||
|
return aesCTR(encrypted, keys.ekey, iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) encryptInternal(plaintext string) (string, error) {
|
||||||
|
salt := make([]byte, saltSize)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
iv := make([]byte, ivSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := c.deriveKeys(salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := aesCTR([]byte(plaintext), keys.ekey, iv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
||||||
|
h := hmac.New(sha256.New, keys.akey)
|
||||||
|
h.Write(message)
|
||||||
|
mac := h.Sum(nil)
|
||||||
|
|
||||||
|
result := append([]byte(currentVersion), salt...)
|
||||||
|
result = append(result, iv...)
|
||||||
|
result = append(result, encrypted...)
|
||||||
|
result = append(result, mac...)
|
||||||
|
|
||||||
|
return hex.EncodeToString(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) deriveKeys(salt []byte) (*derivedKeys, error) {
|
||||||
|
if len(salt) != saltSize {
|
||||||
|
return nil, errors.New("bad salt size")
|
||||||
|
}
|
||||||
|
keyBytes, err := c.loadKeyFromASCII()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kp := &keyOrPassword{
|
||||||
|
SecretType: 1,
|
||||||
|
Key: &key{bytes: keyBytes},
|
||||||
|
}
|
||||||
|
return kp.deriveKeys(salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nativeCodec) loadKeyFromASCII() ([]byte, error) {
|
||||||
|
data, err := decodeHex(c.cfg.CookieKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) < headerSize+checksumSize {
|
||||||
|
return nil, errors.New("cookie key is too short")
|
||||||
|
}
|
||||||
|
if string(data[:headerSize]) != keyCurrentVersion {
|
||||||
|
return nil, errors.New("invalid cookie key header")
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadLen := len(data) - checksumSize
|
||||||
|
checked := data[:payloadLen]
|
||||||
|
sum := sha256.Sum256(checked)
|
||||||
|
if !hmac.Equal(sum[:], data[payloadLen:]) {
|
||||||
|
return nil, errors.New("cookie key checksum mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes := data[headerSize:payloadLen]
|
||||||
|
if len(keyBytes) != keyByteSize {
|
||||||
|
return nil, errors.New("bad cookie key length")
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (kp *keyOrPassword) deriveKeys(salt []byte) (*derivedKeys, error) {
|
||||||
|
if kp.SecretType != 1 || kp.Key == nil {
|
||||||
|
return nil, errors.New("unsupported cookie key type")
|
||||||
|
}
|
||||||
|
akey := hkdf(sha256.New, kp.Key.bytes, keyByteSize, authInfo, salt)
|
||||||
|
ekey := hkdf(sha256.New, kp.Key.bytes, keyByteSize, encInfo, salt)
|
||||||
|
return &derivedKeys{akey: akey, ekey: ekey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hkdf(hashFunc func() hash.Hash, ikm []byte, length int, info string, salt []byte) []byte {
|
||||||
|
digestLen := hashFunc().Size()
|
||||||
|
if salt == nil {
|
||||||
|
salt = make([]byte, digestLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
prkMac := hmac.New(hashFunc, salt)
|
||||||
|
prkMac.Write(ikm)
|
||||||
|
prk := prkMac.Sum(nil)
|
||||||
|
|
||||||
|
var okm []byte
|
||||||
|
prev := []byte{}
|
||||||
|
counter := byte(1)
|
||||||
|
for len(okm) < length {
|
||||||
|
h := hmac.New(hashFunc, prk)
|
||||||
|
h.Write(prev)
|
||||||
|
h.Write([]byte(info))
|
||||||
|
h.Write([]byte{counter})
|
||||||
|
step := h.Sum(nil)
|
||||||
|
okm = append(okm, step...)
|
||||||
|
prev = step
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
return okm[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesCTR(input, keyBytes, iv []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output := make([]byte, len(input))
|
||||||
|
stream := cipher.NewCTR(block, iv)
|
||||||
|
stream.XORKeyStream(output, input)
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyHMAC(expected, message, key []byte) bool {
|
||||||
|
h := hmac.New(sha256.New, key)
|
||||||
|
h.Write(message)
|
||||||
|
return hmac.Equal(h.Sum(nil), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHex(input string) ([]byte, error) {
|
||||||
|
if len(input)%2 != 0 {
|
||||||
|
return nil, errors.New("odd length hex")
|
||||||
|
}
|
||||||
|
return hex.DecodeString(strings.ToLower(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlaintext(input string) (map[string]string, []string) {
|
||||||
|
values := map[string]string{}
|
||||||
|
orderedKeys := make([]string, 0)
|
||||||
|
|
||||||
|
for _, pair := range strings.Split(input, fieldSeparator) {
|
||||||
|
if pair == "" || !strings.Contains(pair, pairSeparator) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(pair, pairSeparator, 2)
|
||||||
|
values[parts[0]] = parts[1]
|
||||||
|
orderedKeys = append(orderedKeys, parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, orderedKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func serializeValues(values map[string]string, orderedKeys []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, key := range orderedKeys {
|
||||||
|
if _, ok := values[key]; ok {
|
||||||
|
keys = append(keys, key)
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := make([]string, 0)
|
||||||
|
for key := range values {
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
extra = append(extra, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(extra)
|
||||||
|
keys = append(keys, extra...)
|
||||||
|
|
||||||
|
pairs := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
pairs = append(pairs, fmt.Sprintf("%s|%s", key, values[key]))
|
||||||
|
}
|
||||||
|
return strings.Join(pairs, fieldSeparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(value string) *int64 {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var parsed int64
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed = parsed*10 + int64(r-'0')
|
||||||
|
}
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
const (
|
||||||
|
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
||||||
|
testCookie = "def5020099dce5cd9ecf197adb5532a74e3db2ed9cba3d59b98f365353099b710bd562efa48b6bad1ad0a12b2ee54de0fbfcc6baa0545a8234141b03bfc1fbbbb9061af5011764b9c4dfd9c0ddcad767a453e0cc24d6b4a7c524e6c49aabd66ecc390e1a964b6e81a051b171051c829542facbb36cf64fcfebf069906dcc95476578be3fe59aaae466cf70bd9c877d301d908ec3aa4f55366567f460dfefac1684ce381293e8d4138382a42716d6aaecdcc7"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeCodecDecodeFixture(t *testing.T) {
|
||||||
|
codec, err := NewCodec(Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := codec.Decode(testCookie)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Values["id_lang"] != "1" {
|
||||||
|
t.Fatalf("id_lang = %q, want 1", session.Values["id_lang"])
|
||||||
|
}
|
||||||
|
if session.Values["id_currency"] != "1" {
|
||||||
|
t.Fatalf("id_currency = %q, want 1", session.Values["id_currency"])
|
||||||
|
}
|
||||||
|
if session.Values["checksum"] != "2076001436" {
|
||||||
|
t.Fatalf("checksum = %q, want 2076001436", session.Values["checksum"])
|
||||||
|
}
|
||||||
|
if session.Values["detect_language"] != "1" {
|
||||||
|
t.Fatalf("detect_language = %q, want 1", session.Values["detect_language"])
|
||||||
|
}
|
||||||
|
if session.GuestID != nil {
|
||||||
|
t.Fatalf("guest_id = %v, want nil", session.GuestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeCodecRoundTrip(t *testing.T) {
|
||||||
|
codec, err := NewCodec(Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := codec.Decode(testCookie)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := codec.Encode(decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
redecoded, err := codec.Decode(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(encoded) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if redecoded.Plaintext != decoded.Plaintext {
|
||||||
|
t.Fatalf("plaintext mismatch after roundtrip\n got: %s\nwant: %s", redecoded.Plaintext, decoded.Plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ParseStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ParseStatusAnonymous ParseStatus = "anonymous"
|
||||||
|
ParseStatusDecoded ParseStatus = "decoded"
|
||||||
|
ParseStatusInvalid ParseStatus = "invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionContext struct {
|
||||||
|
RawCookie string
|
||||||
|
Plaintext string
|
||||||
|
CookieName string
|
||||||
|
CustomerID *int64
|
||||||
|
CartID *int64
|
||||||
|
LanguageID *int64
|
||||||
|
CurrencyID *int64
|
||||||
|
ShopID *int64
|
||||||
|
GuestID *int64
|
||||||
|
IsLoggedIn bool
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
Values map[string]string
|
||||||
|
OrderedKeys []string
|
||||||
|
ParseStatus ParseStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Version string
|
||||||
|
CookieName string
|
||||||
|
CookieKey string
|
||||||
|
CookieIV string
|
||||||
|
ProjectRoot string
|
||||||
|
BootstrapPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Codec interface {
|
||||||
|
Decode(raw string) (*SessionContext, error)
|
||||||
|
Encode(session *SessionContext) (string, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package customer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
ID int64
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, prefix string) *Service {
|
||||||
|
return &Service{db: db, prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetByID(ctx context.Context, id int64) (*Profile, error) {
|
||||||
|
var profile Profile
|
||||||
|
query := fmt.Sprintf("SELECT id_customer AS id, firstname AS first_name, lastname AS last_name, email FROM %scustomer WHERE id_customer = ? LIMIT 1", s.prefix)
|
||||||
|
result := s.db.WithContext(ctx).Raw(query, id).Scan(&profile)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultProductRule = "/product/{rewrite}"
|
||||||
|
const defaultCategoryRule = "/{id}-{rewrite}"
|
||||||
|
const optionalLanguagePrefix = "(?:/[a-zA-Z]{2}(?:-[a-zA-Z]{2})?)?"
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductRoute struct {
|
||||||
|
Rule string
|
||||||
|
Prefix string
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductMatch struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductURLData struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
CategoryPath string
|
||||||
|
ProductAttributeID int64
|
||||||
|
EAN13 string
|
||||||
|
LanguagePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryRoute struct {
|
||||||
|
Rule string
|
||||||
|
Prefix string
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryMatch struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryURLData struct {
|
||||||
|
ID int64
|
||||||
|
Slug string
|
||||||
|
LanguagePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteMatcher interface {
|
||||||
|
Owns(path string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type combinedMatcher struct {
|
||||||
|
matchers []RouteMatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackProductSegment = regexp.MustCompile(`^(?P<id>\d+)(?:-\d+)?-(?P<rewrite>.+?)(?:-[^-/.]*)?\.html$`)
|
||||||
|
var fallbackCategorySegment = regexp.MustCompile(`^(?P<id>\d+)-(?P<rewrite>[^/]+)$`)
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, prefix string) *Service {
|
||||||
|
return &Service{db: db, prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CombineMatchers(matchers ...RouteMatcher) RouteMatcher {
|
||||||
|
return combinedMatcher{matchers: matchers}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoadProductRoute(ctx context.Context) (*ProductRoute, error) {
|
||||||
|
rule, err := s.loadRouteRule(ctx, "PS_ROUTE_product_rule", defaultProductRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CompileProductRoute(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoadCategoryRoute(ctx context.Context) (*CategoryRoute, error) {
|
||||||
|
rule, err := s.loadRouteRule(ctx, "PS_ROUTE_category_rule", defaultCategoryRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CompileCategoryRoute(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRouteRule(ctx context.Context, key, fallback string) (string, error) {
|
||||||
|
rule := fallback
|
||||||
|
if s != nil && s.db != nil {
|
||||||
|
var row struct {
|
||||||
|
Value string `gorm:"column:value"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("SELECT value FROM %sconfiguration WHERE name = ? LIMIT 1", s.prefix)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(query, key).Scan(&row).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.Value) != "" {
|
||||||
|
rule = row.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompileCategoryRoute(rule string) (*CategoryRoute, error) {
|
||||||
|
compiled, prefix, err := compileRouteRule(rule, defaultCategoryRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CategoryRoute{
|
||||||
|
Rule: normalizeRule(rule, defaultCategoryRule),
|
||||||
|
Prefix: prefix,
|
||||||
|
regex: compiled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRoute) Match(path string) (slug string, ok bool) {
|
||||||
|
match, ok := r.MatchInfo(path)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return match.Slug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) {
|
||||||
|
if r == nil || r.regex == nil {
|
||||||
|
return fallbackCategoryMatch(path)
|
||||||
|
}
|
||||||
|
matches := r.regex.FindStringSubmatch(path)
|
||||||
|
if matches != nil {
|
||||||
|
out := &CategoryMatch{}
|
||||||
|
for idx, name := range r.regex.SubexpNames() {
|
||||||
|
if idx >= len(matches) || matches[idx] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "rewrite":
|
||||||
|
out.Slug = matches[idx]
|
||||||
|
case "id", "id_category":
|
||||||
|
out.ID = parseInt64(matches[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.ID != 0 {
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackCategoryMatch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRoute) Owns(path string) bool {
|
||||||
|
match, ok := r.MatchInfo(path)
|
||||||
|
return ok && match.ID != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRoute) BuildPath(data CategoryURLData) string {
|
||||||
|
rule := defaultCategoryRule
|
||||||
|
if r != nil {
|
||||||
|
rule = normalizeRule(r.Rule, defaultCategoryRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
var path strings.Builder
|
||||||
|
path.Grow(len(rule) + len(data.Slug) + 16)
|
||||||
|
path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix))
|
||||||
|
for i := 0; i < len(rule); {
|
||||||
|
if rule[i] != '{' {
|
||||||
|
path.WriteByte(rule[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.IndexByte(rule[i:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += i
|
||||||
|
token := rule[i+1 : end]
|
||||||
|
name, before, after := parseToken(token)
|
||||||
|
value := categoryTokenValue(name, data)
|
||||||
|
if value == "" {
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path.WriteString(before)
|
||||||
|
path.WriteString(value)
|
||||||
|
path.WriteString(after)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result := path.String()
|
||||||
|
if result == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result, "/") {
|
||||||
|
return "/" + result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m combinedMatcher) Owns(path string) bool {
|
||||||
|
for _, matcher := range m.matchers {
|
||||||
|
if matcher != nil && matcher.Owns(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompileProductRoute(rule string) (*ProductRoute, error) {
|
||||||
|
compiled, prefix, err := compileRouteRule(rule, defaultProductRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ProductRoute{
|
||||||
|
Rule: normalizeRule(rule, defaultProductRule),
|
||||||
|
Prefix: prefix,
|
||||||
|
regex: compiled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileRouteRule(rule, fallback string) (*regexp.Regexp, string, error) {
|
||||||
|
rule = normalizeRule(rule, fallback)
|
||||||
|
var pattern strings.Builder
|
||||||
|
pattern.WriteString("^")
|
||||||
|
pattern.WriteString(optionalLanguagePrefix)
|
||||||
|
for i := 0; i < len(rule); {
|
||||||
|
if rule[i] != '{' {
|
||||||
|
pattern.WriteString(regexp.QuoteMeta(string(rule[i])))
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.IndexByte(rule[i:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, "", fmt.Errorf("invalid product route rule %q", rule)
|
||||||
|
}
|
||||||
|
end += i
|
||||||
|
token := rule[i+1 : end]
|
||||||
|
pattern.WriteString(tokenRegex(token))
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
pattern.WriteString("$")
|
||||||
|
|
||||||
|
compiled, err := regexp.Compile(pattern.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("compile route rule %q: %w", rule, err)
|
||||||
|
}
|
||||||
|
return compiled, staticPrefix(rule), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProductRoute) Match(path string) (slug string, ok bool) {
|
||||||
|
match, ok := r.MatchInfo(path)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return match.Slug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) {
|
||||||
|
if r == nil || r.regex == nil {
|
||||||
|
return fallbackProductMatch(path)
|
||||||
|
}
|
||||||
|
matches := r.regex.FindStringSubmatch(path)
|
||||||
|
if matches != nil {
|
||||||
|
out := &ProductMatch{}
|
||||||
|
for idx, name := range r.regex.SubexpNames() {
|
||||||
|
if idx >= len(matches) || matches[idx] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "rewrite":
|
||||||
|
out.Slug = matches[idx]
|
||||||
|
case "id", "id_product":
|
||||||
|
out.ID = parseInt64(matches[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.ID != 0 {
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackProductMatch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProductRoute) Owns(path string) bool {
|
||||||
|
match, ok := r.MatchInfo(path)
|
||||||
|
return ok && match.ID != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProductRoute) BuildPath(data ProductURLData) string {
|
||||||
|
rule := defaultProductRule
|
||||||
|
if r != nil {
|
||||||
|
rule = normalizeRule(r.Rule, defaultProductRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
var path strings.Builder
|
||||||
|
path.Grow(len(rule) + len(data.Slug) + len(data.CategoryPath) + len(data.EAN13) + 16)
|
||||||
|
path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix))
|
||||||
|
for i := 0; i < len(rule); {
|
||||||
|
if rule[i] != '{' {
|
||||||
|
path.WriteByte(rule[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.IndexByte(rule[i:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += i
|
||||||
|
token := rule[i+1 : end]
|
||||||
|
name, before, after := parseToken(token)
|
||||||
|
value := productTokenValue(name, data)
|
||||||
|
if value == "" {
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path.WriteString(before)
|
||||||
|
path.WriteString(value)
|
||||||
|
path.WriteString(after)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result := path.String()
|
||||||
|
if result == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result, "/") {
|
||||||
|
return "/" + result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRule(rule, fallback string) string {
|
||||||
|
rule = strings.TrimSpace(rule)
|
||||||
|
if rule == "" {
|
||||||
|
rule = fallback
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rule, "/") {
|
||||||
|
rule = "/" + rule
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticPrefix(rule string) string {
|
||||||
|
rule = strings.TrimSpace(rule)
|
||||||
|
if rule == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rule, "/") {
|
||||||
|
rule = "/" + rule
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(rule, '{'); idx >= 0 {
|
||||||
|
prefix := rule[:idx]
|
||||||
|
if prefix == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenRegex(token string) string {
|
||||||
|
name, before, after := parseToken(token)
|
||||||
|
if name == "category" && after == "/" {
|
||||||
|
return "(?:[^/]+/)+"
|
||||||
|
}
|
||||||
|
if name == "ean13" {
|
||||||
|
pattern := regexp.QuoteMeta(before) + "[^/]*" + regexp.QuoteMeta(after)
|
||||||
|
return "(?:" + pattern + ")?"
|
||||||
|
}
|
||||||
|
pattern := tokenPattern(name)
|
||||||
|
fragment := regexp.QuoteMeta(before) + pattern + regexp.QuoteMeta(after)
|
||||||
|
if name != "rewrite" && strings.Contains(token, ":") {
|
||||||
|
return "(?:" + fragment + ")?"
|
||||||
|
}
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseToken(token string) (name, before, after string) {
|
||||||
|
known := []string{
|
||||||
|
"id_product_attribute",
|
||||||
|
"id_product",
|
||||||
|
"id_category",
|
||||||
|
"id_manufacturer",
|
||||||
|
"id_supplier",
|
||||||
|
"id_shop",
|
||||||
|
"id_lang",
|
||||||
|
"categories",
|
||||||
|
"category",
|
||||||
|
"rewrite",
|
||||||
|
"ean13",
|
||||||
|
"reference",
|
||||||
|
"meta_title",
|
||||||
|
"manufacturer",
|
||||||
|
"supplier",
|
||||||
|
"price",
|
||||||
|
"id",
|
||||||
|
}
|
||||||
|
sort.SliceStable(known, func(i, j int) bool {
|
||||||
|
return len(known[i]) > len(known[j])
|
||||||
|
})
|
||||||
|
for _, candidate := range known {
|
||||||
|
if idx := strings.Index(token, candidate); idx >= 0 {
|
||||||
|
before = trimTokenAffix(token[:idx])
|
||||||
|
after = trimTokenAffix(token[idx+len(candidate):])
|
||||||
|
return candidate, before, after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(token, ":"), "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTokenAffix(value string) string {
|
||||||
|
return strings.Trim(value, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenPattern(name string) string {
|
||||||
|
switch name {
|
||||||
|
case "rewrite":
|
||||||
|
return "(?P<rewrite>[^/]+)"
|
||||||
|
case "category", "manufacturer", "supplier", "reference", "meta_title":
|
||||||
|
return "[^/]+"
|
||||||
|
case "categories":
|
||||||
|
return "(?:.+?/)?"
|
||||||
|
case "id", "id_product", "id_category", "id_manufacturer", "id_supplier", "id_shop", "id_lang", "id_product_attribute":
|
||||||
|
return "[0-9]+"
|
||||||
|
case "ean13", "price":
|
||||||
|
return "[^/]+"
|
||||||
|
default:
|
||||||
|
return "[^/]+"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productTokenValue(name string, data ProductURLData) string {
|
||||||
|
switch name {
|
||||||
|
case "id", "id_product":
|
||||||
|
if data.ID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", data.ID)
|
||||||
|
case "rewrite":
|
||||||
|
return strings.Trim(data.Slug, "/")
|
||||||
|
case "category", "categories":
|
||||||
|
value := strings.Trim(data.CategoryPath, "/")
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
case "id_product_attribute":
|
||||||
|
if data.ProductAttributeID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", data.ProductAttributeID)
|
||||||
|
case "ean13":
|
||||||
|
return strings.TrimSpace(data.EAN13)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryTokenValue(name string, data CategoryURLData) string {
|
||||||
|
switch name {
|
||||||
|
case "id", "id_category":
|
||||||
|
if data.ID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", data.ID)
|
||||||
|
case "rewrite":
|
||||||
|
return strings.Trim(data.Slug, "/")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLanguagePrefix(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
value = "/" + value
|
||||||
|
}
|
||||||
|
return strings.TrimRight(value, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackProductSlug(path string) (string, bool) {
|
||||||
|
match, ok := fallbackProductMatch(path)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return match.Slug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackProductMatch(path string) (*ProductMatch, bool) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if hasExcludedContentSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
lastSlash := strings.LastIndex(path, "/")
|
||||||
|
segment := path
|
||||||
|
if lastSlash >= 0 {
|
||||||
|
segment = path[lastSlash+1:]
|
||||||
|
}
|
||||||
|
matches := fallbackProductSegment.FindStringSubmatch(segment)
|
||||||
|
if matches == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := &ProductMatch{}
|
||||||
|
for idx, name := range fallbackProductSegment.SubexpNames() {
|
||||||
|
if idx >= len(matches) || matches[idx] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "rewrite":
|
||||||
|
out.Slug = matches[idx]
|
||||||
|
case "id":
|
||||||
|
out.ID = parseInt64(matches[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.ID == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackCategorySlug(path string) (string, bool) {
|
||||||
|
match, ok := fallbackCategoryMatch(path)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return match.Slug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackCategoryMatch(path string) (*CategoryMatch, bool) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if hasExcludedContentSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
lastSlash := strings.LastIndex(path, "/")
|
||||||
|
segment := path
|
||||||
|
if lastSlash >= 0 {
|
||||||
|
segment = path[lastSlash+1:]
|
||||||
|
}
|
||||||
|
matches := fallbackCategorySegment.FindStringSubmatch(segment)
|
||||||
|
if matches == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := &CategoryMatch{}
|
||||||
|
for idx, name := range fallbackCategorySegment.SubexpNames() {
|
||||||
|
if idx >= len(matches) || matches[idx] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "rewrite":
|
||||||
|
out.Slug = matches[idx]
|
||||||
|
case "id":
|
||||||
|
out.ID = parseInt64(matches[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.ID == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(value string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n = n*10 + int64(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExcludedContentSegment(path string) bool {
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
start := 0
|
||||||
|
if len(segments) > 0 && len(segments[0]) >= 2 && len(segments[0]) <= 5 {
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
for i := start; i < len(segments); i++ {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(segments[i]), "content") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaults struct {
|
||||||
|
LanguageID int64
|
||||||
|
CurrencyID int64
|
||||||
|
ShopID int64
|
||||||
|
ShopGroupID int64
|
||||||
|
CountryISO string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, prefix string) *Service {
|
||||||
|
return &Service{db: db, prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, fmt.Errorf("prestashop session service is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
def, err := s.loadDefaults(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
guestID, err := s.insertGuest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionID, err := s.insertConnection(ctx, def, guestID, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
values := map[string]string{
|
||||||
|
"checksum": anonymousChecksum(guestID, connectionID, def.LanguageID, def.CurrencyID, def.ShopID),
|
||||||
|
"date_add": now.Format("2006-01-02 15:04:05"),
|
||||||
|
"id_cart": "",
|
||||||
|
"id_connections": strconv.FormatInt(connectionID, 10),
|
||||||
|
"id_currency": strconv.FormatInt(def.CurrencyID, 10),
|
||||||
|
"id_guest": strconv.FormatInt(guestID, 10),
|
||||||
|
"id_lang": strconv.FormatInt(def.LanguageID, 10),
|
||||||
|
"id_language": strconv.FormatInt(def.LanguageID, 10),
|
||||||
|
"iso_code_country": def.CountryISO,
|
||||||
|
}
|
||||||
|
orderedKeys := []string{
|
||||||
|
"date_add",
|
||||||
|
"id_lang",
|
||||||
|
"id_cart",
|
||||||
|
"id_language",
|
||||||
|
"iso_code_country",
|
||||||
|
"id_currency",
|
||||||
|
"id_guest",
|
||||||
|
"id_connections",
|
||||||
|
"checksum",
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.ShopID > 0 {
|
||||||
|
values["id_shop"] = strconv.FormatInt(def.ShopID, 10)
|
||||||
|
orderedKeys = append(orderedKeys[:6], append([]string{"id_shop"}, orderedKeys[6:]...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pscookie.SessionContext{
|
||||||
|
CookieName: cookieName,
|
||||||
|
LanguageID: int64Ptr(def.LanguageID),
|
||||||
|
CurrencyID: int64Ptr(def.CurrencyID),
|
||||||
|
ShopID: int64Ptr(def.ShopID),
|
||||||
|
GuestID: int64Ptr(guestID),
|
||||||
|
IsLoggedIn: false,
|
||||||
|
Values: values,
|
||||||
|
OrderedKeys: orderedKeys,
|
||||||
|
ParseStatus: pscookie.ParseStatusAnonymous,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadDefaults(ctx context.Context) (*defaults, error) {
|
||||||
|
def := &defaults{
|
||||||
|
LanguageID: 1,
|
||||||
|
CurrencyID: 1,
|
||||||
|
ShopID: 1,
|
||||||
|
ShopGroupID: 1,
|
||||||
|
CountryISO: "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
configTable := s.prefix + "configuration"
|
||||||
|
shopTable := s.prefix + "shop"
|
||||||
|
countryTable := s.prefix + "country"
|
||||||
|
|
||||||
|
var configs []struct {
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
configQuery := fmt.Sprintf("SELECT name, value FROM %s WHERE name IN ('PS_LANG_DEFAULT', 'PS_CURRENCY_DEFAULT', 'PS_COUNTRY_DEFAULT')", configTable)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(configQuery).Scan(&configs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
countryID := int64(0)
|
||||||
|
for _, cfg := range configs {
|
||||||
|
switch cfg.Name {
|
||||||
|
case "PS_LANG_DEFAULT":
|
||||||
|
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||||
|
def.LanguageID = parsed
|
||||||
|
}
|
||||||
|
case "PS_CURRENCY_DEFAULT":
|
||||||
|
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||||
|
def.CurrencyID = parsed
|
||||||
|
}
|
||||||
|
case "PS_COUNTRY_DEFAULT":
|
||||||
|
if parsed, err := strconv.ParseInt(cfg.Value, 10, 64); err == nil && parsed > 0 {
|
||||||
|
countryID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shop struct {
|
||||||
|
ID int64 `gorm:"column:id_shop"`
|
||||||
|
GroupID int64 `gorm:"column:id_shop_group"`
|
||||||
|
}
|
||||||
|
shopQuery := fmt.Sprintf("SELECT id_shop, id_shop_group FROM %s ORDER BY id_shop LIMIT 1", shopTable)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(shopQuery).Scan(&shop).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if shop.ID > 0 {
|
||||||
|
def.ShopID = shop.ID
|
||||||
|
}
|
||||||
|
if shop.GroupID > 0 {
|
||||||
|
def.ShopGroupID = shop.GroupID
|
||||||
|
}
|
||||||
|
|
||||||
|
if countryID > 0 {
|
||||||
|
var country struct {
|
||||||
|
ISOCode string `gorm:"column:iso_code"`
|
||||||
|
}
|
||||||
|
countryQuery := fmt.Sprintf("SELECT iso_code FROM %s WHERE id_country = ? LIMIT 1", countryTable)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(countryQuery, countryID).Scan(&country).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if country.ISOCode != "" {
|
||||||
|
def.CountryISO = country.ISOCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertGuest(ctx context.Context) (int64, error) {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("resolve sql db for guest insert: %w", err)
|
||||||
|
}
|
||||||
|
tableName := s.prefix + "guest"
|
||||||
|
columns, values, err := s.guestInsert(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
query := insertQuery(tableName, columns)
|
||||||
|
result, err := sqlDB.ExecContext(ctx, query, values...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert guest: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("guest last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertConnection(ctx context.Context, def *defaults, guestID int64, req *http.Request) (int64, error) {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("resolve sql db for connection insert: %w", err)
|
||||||
|
}
|
||||||
|
tableName := s.prefix + "connections"
|
||||||
|
columns, values, err := s.connectionInsert(ctx, def, guestID, req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
query := insertQuery(tableName, columns)
|
||||||
|
result, err := sqlDB.ExecContext(ctx, query, values...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert connection: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("connection last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) guestInsert(ctx context.Context) ([]string, []any, error) {
|
||||||
|
available, err := s.tableColumns(ctx, s.prefix+"guest")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("load guest columns: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := make([]string, 0)
|
||||||
|
values := make([]any, 0)
|
||||||
|
addColumn := func(name string, value any) {
|
||||||
|
if available[name] {
|
||||||
|
columns = append(columns, name)
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addColumn("id_customer", 0)
|
||||||
|
addColumn("id_operating_system", 0)
|
||||||
|
addColumn("id_web_browser", 0)
|
||||||
|
addColumn("javascript", 0)
|
||||||
|
addColumn("screen_resolution_x", 0)
|
||||||
|
addColumn("screen_resolution_y", 0)
|
||||||
|
addColumn("screen_color", 0)
|
||||||
|
addColumn("sun_java", 0)
|
||||||
|
addColumn("adobe_flash", 0)
|
||||||
|
addColumn("adobe_director", 0)
|
||||||
|
addColumn("apple_quicktime", 0)
|
||||||
|
addColumn("real_player", 0)
|
||||||
|
addColumn("windows_media", 0)
|
||||||
|
addColumn("accept_language", "")
|
||||||
|
addColumn("mobile_theme", 0)
|
||||||
|
|
||||||
|
return columns, values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) connectionInsert(ctx context.Context, def *defaults, guestID int64, req *http.Request) ([]string, []any, error) {
|
||||||
|
available, err := s.tableColumns(ctx, s.prefix+"connections")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("load connections columns: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
columns := make([]string, 0)
|
||||||
|
values := make([]any, 0)
|
||||||
|
addColumn := func(name string, value any) {
|
||||||
|
if available[name] {
|
||||||
|
columns = append(columns, name)
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addColumn("id_guest", guestID)
|
||||||
|
addColumn("id_shop", def.ShopID)
|
||||||
|
addColumn("id_shop_group", def.ShopGroupID)
|
||||||
|
addColumn("id_page", 0)
|
||||||
|
addColumn("ip_address", ipAsUint32(req))
|
||||||
|
addColumn("date_add", now)
|
||||||
|
addColumn("date_upd", now)
|
||||||
|
addColumn("http_referer", referer(req))
|
||||||
|
addColumn("request_uri", requestURI(req))
|
||||||
|
|
||||||
|
return columns, values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) tableColumns(ctx context.Context, tableName string) (map[string]bool, error) {
|
||||||
|
type columnRow struct {
|
||||||
|
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []columnRow
|
||||||
|
query := `
|
||||||
|
SELECT COLUMN_NAME
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
`
|
||||||
|
if err := s.db.WithContext(ctx).Raw(query, tableName).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := make(map[string]bool, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
columns[row.ColumnName] = true
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertQuery(tableName string, columns []string) string {
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return fmt.Sprintf("INSERT INTO %s () VALUES ()", tableName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"INSERT INTO %s (%s) VALUES (%s)",
|
||||||
|
tableName,
|
||||||
|
strings.Join(columns, ", "),
|
||||||
|
placeholders(len(columns)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
parts := make([]string, n)
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = "?"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func referer(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return req.Referer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestURI(req *http.Request) string {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return req.URL.RequestURI()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipAsUint32(req *http.Request) uint32 {
|
||||||
|
if req == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
raw := req.Header.Get("X-Forwarded-For")
|
||||||
|
if raw == "" {
|
||||||
|
raw = req.RemoteAddr
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, ",") {
|
||||||
|
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
|
||||||
|
}
|
||||||
|
host := raw
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(raw); err == nil {
|
||||||
|
host = parsedHost
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ip = ip.To4()
|
||||||
|
if ip == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func anonymousChecksum(values ...int64) string {
|
||||||
|
buf := make([]byte, 0, len(values)*8)
|
||||||
|
for _, v := range values {
|
||||||
|
buf = strconv.AppendInt(buf, v, 10)
|
||||||
|
buf = append(buf, '|')
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(crc32.ChecksumIEEE(buf)), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(value int64) *int64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"prestaproxy/internal/assets"
|
||||||
|
"prestaproxy/internal/viewmodel"
|
||||||
|
"prestaproxy/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
assets assets.Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(manifest assets.Manifest) *Engine {
|
||||||
|
return &Engine{assets: manifest}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Product(w http.ResponseWriter, r *http.Request, data viewmodel.ProductPageData) error {
|
||||||
|
startHTMLStream(w)
|
||||||
|
component := templates.ProductPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||||
|
return component.Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel.CategoryPageData) error {
|
||||||
|
startHTMLStream(w)
|
||||||
|
component := templates.CategoryPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||||
|
return component.Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHTMLStream(w http.ResponseWriter) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headers := w.Header()
|
||||||
|
if headers.Get("Content-Type") == "" {
|
||||||
|
headers.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeatureFlag struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Key string `gorm:"uniqueIndex;size:191"`
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dialect, dsn string) (*gorm.DB, error) {
|
||||||
|
switch dialect {
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&FeatureFlag{}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported app db dialect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPresta(dialect, dsn string) (*gorm.DB, error) {
|
||||||
|
switch dialect {
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported prestashop db dialect")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package viewmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
pscart "prestaproxy/internal/prestashop/cart"
|
||||||
|
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||||
|
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||||
|
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryPageData struct {
|
||||||
|
Category pscatalog.CategoryPageData
|
||||||
|
Session *pscookie.SessionContext
|
||||||
|
Customer *pscustomer.Profile
|
||||||
|
CartSummary *pscart.Summary
|
||||||
|
ShopBaseURL string
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package viewmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
pscart "prestaproxy/internal/prestashop/cart"
|
||||||
|
pscatalog "prestaproxy/internal/prestashop/catalog"
|
||||||
|
pscookie "prestaproxy/internal/prestashop/cookie"
|
||||||
|
pscustomer "prestaproxy/internal/prestashop/customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductPageData struct {
|
||||||
|
Product pscatalog.ProductPageData
|
||||||
|
CategoryURL string
|
||||||
|
Session *pscookie.SessionContext
|
||||||
|
Customer *pscustomer.Profile
|
||||||
|
CartSummary *pscart.Summary
|
||||||
|
ShopBaseURL string
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ($argc < 3) {
|
||||||
|
fwrite(STDERR, "usage: php prestashop_cookie_bridge.php <mode> <bootstrap>\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);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./templates/**/*.templ"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
<main class="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_35%),linear-gradient(180deg,#0b1020,#111827)]">
|
||||||
|
<div class="mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10 lg:px-8">
|
||||||
|
<header class="rounded-[2rem] border border-emerald-500/20 bg-white/5 p-8 backdrop-blur">
|
||||||
|
<p class="text-xs uppercase tracking-[0.32em] text-emerald-300">Category</p>
|
||||||
|
<h1 class="mt-4 font-serif text-4xl text-white">{ data.Category.Name }</h1>
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-6 text-sm text-slate-300">
|
||||||
|
<p>{ fmt.Sprintf("Products loaded: %d", len(data.Category.Products)) }</p>
|
||||||
|
if data.Customer != nil {
|
||||||
|
<p>{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</p>
|
||||||
|
} else {
|
||||||
|
<p>Guest session</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if data.Category.Description != "" {
|
||||||
|
<div class="prose prose-invert mt-6 max-w-none text-slate-300">
|
||||||
|
@templ.Raw(data.Category.Description)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
for _, product := range data.Category.Products {
|
||||||
|
<article class="group rounded-[1.75rem] border border-white/10 bg-slate-900/70 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.35)] transition hover:-translate-y-1 hover:border-emerald-400/40">
|
||||||
|
<p class="text-xs uppercase tracking-[0.28em] text-emerald-300">Product</p>
|
||||||
|
<h2 class="mt-3 text-2xl font-semibold text-white">{ product.Name }</h2>
|
||||||
|
<p class="mt-4 text-sm leading-7 text-slate-300">{ product.Description }</p>
|
||||||
|
<div class="mt-8 flex items-center justify-between gap-4">
|
||||||
|
<p class="text-2xl font-semibold text-white">{ fmt.Sprintf("%.2f", product.Price) }</p>
|
||||||
|
<a class="rounded-full border border-emerald-400/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200 transition hover:bg-emerald-300 hover:text-slate-950" href={ product.URL }>
|
||||||
|
View Product
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "<main class=\"min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_35%),linear-gradient(180deg,#0b1020,#111827)]\"><div class=\"mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10 lg:px-8\"><header class=\"rounded-[2rem] border border-emerald-500/20 bg-white/5 p-8 backdrop-blur\"><p class=\"text-xs uppercase tracking-[0.32em] text-emerald-300\">Category</p><h1 class=\"mt-4 font-serif text-4xl text-white\">")
|
||||||
|
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, "</h1><div class=\"mt-4 flex items-center justify-between gap-6 text-sm text-slate-300\"><p>")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Customer != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p>")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p>Guest session</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Category.Description != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"prose prose-invert mt-6 max-w-none text-slate-300\">")
|
||||||
|
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, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</header><section class=\"grid gap-5 md:grid-cols-2 xl:grid-cols-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, product := range data.Category.Products {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<article class=\"group rounded-[1.75rem] border border-white/10 bg-slate-900/70 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.35)] transition hover:-translate-y-1 hover:border-emerald-400/40\"><p class=\"text-xs uppercase tracking-[0.28em] text-emerald-300\">Product</p><h2 class=\"mt-3 text-2xl font-semibold text-white\">")
|
||||||
|
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, "</h2><p class=\"mt-4 text-sm leading-7 text-slate-300\">")
|
||||||
|
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, "</p><div class=\"mt-8 flex items-center justify-between gap-4\"><p class=\"text-2xl font-semibold text-white\">")
|
||||||
|
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, "</p><a class=\"rounded-full border border-emerald-400/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200 transition hover:bg-emerald-300 hover:text-slate-950\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(product.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 39, Col: 209}
|
||||||
|
}
|
||||||
|
_, 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, 15, "\">View Product</a></div></article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section></div></main>")
|
||||||
|
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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Layout(title string, cssPath string, jsPath string) {
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>{ title }</title>
|
||||||
|
<link rel="stylesheet" href={ cssPath }/>
|
||||||
|
<script type="module" src={ jsPath } defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-stone-950 text-stone-100 antialiased">
|
||||||
|
{ children... }
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
|
||||||
|
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, "</title><link rel=\"stylesheet\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(cssPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 10, Col: 40}
|
||||||
|
}
|
||||||
|
_, 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, 3, "\"><script type=\"module\" src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(jsPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 11, Col: 37}
|
||||||
|
}
|
||||||
|
_, 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, "\" defer></script></head><body class=\"bg-stone-950 text-stone-100 antialiased\">")
|
||||||
|
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, "</body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -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) {
|
||||||
|
<main class="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.28),_transparent_40%),linear-gradient(180deg,#0c0a09,#1c1917)]">
|
||||||
|
<div class="mx-auto flex max-w-6xl flex-col gap-12 px-6 py-10 lg:px-8">
|
||||||
|
<header class="flex items-center justify-between border-b border-stone-800 pb-6">
|
||||||
|
<a class="text-sm uppercase tracking-[0.32em] text-amber-300" href={ data.ShopBaseURL }>Prestashop Proxy</a>
|
||||||
|
<div class="text-right text-sm text-stone-400">
|
||||||
|
if data.Customer != nil {
|
||||||
|
<p>{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</p>
|
||||||
|
} else {
|
||||||
|
<p>Guest session</p>
|
||||||
|
}
|
||||||
|
if data.CartSummary != nil {
|
||||||
|
<p>{ fmt.Sprintf("Cart items: %d", data.CartSummary.TotalItems) }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="rounded-3xl border border-stone-800 bg-stone-900/70 p-8 shadow-2xl shadow-amber-950/20">
|
||||||
|
<p class="text-xs uppercase tracking-[0.28em] text-stone-500">Product</p>
|
||||||
|
if data.CategoryURL != "" && data.Product.CategoryName != "" {
|
||||||
|
<a class="mt-4 inline-flex text-sm uppercase tracking-[0.24em] text-amber-300 underline underline-offset-4" href={ data.CategoryURL }>{ data.Product.CategoryName }</a>
|
||||||
|
}
|
||||||
|
<h1 class="mt-4 font-serif text-4xl text-stone-50">{ data.Product.Name }</h1>
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-8 text-stone-300">{ data.Product.ShortDescription }</p>
|
||||||
|
<div class="prose prose-invert mt-8 max-w-none text-stone-300">
|
||||||
|
@templ.Raw(data.Product.Description)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="flex flex-col justify-between rounded-3xl border border-amber-500/30 bg-amber-400/10 p-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.28em] text-amber-200">Price</p>
|
||||||
|
<p class="mt-4 text-5xl font-semibold text-stone-50">{ fmt.Sprintf("%.2f", data.Product.Price) }</p>
|
||||||
|
<p class="mt-2 text-sm text-stone-300">Live data loaded from PrestaShop storage, rendered by Go.</p>
|
||||||
|
</div>
|
||||||
|
<form class="mt-10 flex flex-col gap-4" method="post" action={ data.ShopBaseURL + "/cart" }>
|
||||||
|
<input type="hidden" name="id_product" value={ fmt.Sprintf("%d", data.Product.ID) }/>
|
||||||
|
<button class="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-stone-950 transition hover:bg-amber-200" type="submit">
|
||||||
|
Add to cart in PrestaShop
|
||||||
|
</button>
|
||||||
|
<a class="text-sm text-stone-300 underline underline-offset-4" href={ data.ShopBaseURL + "/login" }>Account and login remain on PrestaShop</a>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "<main class=\"min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.28),_transparent_40%),linear-gradient(180deg,#0c0a09,#1c1917)]\"><div class=\"mx-auto flex max-w-6xl flex-col gap-12 px-6 py-10 lg:px-8\"><header class=\"flex items-center justify-between border-b border-stone-800 pb-6\"><a class=\"text-sm uppercase tracking-[0.32em] text-amber-300\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(data.ShopBaseURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 14, Col: 90}
|
||||||
|
}
|
||||||
|
_, 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, "\">Prestashop Proxy</a><div class=\"text-right text-sm text-stone-400\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Customer != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p>")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p>Guest session</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.CartSummary != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p>")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></header><section class=\"grid gap-8 lg:grid-cols-[1.1fr_0.9fr]\"><div class=\"rounded-3xl border border-stone-800 bg-stone-900/70 p-8 shadow-2xl shadow-amber-950/20\"><p class=\"text-xs uppercase tracking-[0.28em] text-stone-500\">Product</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.CategoryURL != "" && data.Product.CategoryName != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a class=\"mt-4 inline-flex text-sm uppercase tracking-[0.24em] text-amber-300 underline underline-offset-4\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(data.CategoryURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 31, Col: 138}
|
||||||
|
}
|
||||||
|
_, 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, 10, "\">")
|
||||||
|
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, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<h1 class=\"mt-4 font-serif text-4xl text-stone-50\">")
|
||||||
|
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, "</h1><p class=\"mt-6 max-w-2xl text-lg leading-8 text-stone-300\">")
|
||||||
|
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, "</p><div class=\"prose prose-invert mt-8 max-w-none text-stone-300\">")
|
||||||
|
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, "</div></div><aside class=\"flex flex-col justify-between rounded-3xl border border-amber-500/30 bg-amber-400/10 p-8\"><div><p class=\"text-xs uppercase tracking-[0.28em] text-amber-200\">Price</p><p class=\"mt-4 text-5xl font-semibold text-stone-50\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", data.Product.Price))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 43, Col: 101}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p class=\"mt-2 text-sm text-stone-300\">Live data loaded from PrestaShop storage, rendered by Go.</p></div><form class=\"mt-10 flex flex-col gap-4\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(data.ShopBaseURL + "/cart")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 46, Col: 95}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"><input type=\"hidden\" name=\"id_product\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Product.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 47, Col: 88}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <button class=\"rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-stone-950 transition hover:bg-amber-200\" type=\"submit\">Add to cart in PrestaShop</button> <a class=\"text-sm text-stone-300 underline underline-offset-4\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(data.ShopBaseURL + "/login")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 51, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">Account and login remain on PrestaShop</a></form></aside></section></div></main>")
|
||||||
|
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
|
||||||
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
body{margin:0;font-family:"IBM Plex Sans",sans-serif;background:#0c0a09;color:#f5f5f4}a{color:inherit}button{cursor:pointer}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
document.documentElement.dataset.js="ready";
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"app.css": "/dist/app.css",
|
||||||
|
"app.js": "/dist/app.js"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user