account page stubbed: link to etsy sign up
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package consts
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// TODO: use throught the database methods and the site template parsing to treat it as a 404 (not a redirect)
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/consts"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type (
|
||||
Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
StoreWithContext struct {
|
||||
ctx context.Context
|
||||
db *Store
|
||||
}
|
||||
|
||||
Account struct {
|
||||
ID int64
|
||||
Email string
|
||||
}
|
||||
)
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
|
||||
return &StoreWithContext{
|
||||
ctx: ctx,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Store) CreateAccount(ctx context.Context, email string) (Account, error) {
|
||||
rows, err := db.db.Query(
|
||||
ctx,
|
||||
"INSERT INTO accounts (email) VALUES (@email) RETURNING account_id",
|
||||
pgx.NamedArgs{
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
|
||||
return Account{
|
||||
ID: acctID,
|
||||
Email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
|
||||
rows, err := db.db.Query(
|
||||
ctx,
|
||||
"SELECT email FROM accounts WHERE account_id = @account_id",
|
||||
pgx.NamedArgs{
|
||||
"account_id": id,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
email, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, consts.ErrNotFound
|
||||
}
|
||||
|
||||
return Account{}, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
|
||||
return Account{
|
||||
ID: id,
|
||||
Email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
|
||||
rows, err := db.db.Query(
|
||||
ctx,
|
||||
"SELECT account_id FROM accounts WHERE email = @email",
|
||||
pgx.NamedArgs{
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
id, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, consts.ErrNotFound
|
||||
}
|
||||
|
||||
return Account{}, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
|
||||
return Account{
|
||||
ID: id,
|
||||
Email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *StoreWithContext) CreateAccount(email string) (Account, error) {
|
||||
return db.db.CreateAccount(db.ctx, email)
|
||||
}
|
||||
|
||||
func (db *StoreWithContext) GetAccount(id int64) (Account, error) {
|
||||
return db.db.GetAccount(db.ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package etsy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
|
||||
)
|
||||
|
||||
func newFixedAccessTokenClient(apiKey, accessToken string) (*generated_client.ClientWithResponses, error) {
|
||||
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
|
||||
return accessToken, nil
|
||||
})
|
||||
}
|
||||
|
||||
func newAccessTokenRefreshingClient(apiKey string) (*generated_client.ClientWithResponses, error) {
|
||||
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
|
||||
// TODO: look up the token in the db.
|
||||
return "", nil
|
||||
})
|
||||
}
|
||||
|
||||
func newAuthenticatingClient(apiKey string, getAccessToken func(context.Context) (string, error)) (*generated_client.ClientWithResponses, error) {
|
||||
var setAuthHeaders generated_client.ClientOption = func(c *generated_client.Client) error {
|
||||
c.RequestEditors = append(c.RequestEditors, generated_client.RequestEditorFn(func(ctx context.Context, req *http.Request) error {
|
||||
accessToken, err := getAccessToken(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h := req.Header
|
||||
h.Set("x-api-key", apiKey)
|
||||
h.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||
|
||||
return nil
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := generated_client.NewClientWithResponses("https://api.etsy.com", setAuthHeaders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize open api client: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
@@ -1,19 +1,320 @@
|
||||
package etsy
|
||||
|
||||
//go:generate oapi-codegen -package generated_client -o generated_client/client.go openapi.3.0.2.json
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
const (
|
||||
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
|
||||
etsyAPISharedSecret = "jaaw0tyizf"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
|
||||
)
|
||||
|
||||
func GetEtsyAPIKeystring() string {
|
||||
return etsyAPIKeystring
|
||||
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
|
||||
|
||||
// TODO:
|
||||
// - [x] document the flow in the README.md
|
||||
// - [ ] save the initial access/refresh tokens in the db
|
||||
// - [ ] make calls to refresh the access token and store it in the db.
|
||||
// - [ ] make cron jobs to automatically refresh the refresh token upon as tokens approach expiration.
|
||||
// - [ ] make pages to direct to the etsy shop acceptance url/page
|
||||
// - [ ] stub an account page
|
||||
|
||||
type (
|
||||
Platform struct {
|
||||
oAuthRedirectURI func(acctID int64) string
|
||||
apiKeystring string
|
||||
apiSharedSecret string
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
PlatformWithContext struct {
|
||||
ctx context.Context
|
||||
p *Platform
|
||||
}
|
||||
)
|
||||
|
||||
// oauth scopes
|
||||
const (
|
||||
scopeAddressRead = "address_r" // Read a member's shipping addresses.
|
||||
scopeAddressWrite = "address_w" // Update and delete a member's shipping address.
|
||||
scopeBillingRead = "billing_r" // Read a member's Etsy bill charges and payments.
|
||||
scopeCartRead = "cart_r" // Read the contents of a member’s cart.
|
||||
scopeCartWrite = "cart_w" // Add and remove listings from a member's cart.
|
||||
scopeEmailRead = "email_r" // Read a user profile
|
||||
scopeFavoritesRead = "favorites_r" // View a member's favorite listings and users.
|
||||
scopeFavoritesWrite = "favorites_w" // Add to and remove from a member's favorite listings and users.
|
||||
scopeFeedbackRead = "feedback_r" // View all details of a member's feedback (including purchase history.)
|
||||
scopeListings_d = "listings_d" // Delete a member's listings.
|
||||
scopeListingsRead = "listings_r" // Read a member's inactive and expired (i.e., non-public) listings.
|
||||
scopeListingsWrite = "listings_w" // Create and edit a member's listings.
|
||||
scopeProfileRead = "profile_r" // Read a member's private profile information.
|
||||
scopeProfileWrite = "profile_w" // Update a member's private profile information.
|
||||
scopeRecommendRead = "recommend_r" // View a member's recommended listings.
|
||||
scopeRecommendWrite = "recommend_w" // Remove a member's recommended listings.
|
||||
scopeShopsRead = "shops_r" // See a member's shop description, messages and sections, even if not (yet) public.
|
||||
scopeShopsWrite = "shops_w" // Update a member's shop description, messages and sections.
|
||||
scopeTransactionsRead = "transactions_r" // Read a member's purchase and sales data. This applies to buyers as well as sellers.
|
||||
scopeTransactionsWrite = "transactions_w" // Update a member's sales data.
|
||||
)
|
||||
|
||||
func NewPlatform(oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSharedSecret string, db *pgxpool.Pool) *Platform {
|
||||
return &Platform{
|
||||
oAuthRedirectURI: oAuthRedirectURI,
|
||||
apiKeystring: apiKeystring,
|
||||
apiSharedSecret: apiSharedSecret,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func GetEtsyAPISharedSecret() string {
|
||||
return etsyAPISharedSecret
|
||||
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
|
||||
return &PlatformWithContext{
|
||||
ctx: ctx,
|
||||
p: p,
|
||||
}
|
||||
}
|
||||
|
||||
func requestAnAuthCode() {
|
||||
func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctID int64) (*url.URL, error) {
|
||||
req, err := p.createNewOAuthRequest(ctx, acctID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create oauth request parameters: %w", err)
|
||||
}
|
||||
|
||||
state := req.state
|
||||
code := req.pkceCode
|
||||
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.etsy.com",
|
||||
Path: "/oauth/connect",
|
||||
RawQuery: url.Values{
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {p.oAuthRedirectURI(acctID)},
|
||||
"scope": {url.QueryEscape(strings.Join([]string{
|
||||
scopeCartRead,
|
||||
scopeCartWrite,
|
||||
scopeEmailRead,
|
||||
scopeListingsWrite,
|
||||
}, ","))},
|
||||
"client_id": {p.apiKeystring},
|
||||
"state": {state.String()},
|
||||
"code_challenge": {fmt.Sprintf("%x", code.challenge)},
|
||||
"code_challenge_method": {"S256"},
|
||||
}.Encode(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
|
||||
return p.p.GenerateConnectionURLForNewAccount(p.ctx, acctID)
|
||||
}
|
||||
|
||||
// HandleNewAuthCode handles the auth code to get api access
|
||||
func (p *Platform) HandleNewAuthCode(ctx context.Context, acctID int64, state, authCode string) (bool, error) {
|
||||
// look up matching oauth request
|
||||
|
||||
stateUUID, err := uuid.Parse(state)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
oar, ok, err := p.getOauthRequest(ctx, stateUUID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to look up existing oauth request: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if oar.acctID != acctID {
|
||||
return false, p.deleteOauthRequest(ctx, stateUUID)
|
||||
}
|
||||
|
||||
// construct http request to obtain access token
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
(&url.URL{
|
||||
Scheme: "https://",
|
||||
Host: "api.etsy.com",
|
||||
Path: "/v3/public/oauth/token",
|
||||
}).String(),
|
||||
bytes.NewReader([]byte(url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {p.apiKeystring},
|
||||
"redirect_uri": {p.oAuthRedirectURI(acctID)},
|
||||
"code": {authCode},
|
||||
"code_verifier": {fmt.Sprintf("%x", oar.pkceCode.verifier)},
|
||||
}.Encode())),
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to generate http request to get oauth tokens: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "x-www-form-urlencoded")
|
||||
|
||||
// perform request to obtain access token
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to perform http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if c := resp.StatusCode; (c / 100) != 2 {
|
||||
return false, fmt.Errorf("unexpected http response code: %d; body = %s", c, string(b))
|
||||
}
|
||||
|
||||
// parse and validate response body
|
||||
|
||||
accessToken, refreshToken, expiration, userID, err := p.parseAccessCodeResponseBody(ctx, b)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse response body: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("new access token:", accessToken)
|
||||
fmt.Println("new refresh token:", refreshToken)
|
||||
fmt.Println("new token expiration:", expiration)
|
||||
|
||||
// look up user's shop id
|
||||
|
||||
shopID, err := p.getNewUserShopID(ctx, accessToken, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to complete sign on due to failing to look up the user's shop id: %w", err)
|
||||
}
|
||||
|
||||
// save all to database
|
||||
const ninetyDays = 90 * 24 * time.Hour
|
||||
|
||||
if err := p.saveNewEtsyUser(
|
||||
ctx,
|
||||
EtsyUser{
|
||||
AcctID: acctID,
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
},
|
||||
etsyAccessTokens{
|
||||
access: tokenAndExpiration{
|
||||
token: accessToken,
|
||||
expiration: expiration,
|
||||
},
|
||||
refresh: tokenAndExpiration{
|
||||
token: refreshToken,
|
||||
expiration: time.Now().Add(ninetyDays),
|
||||
},
|
||||
},
|
||||
); err != nil {
|
||||
return false, fmt.Errorf("failed to save new user and access token: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *Platform) parseAccessCodeResponseBody(ctx context.Context, body []byte) (
|
||||
accessToken string,
|
||||
refreshToken string,
|
||||
expiration time.Time,
|
||||
userID int64, // stored in the access token & refresh token
|
||||
err error,
|
||||
) {
|
||||
// parse and validate response body
|
||||
|
||||
var (
|
||||
tokenType string
|
||||
expirationInSeconds int
|
||||
)
|
||||
|
||||
if err = json.Unmarshal(body, &struct {
|
||||
Access_Token *string
|
||||
Token_Type *string
|
||||
Expires_In *int
|
||||
Refresh_Token *string
|
||||
}{
|
||||
Access_Token: &accessToken,
|
||||
Token_Type: &tokenType,
|
||||
Expires_In: &expirationInSeconds,
|
||||
Refresh_Token: &refreshToken,
|
||||
}); err != nil {
|
||||
err = fmt.Errorf("failed to decode request body as json: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
if v := accessToken; v == "" {
|
||||
err = fmt.Errorf("no access token specified in access token response: body = %s", string(body))
|
||||
return
|
||||
} else if accessTokenParts := strings.SplitN(accessToken, ".", 2); len(accessTokenParts) < 2 {
|
||||
err = fmt.Errorf("unexpected access token format: expected a user_id prefix: <user_id>.<remaindeder>: %s: body = %s", accessToken, string(body))
|
||||
return
|
||||
} else if i, ierr := strconv.ParseInt(accessTokenParts[0], 10, 64); ierr != nil {
|
||||
err = fmt.Errorf("unexpected user_id in access token: should be an integer: %s: %w", accessTokenParts[0], ierr)
|
||||
return
|
||||
} else if i <= 0 {
|
||||
err = fmt.Errorf("unexpected user_id in access token: should be a positive integer: %d: %w", i, err)
|
||||
} else {
|
||||
userID = i
|
||||
}
|
||||
|
||||
if tt := tokenType; tt == "" {
|
||||
err = fmt.Errorf("no token type specified in access token response: body = %s", string(body))
|
||||
return
|
||||
} else if tt != "Bearer" {
|
||||
err = fmt.Errorf("unexpected token type specified in access token response: %s: body = %s", tt, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
if v := expirationInSeconds; v == 0 {
|
||||
err = fmt.Errorf("no expiration specified in access token response: body = %s", string(body))
|
||||
return
|
||||
} else if v < 0 {
|
||||
err = fmt.Errorf("unexpected expiration specified in access token response: %d: body = %s", v, string(body))
|
||||
return
|
||||
} else {
|
||||
expiration = time.Now().UTC().Add(time.Duration(expirationInSeconds) * time.Second)
|
||||
}
|
||||
|
||||
if v := refreshToken; v == "" {
|
||||
err = fmt.Errorf("no refresh token specified in access token response: body = %s", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Platform) getNewUserShopID(ctx context.Context, accessToken string, userID int64) (int64, error) {
|
||||
cli, err := newFixedAccessTokenClient(p.apiKeystring, accessToken)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to initialize openapi client: %w", err)
|
||||
}
|
||||
|
||||
var res *generated_client.GetShopByOwnerUserIdResponse
|
||||
if res, err = cli.GetShopByOwnerUserIdWithResponse(ctx, userID); err != nil {
|
||||
return 0, fmt.Errorf("failed to obtain shop id due to failure to initialize request to obtain shop id: %w", err)
|
||||
} else if res.JSON400 != nil {
|
||||
return 0, fmt.Errorf("failed to look up shop for user %d due to 400 error: %s", userID, res.JSON400.Error)
|
||||
} else if res.JSON403 != nil {
|
||||
return 0, fmt.Errorf("failed to look up shop for user %d due to 403 error: %s", userID, res.JSON403.Error)
|
||||
} else if res.JSON404 != nil {
|
||||
return 0, fmt.Errorf("failed to look up shop for user %d due to 404 error: %s", userID, res.JSON404.Error)
|
||||
} else if res.JSON500 != nil {
|
||||
return 0, fmt.Errorf("failed to look up shop for user %d due to 500 error: %s", userID, res.JSON500.Error)
|
||||
}
|
||||
|
||||
if res.JSON200.ShopId == nil {
|
||||
return 0, fmt.Errorf("shop for user %d has no shop_id set: body = %s", userID, res.Body)
|
||||
}
|
||||
|
||||
return *res.JSON200.ShopId, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package etsy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
EtsyUser struct {
|
||||
AcctID int64
|
||||
UserID int64
|
||||
ShopID int64
|
||||
}
|
||||
|
||||
etsyAccessTokens struct {
|
||||
access tokenAndExpiration
|
||||
refresh tokenAndExpiration
|
||||
}
|
||||
|
||||
tokenAndExpiration struct {
|
||||
token string
|
||||
expiration time.Time
|
||||
}
|
||||
|
||||
oauth2Request struct {
|
||||
acctID int64
|
||||
state uuid.UUID
|
||||
expiration time.Time
|
||||
pkceCode pkceCode
|
||||
}
|
||||
|
||||
pkceCode struct {
|
||||
verifier [32]byte
|
||||
challenge []byte
|
||||
}
|
||||
)
|
||||
|
||||
func (p *Platform) GetUserPointerByAccountID(ctx context.Context, acctID int64) (*EtsyUser, error) {
|
||||
rows, err := p.db.Query(
|
||||
ctx,
|
||||
"SELECT user_id, shop_id FROM etsy_users WHERE account_id = @account_id",
|
||||
pgx.NamedArgs{
|
||||
"account_id": acctID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
User_ID int64
|
||||
Shop_ID int64
|
||||
}
|
||||
|
||||
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
|
||||
return &EtsyUser{
|
||||
AcctID: acctID,
|
||||
UserID: r.User_ID,
|
||||
ShopID: r.Shop_ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
|
||||
return p.p.GetUserPointerByAccountID(p.ctx, acctID)
|
||||
}
|
||||
|
||||
func (p *Platform) saveNewEtsyUser(
|
||||
ctx context.Context,
|
||||
user EtsyUser,
|
||||
tokens etsyAccessTokens,
|
||||
) (err error) {
|
||||
_, err = p.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
WITH new_user (
|
||||
INSERT INTO etsy_users (
|
||||
account_id,
|
||||
user_id,
|
||||
shop_id
|
||||
)
|
||||
VALUES (
|
||||
@account_id,
|
||||
@user_id,
|
||||
@shop_id
|
||||
)
|
||||
RETURNING
|
||||
account_id,
|
||||
user_id,
|
||||
shop_id
|
||||
)
|
||||
INSERT INTO etsy_access_tokens (
|
||||
access_token,
|
||||
refresh_token,
|
||||
access_token_expiration,
|
||||
refresh_token_expiration
|
||||
)
|
||||
VALUE (
|
||||
@access_token,
|
||||
@refresh_token,
|
||||
@access_token_expiration,
|
||||
@refresh_token_expiration
|
||||
)
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"account_id": user.AcctID,
|
||||
"user_id": user.UserID,
|
||||
"shop_id": user.ShopID,
|
||||
"access_token": tokens.access.token,
|
||||
"refresh_token": tokens.refresh.token,
|
||||
"access_token_expiration": tokens.access.expiration,
|
||||
"refresh_token_expiration": tokens.refresh.expiration,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert new records: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: clean these up on timer.
|
||||
func (p *Platform) createNewOAuthRequest(ctx context.Context, acctID int64) (oauth2Request, error) {
|
||||
req := oauth2Request{
|
||||
acctID: acctID,
|
||||
state: uuid.New(),
|
||||
expiration: time.Now().UTC().Add(10 * time.Minute),
|
||||
pkceCode: newPKCECode(),
|
||||
}
|
||||
|
||||
_, err := p.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO etsy_oauth_requests (
|
||||
account_id,
|
||||
state,
|
||||
code_verifier,
|
||||
expiration
|
||||
)
|
||||
VALUES (
|
||||
@account_id,
|
||||
@state,
|
||||
@code_verifier,
|
||||
@expiration
|
||||
)
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"account_id": req.acctID,
|
||||
"state": req.state[:],
|
||||
"code_verifier": req.pkceCode.verifier[:],
|
||||
"expiration": req.expiration,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return oauth2Request{}, fmt.Errorf("failed to insert record: %w", err)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (p *Platform) getOauthRequest(ctx context.Context, state uuid.UUID) (req oauth2Request, ok bool, err error) {
|
||||
stateBytes := [16]byte(state)
|
||||
|
||||
rows, err := p.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
account_id,
|
||||
code_verifier,
|
||||
expiration
|
||||
FROM
|
||||
etsy_oauth_requests
|
||||
WHERE
|
||||
state = @state
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"state": stateBytes[:],
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return oauth2Request{}, false, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
Account_ID int64
|
||||
Code_Verifier []byte
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return oauth2Request{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if l := len(r.Code_Verifier); l != 32 {
|
||||
return oauth2Request{}, false, fmt.Errorf("code_verifier of unexpected length found: expected = 32, found = %d", l)
|
||||
}
|
||||
|
||||
var verifier [32]byte
|
||||
copy(verifier[:], r.Code_Verifier)
|
||||
|
||||
return oauth2Request{
|
||||
acctID: r.Account_ID,
|
||||
state: state,
|
||||
expiration: r.Expiration.UTC(),
|
||||
pkceCode: pkceCode{
|
||||
verifier: verifier,
|
||||
challenge: generateCodeChallenge(verifier),
|
||||
},
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func (p *Platform) InvalidateState(ctx context.Context, state string) error {
|
||||
stateUUID, err := uuid.Parse(state)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.deleteOauthRequest(ctx, stateUUID)
|
||||
}
|
||||
|
||||
func (p *Platform) deleteOauthRequest(ctx context.Context, state uuid.UUID) error {
|
||||
_, err := p.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
DELETE FROM etsy_oauth_requests
|
||||
WHERE state = @state
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPKCECode() pkceCode {
|
||||
var code pkceCode
|
||||
part1 := [16]byte(uuid.New())
|
||||
part2 := [16]byte(uuid.New())
|
||||
copy(code.verifier[0:16], part1[:])
|
||||
copy(code.verifier[16:32], part2[:])
|
||||
|
||||
code.challenge = generateCodeChallenge(code.verifier)
|
||||
return code
|
||||
}
|
||||
|
||||
func generateCodeChallenge(codeVerifier [32]byte) []byte {
|
||||
return generateSHA256Hash(codeVerifier[:])
|
||||
}
|
||||
|
||||
func generateSHA256Hash(b []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -31,30 +31,10 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func NewStore(ctx context.Context) (*Store, error) {
|
||||
pool, err := newPool(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{
|
||||
db: pool,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newPool(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create database client: %w", err)
|
||||
db: db,
|
||||
}
|
||||
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create a database connection: %w", err)
|
||||
}
|
||||
conn.Release()
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
|
||||
|
||||
+127
-9
@@ -7,16 +7,61 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/angelbeltran/templater"
|
||||
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
)
|
||||
|
||||
func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
||||
func NewSiteHandler(
|
||||
dir string,
|
||||
rawEvents *raw_events.Store,
|
||||
accts *accounts.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// api routes
|
||||
|
||||
mux.HandleFunc("POST /accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
email := r.FormValue("email")
|
||||
if email == "" {
|
||||
http.Error(w, "no email provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := accts.CreateAccount(ctx, email)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /log-in", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
email := r.FormValue("email")
|
||||
if email == "" {
|
||||
http.Error(w, "no email provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := accts.GetAccountByEmail(ctx, email)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// non-html routes
|
||||
|
||||
scfs := http.FileServer(http.Dir(dir + "/scripts"))
|
||||
@@ -44,6 +89,12 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
||||
// TODO: make "/site" dynamic somehow
|
||||
return path.Join(append([]string{"/site"}, strParts...)...)
|
||||
},
|
||||
"splitPath": func(p string) []string {
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
|
||||
},
|
||||
|
||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||
b, err := json.MarshalIndent(j, " ", "")
|
||||
@@ -53,6 +104,10 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
||||
return string(b)
|
||||
},
|
||||
|
||||
"parseInt64": func(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
},
|
||||
|
||||
"addInt": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
@@ -66,15 +121,23 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
||||
},
|
||||
)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
name, pathParams := getPageTemplateNameForURL(r.URL)
|
||||
b, err := tmplr.ExecutePage(
|
||||
getPageTemplateNameForURL(r.URL),
|
||||
name,
|
||||
"Request",
|
||||
r,
|
||||
// add services here
|
||||
"RawEvents",
|
||||
db.WithContext(r.Context()),
|
||||
rawEvents.WithContext(ctx),
|
||||
"URLCalc",
|
||||
newURLCalculator(r.URL),
|
||||
"PathParams",
|
||||
pathParams,
|
||||
"Accounts",
|
||||
accts.WithContext(ctx),
|
||||
"Etsy",
|
||||
etsy.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
// TODO: handle 'not found' as a 404?
|
||||
@@ -89,14 +152,69 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
func getPageTemplateNameForURL(u *url.URL) string {
|
||||
filepath := strings.TrimPrefix(strings.TrimSuffix(u.Path, ".html"), "/")
|
||||
if filepath == "" {
|
||||
// "/" maps to "/home"
|
||||
filepath = "home"
|
||||
// TODO: clean this up...
|
||||
// TODO: somehow tell what the path params are and pass them up.
|
||||
// - then consider pushing this functionality into the template library.
|
||||
//
|
||||
// getPageTemplateNameForURL eliminate any trailing .html or /, and checks for any
|
||||
// file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches.
|
||||
func getPageTemplateNameForURL(u *url.URL) (name string, params map[string]string) {
|
||||
fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/")
|
||||
if fp == "" {
|
||||
// "/" maps to "/index"
|
||||
fp = "index"
|
||||
}
|
||||
|
||||
return filepath
|
||||
fpParts := strings.Split(fp, "/")
|
||||
res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts)
|
||||
for _, combs := range res {
|
||||
const pageBodiesPrefix = "internal/site/templates/page_bodies"
|
||||
pattern := path.Join(pageBodiesPrefix, path.Join(combs...)) + ".html.tmpl"
|
||||
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
if len(matches) == 0 {
|
||||
pattern := path.Join(pageBodiesPrefix, path.Join(combs...), "index") + ".html.tmpl"
|
||||
matches, _ = filepath.Glob(pattern)
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
match := matches[0]
|
||||
name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), pageBodiesPrefix+"/")
|
||||
|
||||
patternParts := strings.Split(name, "/")
|
||||
params = make(map[string]string)
|
||||
for i, pp := range patternParts {
|
||||
if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") {
|
||||
params[pp[1:len(pp)-1]] = fpParts[i]
|
||||
}
|
||||
}
|
||||
|
||||
return name, params
|
||||
}
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts []string) [][]string {
|
||||
switch len(filepathParts) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return [][]string{
|
||||
[]string{filepathParts[0]},
|
||||
[]string{"{*}"},
|
||||
}
|
||||
default:
|
||||
tailCombs := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts[1:])
|
||||
|
||||
combs := make([][]string, 2*len(tailCombs))
|
||||
for i, c := range tailCombs {
|
||||
combs[i*2] = append([]string{filepathParts[0]}, c...)
|
||||
combs[i*2+1] = append([]string{"{*}"}, c...)
|
||||
}
|
||||
|
||||
return combs
|
||||
}
|
||||
}
|
||||
|
||||
type URLCalculator struct {
|
||||
|
||||
@@ -7,8 +7,14 @@
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ buildSitePath "reports" }}">
|
||||
Reports
|
||||
<a href="{{ buildSitePath "sign-up" }}">
|
||||
Sign Up
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ buildSitePath "log-in" }}">
|
||||
Log In
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{ componentBody "nav_bar" }}
|
||||
|
||||
|
||||
<h1>Account: {{ .PathParams.acctID }}</h1>
|
||||
|
||||
{{- $acctID := parseInt64 .PathParams.acctID }}
|
||||
|
||||
Email: {{ (.Accounts.GetAccount $acctID).Email }}
|
||||
|
||||
{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID }}
|
||||
{{- if $etsyUser }}
|
||||
<h3>Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}</h3>
|
||||
{{- else }}
|
||||
<h3>
|
||||
<a href="{{ .Etsy.GenerateConnectionURLForNewAccount $acctID }}">
|
||||
Link Your Etsy Store!
|
||||
</a>
|
||||
</h3>
|
||||
{{- end }}
|
||||
<h2><a href="{{$acctID}}/reports">View Reports</a></h2>
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
{{ componentBody "nav_bar" }}
|
||||
|
||||
<h1>Home</h1>
|
||||
|
||||
<h2><a href="sign-up">Sign Up!</a></h2>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ componentBody "nav_bar" }}
|
||||
|
||||
<h1>Log In</h1>
|
||||
|
||||
<form action="log-in" method="post">
|
||||
<label>
|
||||
Email:
|
||||
<input type="text" required name="email" />
|
||||
</label>
|
||||
<input type="submit" value="login" />
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ componentBody "nav_bar" }}
|
||||
|
||||
<h1>Sign Up</h1>
|
||||
|
||||
<form action="accounts" method="post">
|
||||
<label>
|
||||
Email:
|
||||
<input type="text" required name="email" />
|
||||
</label>
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
@@ -4,12 +4,22 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
)
|
||||
|
||||
func NewWebhookHandler(db *raw_events.Store) http.Handler {
|
||||
type Config struct {
|
||||
OAuthRedirectURIWithAcctIDParam string
|
||||
}
|
||||
|
||||
func NewWebhookHandler(
|
||||
db *raw_events.Store,
|
||||
platform *etsy.Platform,
|
||||
cfg Config,
|
||||
) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /test", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -44,5 +54,56 @@ func NewWebhookHandler(db *raw_events.Store) http.Handler {
|
||||
w.WriteHeader(201)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET "+cfg.OAuthRedirectURIWithAcctIDParam, func(w http.ResponseWriter, r *http.Request) {
|
||||
// get account id for the request
|
||||
|
||||
acctID, err := strconv.ParseInt(r.PathValue("acctID"), 10, 64)
|
||||
if err != nil || acctID <= 0 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
q := r.URL.Query()
|
||||
state := q.Get("state")
|
||||
|
||||
// handle failed, potentially non-consenting, request
|
||||
|
||||
if errCode := q.Get("error"); errCode != "" {
|
||||
errDesc := q.Get("error_description")
|
||||
errURI := q.Get("error_uri")
|
||||
|
||||
fmt.Printf(
|
||||
"error in obtaining an OAuth Token: error=%s, error_desc=%s, error_uri=%s, account_id=%d\n",
|
||||
errCode,
|
||||
errDesc,
|
||||
errURI,
|
||||
acctID,
|
||||
)
|
||||
|
||||
platform.InvalidateState(ctx, state)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// validate the state to prevent CSRF attacks
|
||||
|
||||
ok, err := platform.HandleNewAuthCode(ctx, acctID, state, q.Get("code"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Println("failed to handle new auth code:", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: response with a redirect to the user's account page (SUCCESS - new sign up or login)!
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acctID), http.StatusSeeOther)
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -3,18 +3,23 @@ package webhooks
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/webhooks/etsy"
|
||||
"ruben/inventory2/internal/webhooks/tiktok"
|
||||
"ruben/inventory2/internal/webhooks/wix"
|
||||
)
|
||||
|
||||
func New(db *raw_events.Store) http.Handler {
|
||||
type Config struct {
|
||||
Etsy etsy.Config
|
||||
}
|
||||
|
||||
func New(eventsDB *raw_events.Store, etsyPlatform *etsy_platform.Platform, cfg Config) http.Handler {
|
||||
wh := http.NewServeMux()
|
||||
|
||||
wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(db)))
|
||||
wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(db)))
|
||||
wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(db)))
|
||||
wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(eventsDB, etsyPlatform, cfg.Etsy)))
|
||||
wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(eventsDB)))
|
||||
wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(eventsDB)))
|
||||
|
||||
return wh
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user