diff --git a/cmd/cli/components/di.go b/cmd/cli/components/di.go index 85299e9..c3a2dc3 100644 --- a/cmd/cli/components/di.go +++ b/cmd/cli/components/di.go @@ -61,7 +61,7 @@ func SetupDI(ctx context.Context, cfgpath string) error { return nil, fmt.Errorf("getting logger: %w", err) } - client, err := eway.New(eway.Config(cfg.Eway), log) + client, err := eway.New(ctx, eway.Config(cfg.Eway), log) if err != nil { return nil, fmt.Errorf("making new eway client: %w", err) } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index a8d6164..20ef9dd 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + "encoding/binary" "encoding/hex" "encoding/json" "encoding/xml" @@ -11,12 +12,15 @@ import ( "fmt" "io" "math/big" + "net" + "net/http" "os" "os/signal" "strconv" "time" "git.loyso.art/frx/eway/cmd/cli/components" + "git.loyso.art/frx/eway/internal/crypto" "git.loyso.art/frx/eway/internal/encoding/fbs" "git.loyso.art/frx/eway/internal/entity" "git.loyso.art/frx/eway/internal/export" @@ -101,6 +105,8 @@ func setupCLI() *cli.Command { After: releaseDI(), Commands: []*cli.Command{ + newAppCmd(), + newCryptoCmd(), newParseCmd(), newImportCmd(), newExportCmd(), @@ -111,6 +117,93 @@ func setupCLI() *cli.Command { return app } +func newCryptoCmd() *cli.Command { + return &cli.Command{ + Name: "crypto", + Usage: "methods for encrypt/decrypt various things", + Commands: []*cli.Command{ + newCryptoEncyptCmd(), + newCryptoDecryptCmd(), + }, + } +} + +func newCryptoEncyptCmd() *cli.Command { + return &cli.Command{ + Name: "encrypt", + Usage: "encypt incoming text", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "text", + Aliases: []string{"t"}, + Required: true, + }, + }, + Action: cryptoDeEncryptAction(true), + } +} + +func newCryptoDecryptCmd() *cli.Command { + return &cli.Command{ + Name: "decrypt", + Usage: "decrypt incoming text", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "text", + Aliases: []string{"t"}, + Required: true, + }, + }, + Action: cryptoDeEncryptAction(false), + } +} + +func newAppCmd() *cli.Command { + return &cli.Command{ + Name: "app", + Usage: "commands for running different applications", + Commands: []*cli.Command{ + newAppYmlExporter(), + }, + } +} + +func newAppYmlExporter() *cli.Command { + return &cli.Command{ + Name: "ymlexporter", + Usage: "server on given port a http api for accessing yml catalog", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "port", + Usage: "Port for accepting connections", + Value: 9448, + Validator: func(i int64) error { + if i > 1<<16 || i == 0 { + return cli.Exit("bad port value, allowed values should not exceed 65535", 1) + } + + return nil + }, + }, + &cli.StringFlag{ + Name: "src", + TakesFile: true, + Usage: "Source to catalog. Should be a valid xml file", + Value: "yml_catalog.xml", + Validator: func(s string) error { + _, err := os.Stat(s) + if err != nil { + return err + } + + return nil + }, + }, + }, + Action: appYMLExporterAction, + } +} + func newParseCmd() *cli.Command { return &cli.Command{ Name: "parse", @@ -123,9 +216,44 @@ func newParseCmd() *cli.Command { func newParseEwayCmd() *cli.Command { return &cli.Command{ - Name: "eway", - Usage: "parse all available eway goods", - Action: decorateAction(parseEwayAction), + Name: "eway", + Usage: "parse all available eway goods", + Commands: []*cli.Command{ + newParseEwayGetCmd(), + newParseEwayDumpCmd(), + }, + } +} + +func newParseEwayGetCmd() *cli.Command { + return &cli.Command{ + Name: "get", + Usage: "parse all available eway goods", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "page", + Usage: "choose page to load", + Value: 1, + }, + &cli.IntFlag{ + Name: "limit", + Usage: "limits output", + Value: 100, + }, + &cli.IntFlag{ + Name: "min-stock", + Usage: "filters by minimum available items in stock", + }, + }, + Action: decorateAction(parseEwayGetAction), + } +} + +func newParseEwayDumpCmd() *cli.Command { + return &cli.Command{ + Name: "dump", + Usage: "dumps content of eway catalog inside db", + Action: decorateAction(parseEwayDumpAction), } } @@ -642,7 +770,63 @@ func exportYMLCatalogAction(ctx context.Context, c *cli.Command) error { return enc.Encode(container) } -func parseEwayAction(ctx context.Context, c *cli.Command) error { +func parseEwayGetAction(ctx context.Context, cmd *cli.Command) error { + page := cmd.Int("page") + limit := cmd.Int("limit") + atLeast := cmd.Int("min-stock") + searchInStocks := atLeast > 0 + + client, err := components.GetEwayClient() + if err != nil { + return fmt.Errorf("getting eway client: %w", err) + } + + start := page * limit + + items, total, err := client.GetGoodsNew(ctx, eway.GetGoodsNewParams{ + Draw: 1, + Start: int(start), + Length: int(limit), + SearchInStocks: searchInStocks, + RemmantsAtleast: int(atLeast), + }) + if err != nil { + return fmt.Errorf("getting new goods: %w", err) + } + + productIDs := make([]int, 0, len(items)) + for _, item := range items { + productIDs = append(productIDs, int(item.Cart)) + } + + remnants, err := client.GetGoodsRemnants(ctx, productIDs) + if err != nil { + return fmt.Errorf("getting remnants: %w", err) + } + + tbl := table.New("sku", "category", "cart", "stock", "price") + for _, item := range items { + outGood, err := entity.MakeGoodsItem(item, remnants) + if err != nil { + return fmt.Errorf("making goods item: %w", err) + } + + tbl.AddRow( + outGood.Articul, + outGood.Type, + outGood.Cart, + outGood.Stock, + outGood.Price, + ) + } + + tbl.Print() + + println("total:", total) + return nil +} + +func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error { client, err := components.GetEwayClient() if err != nil { return fmt.Errorf("getting eway client: %w", err) @@ -833,3 +1017,104 @@ func testFBSAction(ctx context.Context, c *cli.Command) error { return nil } + +func appYMLExporterAction(ctx context.Context, cmd *cli.Command) error { + port := cmd.Int("port") + src := cmd.String("src") + log, err := components.GetLogger() + if err != nil { + return fmt.Errorf("getting logger: %w", err) + } + + mw := func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + remoteAddr := r.RemoteAddr + xff := r.Header.Get("x-forwarded-for") + method := r.Method + ua := r.UserAgent() + xreqid := r.Header.Get("x-request-id") + if xreqid == "" { + const reqsize = 4 + var reqidRaw [reqsize]byte + _, _ = rand.Read(reqidRaw[:]) + value := binary.BigEndian.Uint32(reqidRaw[:]) + xreqid = fmt.Sprintf("%x", value) + w.Header().Set("x-request-id", xreqid) + } + + start := time.Now() + xlog := log.With().Str("request_id", xreqid).Logger() + xlog.Debug(). + Str("path", path). + Str("remote_addr", remoteAddr). + Str("xff", xff). + Str("method", method). + Str("user_agent", ua). + Msg("incoming request") + + xctx := xlog.WithContext(r.Context()) + r = r.WithContext(xctx) + next(w, r) + + elapsed := time.Since(start).Truncate(time.Millisecond).Seconds() + xlog.Info(). + Float64("elapsed", elapsed). + Msg("request completed") + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/yml_catalog.xml", mw(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, src) + })) + + srv := &http.Server{ + Addr: net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))), + Handler: mux, + } + + go func() { + <-ctx.Done() + + sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*5) + defer sdcancel() + + errShutdown := srv.Shutdown(sdctx) + if errShutdown != nil { + log.Warn().Err(errShutdown).Msg("unable to shutdown server") + } + }() + + log.Info().Str("listen_addr", srv.Addr).Msg("running server") + + err = srv.ListenAndServe() + if err != nil && errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serving http api: %w", err) + } + + return nil +} + +var ( + someDumbKey = []byte("9530e001b619e8e98a889055f06821bb") +) + +func cryptoDeEncryptAction(encrypt bool) cli.ActionFunc { + return func(ctx context.Context, c *cli.Command) (err error) { + value := c.String("text") + var out string + if encrypt { + out, err = crypto.Encrypt(value) + } else { + out, err = crypto.Decrypt(value) + } + if err != nil { + return err + } + + _, err = c.Writer.Write([]byte(out)) + _, err = c.Writer.Write([]byte{'\n'}) + return err + } +} diff --git a/config.toml b/config.toml index e0c626a..98acf99 100644 --- a/config.toml +++ b/config.toml @@ -7,7 +7,9 @@ level = "info" format = "text" [eway] -session_id = "19b98ed56cc144f47e040e68dbcd8481" -session_user = "1490" +login = "leci@yandex.ru" +password = "2a136e113854cc5d46b868919a7d6e939156ccb55ff12e87861513f7767af98be79e62407410" +_session_id = "19b98ed56cc144f47e040e68dbcd8481" +_session_user = "1490" owner_id = "26476" debug = false diff --git a/internal/config/eway.go b/internal/config/eway.go index c81cf74..e00d4a1 100644 --- a/internal/config/eway.go +++ b/internal/config/eway.go @@ -1,6 +1,8 @@ package config type Eway struct { + Login string `toml:"login"` + Password string `toml:"password"` SessionID string `toml:"session_id"` SessionUser string `toml:"session_user"` OwnerID string `toml:"owner_id"` diff --git a/internal/crypto/cipher.go b/internal/crypto/cipher.go new file mode 100644 index 0000000..c0a744a --- /dev/null +++ b/internal/crypto/cipher.go @@ -0,0 +1,32 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" +) + +var ( + someDumbKey = []byte("9530e001b619e8e98a889055f06821bb") +) + +func Encrypt(plaintext string) (hexed string, err error) { + aes, err := aes.NewCipher(someDumbKey) + if err != nil { + return "", fmt.Errorf("making new cipher: %w", err) + } + gcm, err := cipher.NewGCM(aes) + if err != nil { + return "", fmt.Errorf("making new gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + _, err = rand.Read(nonce) + if err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + outvalue := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(outvalue), nil +} diff --git a/internal/crypto/decrypt_off.go b/internal/crypto/decrypt_off.go new file mode 100644 index 0000000..08795d6 --- /dev/null +++ b/internal/crypto/decrypt_off.go @@ -0,0 +1,10 @@ +//go:build !encon +// +build !encon + +package crypto + +import "git.loyso.art/frx/eway/internal/entity" + +func Decrypt(hexedcipher string) (plaintext string, err error) { + return "", entity.SimpleError("this feature turned off") +} diff --git a/internal/crypto/decrypt_on.go b/internal/crypto/decrypt_on.go new file mode 100644 index 0000000..8cb5d0e --- /dev/null +++ b/internal/crypto/decrypt_on.go @@ -0,0 +1,35 @@ +//go:build encon +// +build encon + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "fmt" +) + +func Decrypt(hexedcipher string) (plaintext string, err error) { + aes, err := aes.NewCipher(someDumbKey) + if err != nil { + return "", fmt.Errorf("making new cipher: %w", err) + } + gcm, err := cipher.NewGCM(aes) + if err != nil { + return "", fmt.Errorf("making new gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + valueDecoded, err := hex.DecodeString(hexedcipher) + if err != nil { + return "", fmt.Errorf("decoding hexed value: %w", err) + } + nonce, cipherText := valueDecoded[:nonceSize], valueDecoded[nonceSize:] + plaintextRaw, err := gcm.Open(nil, []byte(nonce), []byte(cipherText), nil) + if err != nil { + return "", fmt.Errorf("opening: %w", err) + } + + return string(plaintextRaw), nil +} diff --git a/internal/interconnect/eway/client.go b/internal/interconnect/eway/client.go index d5e51b6..b4ac69a 100644 --- a/internal/interconnect/eway/client.go +++ b/internal/interconnect/eway/client.go @@ -13,6 +13,7 @@ import ( "strings" "git.loyso.art/frx/eway/internal/config" + "git.loyso.art/frx/eway/internal/crypto" "git.loyso.art/frx/eway/internal/entity" "github.com/go-resty/resty/v2" @@ -36,38 +37,54 @@ type client struct { type Config config.Eway -func New(cfg Config, log zerolog.Logger) (client, error) { - if cfg.SessionID == "" { - return client{}, entity.SimpleError("no session id provided") - } - if cfg.SessionUser == "" { - return client{}, entity.SimpleError("no session user provided") - } - - cookies := []*http.Cookie{ - { - Name: "session_id", - Value: cfg.SessionID, - Domain: "eway.elevel.ru", - HttpOnly: true, - }, - { - Name: "session_user", - Value: cfg.SessionUser, - Domain: "eway.elevel.ru", - HttpOnly: true, - }, - } - +func New(ctx context.Context, cfg Config, log zerolog.Logger) (client, error) { httpclient := resty.New(). SetDebug(cfg.Debug). - SetCookies(cookies). + // SetCookies(cookies). SetBaseURL("https://eway.elevel.ru/api") - return client{ + c := client{ http: httpclient, log: log.With().Str("client", "eway").Logger(), - }, nil + } + + if cfg.SessionID == "" || cfg.SessionUser == "" { + if cfg.Login == "" || cfg.Password == "" { + return client{}, entity.SimpleError("no auth method provided") + } + + decryptedPassword, err := crypto.Decrypt(cfg.Password) + if err != nil { + return client{}, fmt.Errorf("decrypting password: %w", err) + } + err = c.login(ctx, cfg.Login, decryptedPassword) + if err != nil { + return client{}, err + } + + log.Info().Msg("login successful") + } else if cfg.SessionID != "" && cfg.SessionUser != "" { + cookies := []*http.Cookie{ + { + Name: "session_id", + Value: cfg.SessionID, + Domain: "eway.elevel.ru", + HttpOnly: true, + }, + { + Name: "session_user", + Value: cfg.SessionUser, + Domain: "eway.elevel.ru", + HttpOnly: true, + }, + } + + c.http.SetCookies(cookies) + } else { + return client{}, entity.SimpleError("bad configuration: either session_id and session_user should be set or login and password") + } + + return c, nil } type GetGoodsNewParams struct { @@ -181,8 +198,6 @@ func (c client) GetGoodsRemnants( return nil, fmt.Errorf("reading raw body: %w", err) } - c.log.Debug().RawJSON("response", data).Msg("body prepared") - out = make(entity.MappedGoodsRemnants, len(productIDs)) err = json.NewDecoder(bytes.NewReader(data)).Decode(&out) if err != nil { @@ -234,3 +249,23 @@ func (c client) GetGoodsNew( return mapResponseByOrder(response), response.RecordsTotal, nil } + +func (c client) login(ctx context.Context, user, pass string) error { + resp, err := c.http.R(). + SetDoNotParseResponse(true). + SetFormData(map[string]string{ + "username": user, + "password": pass, + }).Post("https://eway.elevel.ru/") + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + + if resp.IsError() { + zerolog.Ctx(ctx).Warn().Int("code", resp.StatusCode()).Msg("bad response") + + return entity.SimpleError("request was not successful") + } + + return nil +}