account page stubbed: link to etsy sign up
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
|
- [ ] Etsy (WIP)
|
||||||
|
- [ ] GET ETSY AUTH (WIP)
|
||||||
|
- [ ] move auth state stuff to database (out of cache)
|
||||||
|
- [ ] Get new access token using refresh token flow
|
||||||
|
- [ ] Get new refresh token flow
|
||||||
- [ ] Complete this design document?
|
- [ ] Complete this design document?
|
||||||
- [ ] Complete defining this roadmap checklist
|
- [ ] Complete defining this roadmap checklist
|
||||||
- [ ] Website displaying an audit of store events
|
- [ ] Website displaying an audit of store events
|
||||||
@@ -51,13 +56,19 @@ All events (or commands) will be stored in a respective event series, and all da
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Website heirarchy
|
## Website hierarchy
|
||||||
|
|
||||||
- /site
|
- /site
|
||||||
|
|
||||||
|
|
||||||
## Architectural and Software Diagrams
|
# Architectural and Software Diagrams
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
### Event Sourcing Architecture
|
### Event Sourcing Architecture
|
||||||

|

|
||||||
@@ -73,6 +84,26 @@ All events (or commands) will be stored in a respective event series, and all da
|
|||||||

|

|
||||||
|
|
||||||
|
|
||||||
### All Diagrams
|
## Platform: Etsy
|
||||||
|
|
||||||
|
### Signing up
|
||||||
|
|
||||||
|
**TODO: need an account page that can create accounts ahead of time - force users to create an account first!**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
***TODO: create a page that will take billing information and include it in this process***
|
||||||
|
|
||||||
|
### Getting a new refresh token
|
||||||
|
|
||||||
|
***TODO***
|
||||||
|
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## All Diagrams
|
||||||
|
|
||||||
All diagrams are stored in the [diagrams](./diagrams) directory
|
All diagrams are stored in the [diagrams](./diagrams) directory
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE accounts CASCADE;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE accounts (
|
||||||
|
account_id SERIAL NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
PRIMARY KEY (account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ON accounts (email);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE etsy_access_tokens;
|
||||||
|
DROP TABLE etsy_users;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE etsy_users (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
store_id INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE etsy_access_tokens (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL,
|
||||||
|
access_token_expiration TIMESTAMPTZ NOT NULL,
|
||||||
|
refresh_token_expiration TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES etsy_users
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE etsy_oauth_requests CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE etsy_oauth_requests (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
state BYTEA NOT NULL,
|
||||||
|
code_verifier BYTEA NOT NULL,
|
||||||
|
expiration TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (state),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ON etsy_oauth_requests (state);
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,94 @@
|
|||||||
|
@startuml
|
||||||
|
hide circle
|
||||||
|
skinparam linetype ortho
|
||||||
|
|
||||||
|
entity "**accounts**" {
|
||||||
|
+ ""account_id"": //serial [PK]//
|
||||||
|
--
|
||||||
|
*""email"": //text //
|
||||||
|
*""verified"": //boolean //
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**etsy_access_tokens**" {
|
||||||
|
+ ""user_id"": //integer [PK][FK]//
|
||||||
|
--
|
||||||
|
*""access_token"": //text //
|
||||||
|
*""refresh_token"": //text //
|
||||||
|
*""access_token_expiration"": //timestamp with time zone //
|
||||||
|
*""refresh_token_expiration"": //timestamp with time zone //
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**etsy_store_events**" {
|
||||||
|
+ ""store_id"": //text [PK][FK]//
|
||||||
|
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
|
||||||
|
+ ""event_id"": //text [PK][FK]//
|
||||||
|
--
|
||||||
|
*""platform"": //text [FK]//
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**etsy_users**" {
|
||||||
|
+ ""user_id"": //integer [PK]//
|
||||||
|
--
|
||||||
|
*""account_id"": //integer [FK]//
|
||||||
|
*""shop_id"": //integer //
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**raw_store_events**" {
|
||||||
|
+ ""platform"": //text [PK]//
|
||||||
|
+ ""store_id"": //text [PK]//
|
||||||
|
+ ""event_timestamp"": //timestamp with time zone [PK]//
|
||||||
|
+ ""event_id"": //text [PK]//
|
||||||
|
--
|
||||||
|
*""raw_payload"": //jsonb //
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**schema_migrations**" {
|
||||||
|
+ ""version"": //bigint [PK]//
|
||||||
|
--
|
||||||
|
*""dirty"": //boolean //
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**tiktok_store_events**" {
|
||||||
|
+ ""store_id"": //text [PK][FK]//
|
||||||
|
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
|
||||||
|
+ ""event_id"": //text [PK][FK]//
|
||||||
|
--
|
||||||
|
*""platform"": //text [FK]//
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "**wix_store_events**" {
|
||||||
|
+ ""store_id"": //text [PK][FK]//
|
||||||
|
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
|
||||||
|
+ ""event_id"": //text [PK][FK]//
|
||||||
|
--
|
||||||
|
*""platform"": //text [FK]//
|
||||||
|
}
|
||||||
|
|
||||||
|
"**etsy_access_tokens**" ||-|| "**etsy_users**"
|
||||||
|
|
||||||
|
"**etsy_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**etsy_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**etsy_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**etsy_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**etsy_users**" }-- "**accounts**"
|
||||||
|
|
||||||
|
"**tiktok_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**tiktok_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**tiktok_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**tiktok_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**wix_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**wix_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**wix_store_events**" }-- "**raw_store_events**"
|
||||||
|
|
||||||
|
"**wix_store_events**" }-- "**raw_store_events**"
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="CLASS" height="170px" preserveAspectRatio="none" style="width:79px;height:170px;background:#FFFFFF;" version="1.1" viewBox="0 0 79 170" width="79px" zoomAndPan="magnify"><defs/><g><!--class User--><g class="entity" data-entity="User" data-source-line="3" data-uid="ent0002" id="entity_User"><rect fill="#F1F1F1" height="48" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="62.6179" x="8" y="7"/><ellipse cx="23" cy="23" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1;"/><path d="M25.9688,28.6406 Q25.3906,28.9375 24.75,29.0781 Q24.1094,29.2344 23.4063,29.2344 Q20.9063,29.2344 19.5781,27.5938 Q18.2656,25.9375 18.2656,22.8125 Q18.2656,19.6875 19.5781,18.0313 Q20.9063,16.375 23.4063,16.375 Q24.1094,16.375 24.75,16.5313 Q25.4063,16.6875 25.9688,16.9844 L25.9688,19.7031 Q25.3438,19.125 24.75,18.8594 Q24.1563,18.5781 23.5313,18.5781 Q22.1875,18.5781 21.5,19.6563 Q20.8125,20.7188 20.8125,22.8125 Q20.8125,24.9063 21.5,25.9844 Q22.1875,27.0469 23.5313,27.0469 Q24.1563,27.0469 24.75,26.7813 Q25.3438,26.5 25.9688,25.9219 L25.9688,28.6406 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="30.6179" x="37" y="28.432">User</text><line style="stroke:#181818;stroke-width:0.5;" x1="9" x2="69.6179" y1="39" y2="39"/><line style="stroke:#181818;stroke-width:0.5;" x1="9" x2="69.6179" y1="47" y2="47"/></g><!--class Shop--><g class="entity" data-entity="Shop" data-source-line="5" data-uid="ent0003" id="entity_Shop"><rect fill="#F1F1F1" height="48" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="65.4179" x="7" y="115"/><ellipse cx="22" cy="131" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1;"/><path d="M24.9688,136.6406 Q24.3906,136.9375 23.75,137.0781 Q23.1094,137.2344 22.4063,137.2344 Q19.9063,137.2344 18.5781,135.5938 Q17.2656,133.9375 17.2656,130.8125 Q17.2656,127.6875 18.5781,126.0313 Q19.9063,124.375 22.4063,124.375 Q23.1094,124.375 23.75,124.5313 Q24.4063,124.6875 24.9688,124.9844 L24.9688,127.7031 Q24.3438,127.125 23.75,126.8594 Q23.1563,126.5781 22.5313,126.5781 Q21.1875,126.5781 20.5,127.6563 Q19.8125,128.7188 19.8125,130.8125 Q19.8125,132.9063 20.5,133.9844 Q21.1875,135.0469 22.5313,135.0469 Q23.1563,135.0469 23.75,134.7813 Q24.3438,134.5 24.9688,133.9219 L24.9688,136.6406 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="33.4179" x="36" y="136.432">Shop</text><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="71.4179" y1="147" y2="147"/><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="71.4179" y1="155" y2="155"/></g><!--reverse link User to Shop--><g class="link" data-entity-1="User" data-entity-2="Shop" data-source-line="7" data-uid="lnk4" id="link_User_Shop"><path codeLine="7" d="M39.5,67 C39.5,84.66 39.5,96.94 39.5,114.68" fill="none" id="User-backto-Shop" style="stroke:#181818;stroke-width:1;"/><polygon fill="none" points="39.5,55,35.5,61,39.5,67,43.5,61,39.5,55" style="stroke:#181818;stroke-width:1;"/></g><!--SRC=[Iyv9B2vM22rEBUBYIWQpWpFo2xYuG28A-RgwKAvwCboTOqfAKMfnCL0ChWP56000]--></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
|
||||||
|
class User
|
||||||
|
|
||||||
|
class Shop
|
||||||
|
|
||||||
|
User o-- Shop
|
||||||
|
|
||||||
|
'class UserAddress
|
||||||
|
|
||||||
|
'User o-- UserAddress
|
||||||
|
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
|
||||||
|
actor User
|
||||||
|
boundary AccountPage
|
||||||
|
boundary EtsyPage
|
||||||
|
control AppServer
|
||||||
|
database DB
|
||||||
|
control Etsy
|
||||||
|
|
||||||
|
|
||||||
|
User -> AccountPage ++ : load
|
||||||
|
AccountPage -> AppServer ++ : get link to etsy to give app access
|
||||||
|
AppServer -> DB ++ : TODO: store oauth state and code
|
||||||
|
AppServer <-- DB --
|
||||||
|
AccountPage <-- AppServer -- : link
|
||||||
|
User -> AccountPage : click link
|
||||||
|
User <-- AccountPage -- : redirect
|
||||||
|
|
||||||
|
User -> EtsyPage ++ : load
|
||||||
|
User <-- EtsyPage
|
||||||
|
User -> EtsyPage : click button to "give app access"
|
||||||
|
EtsyPage -> Etsy ++
|
||||||
|
AppServer <- Etsy ++ : send auth code
|
||||||
|
AppServer -> DB ++ : get oauth state
|
||||||
|
AppServer <-- DB --
|
||||||
|
AppServer -> AppServer : verify state
|
||||||
|
AppServer -> Etsy ++ : request access code
|
||||||
|
AppServer <-- Etsy -- : access token and expiration, refresh token, and user_id
|
||||||
|
AppServer -> Etsy ++ : get user's store
|
||||||
|
AppServer <-- Etsy --
|
||||||
|
AppServer -> DB ++ : save access token, store id, etc
|
||||||
|
AppServer <-- DB --
|
||||||
|
AppServer --> Etsy --
|
||||||
|
EtsyPage <-- Etsy : 3xx redirect to app's redirect url (account page)
|
||||||
|
User <-- EtsyPage --
|
||||||
|
|
||||||
|
User -> AccountPage : load
|
||||||
|
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
@@ -8,26 +8,32 @@ require github.com/jackc/pgx/v5 v5.7.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/angelbeltran/templater v0.1.0
|
github.com/angelbeltran/templater v0.1.0
|
||||||
|
github.com/google/uuid v1.5.0
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e // indirect
|
||||||
|
github.com/alecthomas/kingpin v2.2.6+incompatible // indirect
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||||
|
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/google/uuid v1.5.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
@@ -42,4 +48,7 @@ require (
|
|||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
tool (
|
||||||
|
github.com/achiku/planter
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e h1:21CBUAcoqBaU42GVG3xIC1ckeOWtZbqSA4NxAyei9CU=
|
||||||
|
github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e/go.mod h1:ajWKEzGTqo7y5QInkqZJIU2kPwxfmJmKPMkWBiAv2y4=
|
||||||
|
github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI=
|
||||||
|
github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs=
|
||||||
|
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
@@ -61,6 +69,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
@@ -90,6 +100,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
|||||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
|||||||
@@ -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
|
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 (
|
"github.com/google/uuid"
|
||||||
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
etsyAPISharedSecret = "jaaw0tyizf"
|
|
||||||
|
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetEtsyAPIKeystring() string {
|
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
|
||||||
return etsyAPIKeystring
|
|
||||||
|
// 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 {
|
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
|
||||||
return etsyAPISharedSecret
|
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) {
|
func NewStore(db *pgxpool.Pool) *Store {
|
||||||
pool, err := newPool(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Store{
|
return &Store{
|
||||||
db: pool,
|
db: db,
|
||||||
}, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
|
||||||
|
|||||||
@@ -7,16 +7,61 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/angelbeltran/templater"
|
"github.com/angelbeltran/templater"
|
||||||
|
|
||||||
|
"ruben/inventory2/internal/domains/accounts"
|
||||||
|
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||||
"ruben/inventory2/internal/domains/raw_events"
|
"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()
|
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
|
// non-html routes
|
||||||
|
|
||||||
scfs := http.FileServer(http.Dir(dir + "/scripts"))
|
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
|
// TODO: make "/site" dynamic somehow
|
||||||
return path.Join(append([]string{"/site"}, strParts...)...)
|
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 {
|
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||||
b, err := json.MarshalIndent(j, " ", "")
|
b, err := json.MarshalIndent(j, " ", "")
|
||||||
@@ -53,6 +104,10 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
|||||||
return string(b)
|
return string(b)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"parseInt64": func(s string) (int64, error) {
|
||||||
|
return strconv.ParseInt(s, 10, 64)
|
||||||
|
},
|
||||||
|
|
||||||
"addInt": func(a, b int) int {
|
"addInt": func(a, b int) int {
|
||||||
return a + b
|
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) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
name, pathParams := getPageTemplateNameForURL(r.URL)
|
||||||
b, err := tmplr.ExecutePage(
|
b, err := tmplr.ExecutePage(
|
||||||
getPageTemplateNameForURL(r.URL),
|
name,
|
||||||
"Request",
|
"Request",
|
||||||
r,
|
r,
|
||||||
// add services here
|
// add services here
|
||||||
"RawEvents",
|
"RawEvents",
|
||||||
db.WithContext(r.Context()),
|
rawEvents.WithContext(ctx),
|
||||||
"URLCalc",
|
"URLCalc",
|
||||||
newURLCalculator(r.URL),
|
newURLCalculator(r.URL),
|
||||||
|
"PathParams",
|
||||||
|
pathParams,
|
||||||
|
"Accounts",
|
||||||
|
accts.WithContext(ctx),
|
||||||
|
"Etsy",
|
||||||
|
etsy.WithContext(ctx),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: handle 'not found' as a 404?
|
// TODO: handle 'not found' as a 404?
|
||||||
@@ -89,14 +152,69 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
|
|||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPageTemplateNameForURL(u *url.URL) string {
|
// TODO: clean this up...
|
||||||
filepath := strings.TrimPrefix(strings.TrimSuffix(u.Path, ".html"), "/")
|
// TODO: somehow tell what the path params are and pass them up.
|
||||||
if filepath == "" {
|
// - then consider pushing this functionality into the template library.
|
||||||
// "/" maps to "/home"
|
//
|
||||||
filepath = "home"
|
// 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 {
|
type URLCalculator struct {
|
||||||
|
|||||||
@@ -7,8 +7,14 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ buildSitePath "reports" }}">
|
<a href="{{ buildSitePath "sign-up" }}">
|
||||||
Reports
|
Sign Up
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="{{ buildSitePath "log-in" }}">
|
||||||
|
Log In
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
{{ componentBody "nav_bar" }}
|
{{ componentBody "nav_bar" }}
|
||||||
|
|
||||||
<h1>Home</h1>
|
<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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ruben/inventory2/internal/domains/platforms/etsy"
|
||||||
"ruben/inventory2/internal/domains/raw_events"
|
"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 := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("POST /test", func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ package webhooks
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||||
"ruben/inventory2/internal/domains/raw_events"
|
"ruben/inventory2/internal/domains/raw_events"
|
||||||
"ruben/inventory2/internal/webhooks/etsy"
|
"ruben/inventory2/internal/webhooks/etsy"
|
||||||
"ruben/inventory2/internal/webhooks/tiktok"
|
"ruben/inventory2/internal/webhooks/tiktok"
|
||||||
"ruben/inventory2/internal/webhooks/wix"
|
"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 := http.NewServeMux()
|
||||||
|
|
||||||
wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(db)))
|
wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(eventsDB, etsyPlatform, cfg.Etsy)))
|
||||||
wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(db)))
|
wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(eventsDB)))
|
||||||
wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(db)))
|
wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(eventsDB)))
|
||||||
|
|
||||||
return wh
|
return wh
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
//go:generate planter postgres://planter:planter@localhost:5432/inventory_2?sslmode=disable -o diagrams/database_schema.uml
|
||||||
|
//go:generate plantuml diagrams/*.uml -tsvg
|
||||||
|
//go:generate plantuml diagrams/etsy/*.uml -tsvg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,9 +14,14 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"ruben/inventory2/internal/domains/accounts"
|
||||||
|
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||||
"ruben/inventory2/internal/domains/raw_events"
|
"ruben/inventory2/internal/domains/raw_events"
|
||||||
"ruben/inventory2/internal/site"
|
"ruben/inventory2/internal/site"
|
||||||
"ruben/inventory2/internal/webhooks"
|
"ruben/inventory2/internal/webhooks"
|
||||||
|
etsy_webhooks "ruben/inventory2/internal/webhooks/etsy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,14 +42,14 @@ func runApp(ctx context.Context) error {
|
|||||||
|
|
||||||
// connect to the database
|
// connect to the database
|
||||||
|
|
||||||
db, err := raw_events.NewStore(ctx)
|
connPool, err := newPool(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize the raw event store: %w", err)
|
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// start http server
|
// start http server
|
||||||
|
|
||||||
srvErrCh := runServer(ctx, db)
|
srvErrCh := runServer(ctx, connPool)
|
||||||
|
|
||||||
// wait for interrupt signal or unrecoverable failure, then shutdown
|
// wait for interrupt signal or unrecoverable failure, then shutdown
|
||||||
|
|
||||||
@@ -76,10 +85,10 @@ func runApp(ctx context.Context) error {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
|
func runServer(ctx context.Context, connPool *pgxpool.Pool) <-chan error {
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":8082", // local
|
Addr: ":8082", // local
|
||||||
Handler: buildHTTPHandler(db),
|
Handler: buildHTTPHandler(connPool),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
@@ -130,11 +139,26 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
|
|||||||
return errCh
|
return errCh
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildHTTPHandler(db *raw_events.Store) http.Handler {
|
func buildHTTPHandler(connPool *pgxpool.Pool) http.Handler {
|
||||||
|
eventsDB := raw_events.NewStore(connPool)
|
||||||
|
etsy := etsy_platform.NewPlatform(
|
||||||
|
func(acctID int64) string {
|
||||||
|
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
|
||||||
|
},
|
||||||
|
etsyAPIKeystring,
|
||||||
|
etsyAPISharedSecret,
|
||||||
|
connPool,
|
||||||
|
)
|
||||||
|
accts := accounts.NewStore(connPool)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db)))
|
mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(eventsDB, etsy, webhooks.Config{
|
||||||
mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", db)))
|
Etsy: etsy_webhooks.Config{
|
||||||
|
OAuthRedirectURIWithAcctIDParam: "/oauth/account/{acctID}/auth_code",
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", eventsDB, accts, etsy)))
|
||||||
mux.Handle("/", http.RedirectHandler("/site", http.StatusPermanentRedirect))
|
mux.Handle("/", http.RedirectHandler("/site", http.StatusPermanentRedirect))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
|
|||||||