diff --git a/README.md b/README.md index e9053d2..8919094 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ # 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 defining this roadmap checklist - [ ] 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 ![Application structure](./diagrams/application_structure.svg) -## Website heirarchy +## Website hierarchy - /site -## Architectural and Software Diagrams +# Architectural and Software Diagrams +## Database schema + +![Schema](./diagrams/database_schema.svg) + + +## Events ### Event Sourcing Architecture ![Event sourcing architecture](./diagrams/event_sourcing.svg) @@ -73,6 +84,26 @@ All events (or commands) will be stored in a respective event series, and all da ![Event database tables](./diagrams/event_tables.svg) -### 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!** + +![Event database tables](./diagrams/etsy/obtaining_access_token.svg) + +***TODO: create a page that will take billing information and include it in this process*** + +### Getting a new refresh token + +***TODO*** + + +### Models + +![Models](./diagrams/etsy/models.svg) + + +## All Diagrams All diagrams are stored in the [diagrams](./diagrams) directory diff --git a/database.go b/database.go new file mode 100644 index 0000000..d85c975 --- /dev/null +++ b/database.go @@ -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 +} diff --git a/database_migrations/000007_accounts.down.sql b/database_migrations/000007_accounts.down.sql new file mode 100644 index 0000000..2b201b7 --- /dev/null +++ b/database_migrations/000007_accounts.down.sql @@ -0,0 +1 @@ +DROP TABLE accounts CASCADE; diff --git a/database_migrations/000007_accounts.up.sql b/database_migrations/000007_accounts.up.sql new file mode 100644 index 0000000..e8ace34 --- /dev/null +++ b/database_migrations/000007_accounts.up.sql @@ -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); diff --git a/database_migrations/000008_etsy_access_tokens.down.sql b/database_migrations/000008_etsy_access_tokens.down.sql new file mode 100644 index 0000000..5cf2b37 --- /dev/null +++ b/database_migrations/000008_etsy_access_tokens.down.sql @@ -0,0 +1,2 @@ +DROP TABLE etsy_access_tokens; +DROP TABLE etsy_users; diff --git a/database_migrations/000008_etsy_access_tokens.up.sql b/database_migrations/000008_etsy_access_tokens.up.sql new file mode 100644 index 0000000..307becb --- /dev/null +++ b/database_migrations/000008_etsy_access_tokens.up.sql @@ -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 +); diff --git a/database_migrations/000009_etsy_oauth_requests.down.sql b/database_migrations/000009_etsy_oauth_requests.down.sql new file mode 100644 index 0000000..a669136 --- /dev/null +++ b/database_migrations/000009_etsy_oauth_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE etsy_oauth_requests CASCADE; diff --git a/database_migrations/000009_etsy_oauth_requests.up.sql b/database_migrations/000009_etsy_oauth_requests.up.sql new file mode 100644 index 0000000..5a9ab5c --- /dev/null +++ b/database_migrations/000009_etsy_oauth_requests.up.sql @@ -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); diff --git a/diagrams/database_schema.svg b/diagrams/database_schema.svg new file mode 100644 index 0000000..2a148f2 --- /dev/null +++ b/diagrams/database_schema.svg @@ -0,0 +1 @@ +accountsaccount_id:serial [PK]email:textverified:booleanetsy_access_tokensuser_id:integer [PK][FK]access_token:textrefresh_token:textaccess_token_expiration:timestamp with time zonerefresh_token_expiration:timestamp with time zoneetsy_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]etsy_usersuser_id:integer [PK]account_id:integer [FK]shop_id:integerraw_store_eventsplatform:text [PK]store_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]raw_payload:jsonbschema_migrationsversion:bigint [PK]dirty:booleantiktok_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]wix_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK] \ No newline at end of file diff --git a/diagrams/database_schema.uml b/diagrams/database_schema.uml new file mode 100644 index 0000000..4837f53 --- /dev/null +++ b/diagrams/database_schema.uml @@ -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 diff --git a/diagrams/etsy/models.svg b/diagrams/etsy/models.svg new file mode 100644 index 0000000..02143b8 --- /dev/null +++ b/diagrams/etsy/models.svg @@ -0,0 +1 @@ +UserShop \ No newline at end of file diff --git a/diagrams/etsy/models.uml b/diagrams/etsy/models.uml new file mode 100644 index 0000000..4148059 --- /dev/null +++ b/diagrams/etsy/models.uml @@ -0,0 +1,15 @@ +@startuml + + +class User + +class Shop + +User o-- Shop + +'class UserAddress + +'User o-- UserAddress + + +@enduml diff --git a/diagrams/etsy/obtaining_access_token.svg b/diagrams/etsy/obtaining_access_token.svg new file mode 100644 index 0000000..950c7a3 --- /dev/null +++ b/diagrams/etsy/obtaining_access_token.svg @@ -0,0 +1 @@ +AccountPageEtsyPageAppServerAppServerDBDBDBEtsyEtsyEtsyUserAccountPageEtsyPageAppServerDBEtsyUserUserAccountPageAccountPageEtsyPageEtsyPageAppServerAppServerDBDBEtsyEtsyAccountPageEtsyPageAppServerAppServerDBDBDBEtsyEtsyEtsyloadget link to etsy to give app accessTODO: store oauth state and codelinkclick linkredirectloadclick button to "give app access"send auth codeget oauth stateverify staterequest access codeaccess token and expiration, refresh token, and user_idget user's storesave access token, store id, etc3xx redirect to app's redirect url (account page)load \ No newline at end of file diff --git a/diagrams/etsy/obtaining_access_token.uml b/diagrams/etsy/obtaining_access_token.uml new file mode 100644 index 0000000..1602581 --- /dev/null +++ b/diagrams/etsy/obtaining_access_token.uml @@ -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 diff --git a/diagrams/event.svg b/diagrams/event.svg index f918ffa..3937b62 100644 --- a/diagrams/event.svg +++ b/diagrams/event.svg @@ -1,46 +1 @@ -platformOrderEventHTTP RequestHeadersBodyevent descriptionstoreidtimestamporder \ No newline at end of file +platformOrderEventHTTP RequestHeadersBodyevent descriptionstoreidtimestamporder \ No newline at end of file diff --git a/diagrams/event_sourcing.svg b/diagrams/event_sourcing.svg index f736795..ade949f 100644 --- a/diagrams/event_sourcing.svg +++ b/diagrams/event_sourcing.svg @@ -1,51 +1 @@ -db (postgres)viewsraw eventstriggervirtualmaterialecommerce platforms:A, B, C, ...webhookeventsavereadupdate \ No newline at end of file +db (postgres)viewsraw eventstriggervirtualmaterialecommerce platforms:A, B, C, ...webhookeventsavereadupdate \ No newline at end of file diff --git a/diagrams/event_tables.svg b/diagrams/event_tables.svg index 55297ff..c54edf8 100644 --- a/diagrams/event_tables.svg +++ b/diagrams/event_tables.svg @@ -1 +1 @@ -raw_store_eventsplatform textstore_id textevent_id textevent_timestamp timestamptzraw_payload jsonbtiktok_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]etsy_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]etc_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]Holds the parsed raw_payload ofthe raw_store_events rows whoseplatform is 'tiktok'. Theserecords are created automaticallyvia database triggers. Other tables hold similar recordscreating in the same manner.(store_id, event_id)(store_id, event_id)(store_id, event_id) \ No newline at end of file +raw_store_eventsplatform textstore_id textevent_id textevent_timestamp timestamptzraw_payload jsonbtiktok_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]etsy_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]etc_store_eventsstore_id textevent_id textevent_timestamp timestamptz[attribute: value ...]Holds the parsed raw_payload ofthe raw_store_events rows whoseplatform is 'tiktok'. Theserecords are created automaticallyvia database triggers. Other tables hold similar recordscreating in the same manner.(store_id, event_id)(store_id, event_id)(store_id, event_id) \ No newline at end of file diff --git a/go.mod b/go.mod index abffe27..b12a9fc 100644 --- a/go.mod +++ b/go.mod @@ -8,26 +8,32 @@ require github.com/jackc/pgx/v5 v5.7.6 require ( github.com/angelbeltran/templater v0.1.0 + github.com/google/uuid v1.5.0 github.com/oapi-codegen/runtime v1.1.2 ) 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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-openapi/jsonpointer v0.21.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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/yaml3 v0.0.0-20250309153720-d2182401db90 // 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/openapi-overlay v0.10.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 ) -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 +) diff --git a/go.sum b/go.sum index 89276f9..4cdbebb 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,12 @@ 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/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= diff --git a/internal/consts/errors.go b/internal/consts/errors.go new file mode 100644 index 0000000..5e2dea1 --- /dev/null +++ b/internal/consts/errors.go @@ -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") +) diff --git a/internal/domains/accounts/accounts.go b/internal/domains/accounts/accounts.go new file mode 100644 index 0000000..3785200 --- /dev/null +++ b/internal/domains/accounts/accounts.go @@ -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) +} diff --git a/internal/domains/platforms/etsy/client.go b/internal/domains/platforms/etsy/client.go new file mode 100644 index 0000000..bf168fb --- /dev/null +++ b/internal/domains/platforms/etsy/client.go @@ -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 +} diff --git a/internal/domains/platforms/etsy/etsy.go b/internal/domains/platforms/etsy/etsy.go index c2c474b..fddcbcf 100644 --- a/internal/domains/platforms/etsy/etsy.go +++ b/internal/domains/platforms/etsy/etsy.go @@ -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: .: %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 } diff --git a/internal/domains/platforms/etsy/store.go b/internal/domains/platforms/etsy/store.go new file mode 100644 index 0000000..2a684fe --- /dev/null +++ b/internal/domains/platforms/etsy/store.go @@ -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) +} diff --git a/internal/domains/raw_events/events.go b/internal/domains/raw_events/events.go index 840f382..e88ed1a 100644 --- a/internal/domains/raw_events/events.go +++ b/internal/domains/raw_events/events.go @@ -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 { diff --git a/internal/site/site.go b/internal/site/site.go index 82c9d7d..38dbfef 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -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 { diff --git a/internal/site/templates/component_bodies/nav_bar.html.tmpl b/internal/site/templates/component_bodies/nav_bar.html.tmpl index fb83940..bb4116b 100644 --- a/internal/site/templates/component_bodies/nav_bar.html.tmpl +++ b/internal/site/templates/component_bodies/nav_bar.html.tmpl @@ -7,8 +7,14 @@
  • - - Reports + + Sign Up + +
  • + +
  • + + Log In
  • diff --git a/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl b/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl new file mode 100644 index 0000000..9aa5f9d --- /dev/null +++ b/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl @@ -0,0 +1,20 @@ +{{ componentBody "nav_bar" }} + + +

    Account: {{ .PathParams.acctID }}

    + +{{- $acctID := parseInt64 .PathParams.acctID }} + +Email: {{ (.Accounts.GetAccount $acctID).Email }} + +{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID }} +{{- if $etsyUser }} +

    Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}

    +{{- else }} +

    + + Link Your Etsy Store! + +

    +{{- end }} +

    View Reports

    diff --git a/internal/site/templates/page_bodies/reports.html.tmpl b/internal/site/templates/page_bodies/accounts/{acctID}/reports.html.tmpl similarity index 100% rename from internal/site/templates/page_bodies/reports.html.tmpl rename to internal/site/templates/page_bodies/accounts/{acctID}/reports.html.tmpl diff --git a/internal/site/templates/page_bodies/home.html.tmpl b/internal/site/templates/page_bodies/index.html.tmpl similarity index 52% rename from internal/site/templates/page_bodies/home.html.tmpl rename to internal/site/templates/page_bodies/index.html.tmpl index 7cce70e..25ccdee 100644 --- a/internal/site/templates/page_bodies/home.html.tmpl +++ b/internal/site/templates/page_bodies/index.html.tmpl @@ -1,3 +1,5 @@ {{ componentBody "nav_bar" }}

    Home

    + +

    Sign Up!

    diff --git a/internal/site/templates/page_bodies/log-in.html.tmpl b/internal/site/templates/page_bodies/log-in.html.tmpl new file mode 100644 index 0000000..ecbbef9 --- /dev/null +++ b/internal/site/templates/page_bodies/log-in.html.tmpl @@ -0,0 +1,11 @@ +{{ componentBody "nav_bar" }} + +

    Log In

    + +
    + + +
    diff --git a/internal/site/templates/page_bodies/sign-up.html.tmpl b/internal/site/templates/page_bodies/sign-up.html.tmpl new file mode 100644 index 0000000..632a03c --- /dev/null +++ b/internal/site/templates/page_bodies/sign-up.html.tmpl @@ -0,0 +1,11 @@ +{{ componentBody "nav_bar" }} + +

    Sign Up

    + +
    + + +
    diff --git a/internal/webhooks/etsy/webhooks.go b/internal/webhooks/etsy/webhooks.go index 38305c9..31dca72 100644 --- a/internal/webhooks/etsy/webhooks.go +++ b/internal/webhooks/etsy/webhooks.go @@ -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 } diff --git a/internal/webhooks/webhooks.go b/internal/webhooks/webhooks.go index 0d6f4f9..fc69001 100644 --- a/internal/webhooks/webhooks.go +++ b/internal/webhooks/webhooks.go @@ -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 } diff --git a/main.go b/main.go index 6b8844e..f30258b 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,9 @@ 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 ( "context" "errors" @@ -10,9 +14,14 @@ import ( "syscall" "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/site" "ruben/inventory2/internal/webhooks" + etsy_webhooks "ruben/inventory2/internal/webhooks/etsy" ) const ( @@ -33,14 +42,14 @@ func runApp(ctx context.Context) error { // connect to the database - db, err := raw_events.NewStore(ctx) + connPool, err := newPool(ctx) 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 - srvErrCh := runServer(ctx, db) + srvErrCh := runServer(ctx, connPool) // wait for interrupt signal or unrecoverable failure, then shutdown @@ -76,10 +85,10 @@ func runApp(ctx context.Context) error { 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{ Addr: ":8082", // local - Handler: buildHTTPHandler(db), + Handler: buildHTTPHandler(connPool), } ctx, cancel := context.WithCancel(ctx) @@ -130,11 +139,26 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error { 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.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db))) - mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", db))) + mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(eventsDB, etsy, webhooks.Config{ + 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)) return mux