From 6acc2936e3ac194ba3d173c2764d7fc3949fe0a2 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Fri, 22 May 2020 16:30:05 +1000 Subject: [PATCH] Reduce bundle size (#469) * Rename handlers to api * Fix imports * Fix test * Abstract * Fix warning * wip * Split session * Pass db * Fix test * Fix test * Remove payment * Fix state * Fix flow * Check password when changing email * Add test methods * Fix timestamp * Document * Remove clutter * Redirect to login * Fix * Fix --- .eslintrc | 1 + CHANGELOG.md | 8 +- browser/src/global.d.ts | 1 + go.mod | 23 +- go.sum | 96 +-- jslib/src/services/index.ts | 5 +- jslib/src/services/payment.ts | 70 --- jslib/src/services/users.ts | 6 +- pkg/assert/assert.go | 8 + pkg/server/.gitignore | 8 - pkg/server/api/auth.go | 181 ++++++ pkg/server/{handlers => api}/auth_test.go | 82 ++- pkg/server/{handlers => api}/health.go | 2 +- pkg/server/{handlers => api}/health_test.go | 2 +- pkg/server/api/helpers.go | 85 +++ .../Form.scss => pkg/server/api/main_test.go | 22 +- pkg/server/{handlers => api}/notes.go | 27 +- pkg/server/{handlers => api}/notes_test.go | 6 +- pkg/server/api/routes.go | 116 ++++ pkg/server/api/routes_test.go | 161 +++++ pkg/server/{handlers => api}/testutils.go | 5 +- pkg/server/{handlers => api}/user.go | 89 +-- pkg/server/{handlers => api}/user_test.go | 74 ++- pkg/server/{handlers => api}/v3_auth.go | 41 +- pkg/server/{handlers => api}/v3_auth_test.go | 25 +- pkg/server/{handlers => api}/v3_books.go | 39 +- pkg/server/{handlers => api}/v3_books_test.go | 14 +- pkg/server/{handlers => api}/v3_notes.go | 37 +- pkg/server/{handlers => api}/v3_notes_test.go | 8 +- pkg/server/{handlers => api}/v3_sync.go | 15 +- pkg/server/{handlers => api}/v3_sync_test.go | 2 +- pkg/server/app/app.go | 12 +- pkg/server/app/books_test.go | 6 +- pkg/server/app/helpers_test.go | 2 +- pkg/server/app/main_test.go | 2 +- pkg/server/app/notes_test.go | 6 +- pkg/server/app/subscriptions.go | 80 --- pkg/server/app/testutils.go | 14 +- pkg/server/app/users.go | 4 +- pkg/server/app/users_test.go | 2 +- pkg/server/handlers/auth.go | 282 ++++----- pkg/server/handlers/helpers.go | 190 +++--- .../{routes_test.go => helpers_test.go} | 216 +++---- pkg/server/handlers/limit.go | 4 +- pkg/server/handlers/logging.go | 42 ++ pkg/server/handlers/main_test.go | 2 +- pkg/server/handlers/routes.go | 390 ------------ pkg/server/handlers/semver.go | 62 -- pkg/server/handlers/subscription.go | 561 ------------------ pkg/server/job/remind/inactive_test.go | 4 +- pkg/server/job/remind/main_test.go | 2 +- pkg/server/main.go | 16 +- pkg/server/operations/main_test.go | 2 +- pkg/server/operations/notes_test.go | 4 +- pkg/server/permissions/permissions_test.go | 4 +- pkg/server/session/session.go | 23 + pkg/server/session/session_test.go | 67 +++ pkg/server/testutils/main.go | 43 +- pkg/server/tmpl/app_test.go | 2 +- pkg/server/tmpl/main_test.go | 2 +- pkg/server/token/main_test.go | 2 +- pkg/server/token/token_test.go | 2 +- pkg/server/web/handlers.go | 2 +- pkg/server/web/main_test.go | 2 +- scripts/web/build-prod.sh | 1 + scripts/web/build.sh | 4 +- scripts/web/dev.sh | 2 +- scripts/web/webpack-dev.sh | 3 +- web/declrations.d.ts | 2 +- web/package-lock.json | 8 - web/package.json | 1 - web/src/components/App/index.tsx | 5 +- web/src/components/Common/Note/Footer.tsx | 12 +- web/src/components/Common/PayWall.tsx | 9 +- .../components/Common/PaymentInput/Card.tsx | 94 --- .../Common/PaymentInput/Country.tsx | 58 -- .../Common/PaymentInput/CountrySelect.scss | 50 -- .../Common/PaymentInput/CountrySelect.tsx | 63 -- .../Common/PaymentInput/NameOnCard.tsx | 60 -- .../Common/PaymentInput/PaymentInput.scss | 51 -- .../components/Header/SubscriptionHeader.scss | 59 -- .../components/Header/SubscriptionHeader.tsx | 54 -- web/src/components/Settings/About/index.tsx | 2 +- .../Settings/Account/EmailModal.tsx | 3 +- .../Settings/Billing/CancelPlanModal.tsx | 108 ---- .../Billing/PaymentMethodModal/Form.tsx | 151 ----- .../Billing/PaymentMethodModal/index.tsx | 91 --- .../PaymentSection/PaymentMethodRow.tsx | 66 --- .../Billing/PaymentSection/Placeholder.scss | 49 -- .../Billing/PaymentSection/Placeholder.tsx | 51 -- .../Settings/Billing/PaymentSection/index.tsx | 52 -- .../Billing/PlanSection/CancelRow.tsx | 49 -- .../Billing/PlanSection/Placeholder.scss | 41 -- .../Billing/PlanSection/Placeholder.tsx | 60 -- .../Settings/Billing/PlanSection/PlanRow.scss | 52 -- .../Settings/Billing/PlanSection/PlanRow.tsx | 91 --- .../Billing/PlanSection/ReactivateRow.tsx | 79 --- .../Settings/Billing/PlanSection/index.tsx | 64 -- web/src/components/Settings/Billing/index.tsx | 176 ------ web/src/components/Settings/Sidebar.tsx | 16 +- web/src/components/Settings/index.tsx | 4 - .../Subscription/Checkout/Form.scss | 72 --- .../components/Subscription/Checkout/Form.tsx | 194 ------ .../Subscription/Checkout/PaymentSummary.tsx | 36 -- .../Subscription/Checkout/Price.tsx | 28 - .../Subscription/Checkout/ScheduleSummary.tsx | 43 -- .../Subscription/Checkout/Sidebar.scss | 131 ---- .../Subscription/Checkout/Sidebar.tsx | 113 ---- .../Subscription/Checkout/index.tsx | 58 -- web/src/components/Subscription/Footer.tsx | 35 -- .../components/Subscription/Subscription.scss | 101 ---- web/src/hocs/guestOnly.tsx | 6 + web/src/libs/paths.ts | 7 - web/src/routes.tsx | 12 - web/src/store/auth/actions.ts | 109 +--- web/src/store/auth/reducers.ts | 114 +--- web/src/store/auth/type.ts | 62 +- web/webpack/dev.config.js | 4 +- web/webpack/plugins.js | 14 +- web/webpack/prod.config.js | 4 +- 120 files changed, 1418 insertions(+), 4779 deletions(-) delete mode 100644 jslib/src/services/payment.ts create mode 100644 pkg/server/api/auth.go rename pkg/server/{handlers => api}/auth_test.go (84%) rename pkg/server/{handlers => api}/health.go (98%) rename pkg/server/{handlers => api}/health_test.go (98%) create mode 100644 pkg/server/api/helpers.go rename web/src/components/Settings/Billing/PaymentMethodModal/Form.scss => pkg/server/api/main_test.go (77%) rename pkg/server/{handlers => api}/notes.go (89%) rename pkg/server/{handlers => api}/notes_test.go (99%) create mode 100644 pkg/server/api/routes.go create mode 100644 pkg/server/api/routes_test.go rename pkg/server/{handlers => api}/testutils.go (96%) rename pkg/server/{handlers => api}/user.go (75%) rename pkg/server/{handlers => api}/user_test.go (91%) rename pkg/server/{handlers => api}/v3_auth.go (84%) rename pkg/server/{handlers => api}/v3_auth_test.go (96%) rename pkg/server/{handlers => api}/v3_books.go (81%) rename pkg/server/{handlers => api}/v3_books_test.go (98%) rename pkg/server/{handlers => api}/v3_notes.go (78%) rename pkg/server/{handlers => api}/v3_notes_test.go (98%) rename pkg/server/{handlers => api}/v3_sync.go (93%) rename pkg/server/{handlers => api}/v3_sync_test.go (99%) delete mode 100644 pkg/server/app/subscriptions.go rename pkg/server/handlers/{routes_test.go => helpers_test.go} (79%) create mode 100644 pkg/server/handlers/logging.go delete mode 100644 pkg/server/handlers/routes.go delete mode 100644 pkg/server/handlers/semver.go delete mode 100644 pkg/server/handlers/subscription.go create mode 100644 pkg/server/session/session.go create mode 100644 pkg/server/session/session_test.go delete mode 100644 web/src/components/Common/PaymentInput/Card.tsx delete mode 100644 web/src/components/Common/PaymentInput/Country.tsx delete mode 100644 web/src/components/Common/PaymentInput/CountrySelect.scss delete mode 100644 web/src/components/Common/PaymentInput/CountrySelect.tsx delete mode 100644 web/src/components/Common/PaymentInput/NameOnCard.tsx delete mode 100644 web/src/components/Common/PaymentInput/PaymentInput.scss delete mode 100644 web/src/components/Header/SubscriptionHeader.scss delete mode 100644 web/src/components/Header/SubscriptionHeader.tsx delete mode 100644 web/src/components/Settings/Billing/CancelPlanModal.tsx delete mode 100644 web/src/components/Settings/Billing/PaymentMethodModal/Form.tsx delete mode 100644 web/src/components/Settings/Billing/PaymentMethodModal/index.tsx delete mode 100644 web/src/components/Settings/Billing/PaymentSection/PaymentMethodRow.tsx delete mode 100644 web/src/components/Settings/Billing/PaymentSection/Placeholder.scss delete mode 100644 web/src/components/Settings/Billing/PaymentSection/Placeholder.tsx delete mode 100644 web/src/components/Settings/Billing/PaymentSection/index.tsx delete mode 100644 web/src/components/Settings/Billing/PlanSection/CancelRow.tsx delete mode 100644 web/src/components/Settings/Billing/PlanSection/Placeholder.scss delete mode 100644 web/src/components/Settings/Billing/PlanSection/Placeholder.tsx delete mode 100644 web/src/components/Settings/Billing/PlanSection/PlanRow.scss delete mode 100644 web/src/components/Settings/Billing/PlanSection/PlanRow.tsx delete mode 100644 web/src/components/Settings/Billing/PlanSection/ReactivateRow.tsx delete mode 100644 web/src/components/Settings/Billing/PlanSection/index.tsx delete mode 100644 web/src/components/Settings/Billing/index.tsx delete mode 100644 web/src/components/Subscription/Checkout/Form.scss delete mode 100644 web/src/components/Subscription/Checkout/Form.tsx delete mode 100644 web/src/components/Subscription/Checkout/PaymentSummary.tsx delete mode 100644 web/src/components/Subscription/Checkout/Price.tsx delete mode 100644 web/src/components/Subscription/Checkout/ScheduleSummary.tsx delete mode 100644 web/src/components/Subscription/Checkout/Sidebar.scss delete mode 100644 web/src/components/Subscription/Checkout/Sidebar.tsx delete mode 100644 web/src/components/Subscription/Checkout/index.tsx delete mode 100644 web/src/components/Subscription/Footer.tsx delete mode 100644 web/src/components/Subscription/Subscription.scss diff --git a/.eslintrc b/.eslintrc index 4c0427d6..1e16f097 100644 --- a/.eslintrc +++ b/.eslintrc @@ -60,6 +60,7 @@ "__STRIPE_PUBLIC_KEY__": true, "__ROOT_URL__": true, "__CDN_URL__": true, + "__STANDALONE__": true, "socket": true, "webpackIsomorphicTools": true, "StripeCheckout": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d302062..0ce29c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,13 @@ The following log documents the history of the server project. ### Unreleased -None +#### Removed + +- Remove unnecessary payment logic + +#### Fixed + +- Fix timestamp in the note content view ### 1.0.3 2020-05-03 diff --git a/browser/src/global.d.ts b/browser/src/global.d.ts index d0fe4ad7..0bf0f41c 100644 --- a/browser/src/global.d.ts +++ b/browser/src/global.d.ts @@ -20,5 +20,6 @@ // defined by webpack-define-plugin declare let __API_ENDPOINT__: string; +declare let __STANDALONE__: string; declare let __WEB_URL__: string; declare let __VERSION__: string; diff --git a/go.mod b/go.mod index 086444d2..23714f4f 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,11 @@ module github.com/dnote/dnote go 1.13 require ( - cloud.google.com/go v0.37.4 // indirect github.com/PuerkitoBio/goquery v1.5.1 // indirect + github.com/andybalholm/cascadia v1.2.0 // indirect github.com/aymerick/douceur v0.2.0 github.com/dnote/actions v0.2.0 github.com/dnote/color v1.7.0 - github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff // indirect - github.com/gobuffalo/envy v1.9.0 // indirect - github.com/gobuffalo/packr v1.30.1 // indirect github.com/gobuffalo/packr/v2 v2.8.0 github.com/google/go-cmp v0.4.0 github.com/google/go-github v17.0.0+incompatible @@ -20,28 +17,24 @@ require ( github.com/gorilla/mux v1.7.4 github.com/jinzhu/gorm v1.9.12 github.com/joho/godotenv v1.3.0 - github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 // indirect - github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d github.com/karrick/godirwalk v1.15.6 // indirect - github.com/lib/pq v1.4.0 + github.com/lib/pq v1.5.2 github.com/mattn/go-colorable v0.1.6 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible github.com/pkg/errors v0.9.1 github.com/radovskyb/watcher v1.0.7 github.com/robfig/cron v1.2.0 - github.com/rubenv/sql-migrate v0.0.0-20200423171638-eef9d3b68125 + github.com/rubenv/sql-migrate v0.0.0-20200429072036-ae26b214fa43 github.com/sergi/go-diff v1.1.0 - github.com/sirupsen/logrus v1.5.0 // indirect + github.com/sirupsen/logrus v1.6.0 // indirect github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 // indirect - github.com/stripe/stripe-go v70.15.0+incompatible - golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 - golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect + golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 + golang.org/x/net v0.0.0-20200513185701-a91f0712d120 // indirect golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a // indirect - golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect + golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c // indirect golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 - golang.org/x/tools v0.0.0-20200425043458-8463f397d07c // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df - gopkg.in/yaml.v2 v2.2.8 + gopkg.in/yaml.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index bad19a68..9f6ee296 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU= -cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= -github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= @@ -17,10 +13,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= -github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= +github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -61,9 +57,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= -github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -71,8 +66,6 @@ github.com/dnote/actions v0.2.0 h1:P1ut2/QRKwfAzIIB374vN9A4IanU94C/payEocvngYo= github.com/dnote/actions v0.2.0/go.mod h1:bBIassLhppVQdbC3iaE92SHBpM1HOVe+xZoAlj9ROxw= github.com/dnote/color v1.7.0 h1:8/QGLQKSU8/zcWQaHbMyC1hJRkKO/Uu9M89sH76ecHE= github.com/dnote/color v1.7.0/go.mod h1:75UcP/TH7CNvjQ5pwDumkUS3vkPdGggy7/3fT8MlxHM= -github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff h1:DJKdzouhr6u1NzuLbmSWeei9BagH3Nm4mSOzP0RMdc0= -github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff/go.mod h1:ruGZjl8WThApI7BAIKV2Q/PnJoudvd6Epjc3z79jWVg= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= @@ -101,8 +94,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8= github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= -github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= -github.com/gobuffalo/logger v1.0.0/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs= github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg= github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs= github.com/gobuffalo/logger v1.0.3 h1:YaXOTHNPCvkqqA7w05A4v0k2tCdpr+sgFlgINbQ6gqc= @@ -111,9 +102,6 @@ github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4 github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q= github.com/gobuffalo/packd v1.0.0 h1:6ERZvJHfe24rfFmA9OaoKBdC7+c9sydrytMg8SdFGBM= github.com/gobuffalo/packd v1.0.0/go.mod h1:6VTc4htmJRFB7u1m/4LeMTWjFoYrUiBkU9Fdec9hrhI= -github.com/gobuffalo/packr v1.30.1 h1:hu1fuVR3fXEZR7rXNW3h8rqSML8EVAf6KNm0NKO/wKg= -github.com/gobuffalo/packr v1.30.1/go.mod h1:ljMyFO2EcrnzsHsN99cvbq055Y9OhRrIaviy289eRuk= -github.com/gobuffalo/packr/v2 v2.5.1/go.mod h1:8f9c96ITobJlPzI44jj+4tHnEKNt0xXWSVlXRN9X1Iw= github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o= github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc= github.com/gobuffalo/packr/v2 v2.8.0 h1:IULGd15bQL59ijXLxEvA5wlMxsmx/ZkQv9T282zNVIY= @@ -123,13 +111,13 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -147,20 +135,15 @@ github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+u github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I= -github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= @@ -197,8 +180,6 @@ github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmK github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/jinzhu/gorm v1.9.9 h1:Gc8bP20O+vroFUzZEXA1r7vNGQZGQ+RKgOnriuNF3ds= -github.com/jinzhu/gorm v1.9.9/go.mod h1:Kh6hTsSGffh4ui079FHrR5Gg+5D0hgihqDcsDN2BBJY= github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -212,14 +193,8 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 h1:abLciEiilfMf19Q1TFWDrp9j5z5one60dnnpvc6eabg= -github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666/go.mod h1:xqGOmDZzLOG7+q/CgsbXv10g4tgPsbjhmAxyaTJMvis= -github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d h1:qtCcYJK2bebPXEC8Wy+enYxQqmWnT6jlVTHnDGpwvkc= -github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d/go.mod h1:U7FWcK1jzZJnYuSnxP6efX3ZoHbK1CEpD0ThYyGNPNI= -github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/karrick/godirwalk v1.15.3 h1:0a2pXOgtB16CqIqXTiT7+K9L73f74n/aNQUnH6Ortew= github.com/karrick/godirwalk v1.15.3/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/karrick/godirwalk v1.15.6 h1:Yf2mmR8TJy+8Fa0SuQVto5SYap6IF7lNVX4Jdl8G1qA= @@ -229,6 +204,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -238,8 +215,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc= -github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.5.2 h1:yTSXVswvWUOQ3k1sd7vJfDrbSl8lKuscqFJRqjC0ifw= +github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -256,15 +233,11 @@ github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+v github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= @@ -359,25 +332,21 @@ github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cS github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.5.2 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25QiF0w= github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rubenv/sql-migrate v0.0.0-20190618074426-f4d34eae5a5c h1:LCELEbde3/GT141OpHRs+jJZrI1tI3ayVd4VqW7Ui2U= -github.com/rubenv/sql-migrate v0.0.0-20190618074426-f4d34eae5a5c/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY= -github.com/rubenv/sql-migrate v0.0.0-20200423171638-eef9d3b68125 h1:TNreUp2iVj8BSG7NrBFUeq5UoAGK7fWas/Eb4jlwKlY= -github.com/rubenv/sql-migrate v0.0.0-20200423171638-eef9d3b68125/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= +github.com/rubenv/sql-migrate v0.0.0-20200429072036-ae26b214fa43 h1:0i6uTtxUGc/jpK/CngM4T2S2NFnqYUUxH+lKDgBLw8U= +github.com/rubenv/sql-migrate v0.0.0-20200429072036-ae26b214fa43/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= -github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= @@ -408,11 +377,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stripe/stripe-go v61.7.1+incompatible h1:sflLf/SPZxu81RtdypT48tjw6/NYQX55JCSuEm0rkWs= -github.com/stripe/stripe-go v61.7.1+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= -github.com/stripe/stripe-go v70.15.0+incompatible h1:hNML7M1zx8RgtepEMlxyu/FpVPrP7KZm1gPFQquJQvM= -github.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= @@ -421,7 +387,6 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -450,8 +415,8 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 h1:Q7tZBpemrlsc2I7IyODzhtallWRSm4Q0d09pL6XbQtU= -golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -482,10 +447,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200421231249-e086a090c8fd h1:QPwSajcTUrFriMF1nJ3XzgoqakqQEsnZf9LdXdi2nkI= -golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= -golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120 h1:EZ3cVSzKOlJxAd8e8YAJ7no8nNypTxexh/YE/xW3ZEY= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -507,27 +470,22 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= -golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c h1:kISX68E8gSkNYAFRFiDU8rl5RIn1sJYKYb/r2vMLDrU= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -543,7 +501,6 @@ golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190624180213-70d37148ca0c/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -551,20 +508,19 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE= golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200425043458-8463f397d07c h1:iHhCR0b26amDCiiO+kBguKZom9aMF+NrFxh9zeKR/XU= -golang.org/x/tools v0.0.0-20200425043458-8463f397d07c/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= @@ -583,13 +539,12 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/gomail.v2 v2.0.0-20150902115704-41f357289737 h1:NvePS/smRcFQ4bMtTddFtknbGCtoBkJxGmpSpVRafCc= -gopkg.in/gomail.v2 v2.0.0-20150902115704-41f357289737/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw= @@ -603,11 +558,10 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/jslib/src/services/index.ts b/jslib/src/services/index.ts index f486a177..ec403fcc 100644 --- a/jslib/src/services/index.ts +++ b/jslib/src/services/index.ts @@ -20,7 +20,6 @@ import { HttpClientConfig } from '../helpers/http'; import initUsersService from './users'; import initBooksService from './books'; import initNotesService from './notes'; -import initPaymentService from './payment'; // init initializes service helpers with the given http configuration // and returns an object of all services. @@ -28,12 +27,10 @@ export default function initServices(c: HttpClientConfig) { const usersService = initUsersService(c); const booksService = initBooksService(c); const notesService = initNotesService(c); - const paymentService = initPaymentService(c); return { users: usersService, books: booksService, - notes: notesService, - payment: paymentService + notes: notesService }; } diff --git a/jslib/src/services/payment.ts b/jslib/src/services/payment.ts deleted file mode 100644 index d2539c02..00000000 --- a/jslib/src/services/payment.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -import { getHttpClient, HttpClientConfig } from '../helpers/http'; - -export default function init(config: HttpClientConfig) { - const client = getHttpClient(config); - - return { - createSubscription: ({ yearly, source, country }) => { - const payload = { - yearly, - source, - country - }; - - return client.post('/subscriptions', payload); - }, - - getSubscription: () => { - return client.get('/subscriptions'); - }, - - cancelSubscription: ({ subscriptionId }) => { - const data = { - op: 'cancel', - stripe_subscription_id: subscriptionId - }; - - return client.patch('/subscriptions', data); - }, - - reactivateSubscription: ({ subscriptionId }) => { - const data = { - op: 'reactivate', - stripe_subscription_id: subscriptionId - }; - - return client.patch('/subscriptions', data); - }, - - getSource: () => { - return client.get('/stripe_source'); - }, - - updateSource: ({ source, country }) => { - const payload = { - source, - country - }; - - return client.patch('/stripe_source', payload); - } - }; -} diff --git a/jslib/src/services/users.ts b/jslib/src/services/users.ts index b872031d..ed2cd0c6 100644 --- a/jslib/src/services/users.ts +++ b/jslib/src/services/users.ts @@ -21,6 +21,7 @@ import { EmailPrefData, UserData } from '../operations/types'; export interface UpdateProfileParams { email: string; + password: string; } export interface UpdatePasswordParams { @@ -83,9 +84,10 @@ export default function init(config: HttpClientConfig) { return client.patch('/account/profile', payload); }, - updateProfile: ({ email }: UpdateProfileParams) => { + updateProfile: ({ email, password }: UpdateProfileParams) => { const payload = { - email + email, + password }; return client.patch('/account/profile', payload); diff --git a/pkg/assert/assert.go b/pkg/assert/assert.go index c1abdaf4..24c65bd7 100644 --- a/pkg/assert/assert.go +++ b/pkg/assert/assert.go @@ -87,6 +87,14 @@ func NotEqual(t *testing.T, a, b interface{}, message string) { } } +// NotEqualf fails a test if the actual matches the expected +func NotEqualf(t *testing.T, a, b interface{}, message string) { + ok, m := checkEqual(a, b, message) + if ok { + t.Fatal(m) + } +} + // DeepEqual fails a test if the actual does not deeply equal the expected func DeepEqual(t *testing.T, a, b interface{}, message string) { if cmp.Equal(a, b) { diff --git a/pkg/server/.gitignore b/pkg/server/.gitignore index 0494e979..fa49c96a 100644 --- a/pkg/server/.gitignore +++ b/pkg/server/.gitignore @@ -6,11 +6,3 @@ test-dnote /dist /build server - -# Elastic Beanstalk Files -/tmp -application.zip -test-api -/dump -api -/build diff --git a/pkg/server/api/auth.go b/pkg/server/api/auth.go new file mode 100644 index 00000000..678ad5d0 --- /dev/null +++ b/pkg/server/api/auth.go @@ -0,0 +1,181 @@ +/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/log" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/session" + "github.com/dnote/dnote/pkg/server/token" + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" +) + +// GetMeResponse is the response for getMe endpoint +type GetMeResponse struct { + User session.Session `json:"user"` +} + +func (a *API) getMe(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(helpers.KeyUser).(database.User) + if !ok { + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + var account database.Account + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + handlers.DoError(w, "finding account", err, http.StatusInternalServerError) + return + } + + tx := a.App.DB.Begin() + if err := a.App.TouchLastLoginAt(user, tx); err != nil { + tx.Rollback() + // In case of an error, gracefully continue to avoid disturbing the service + log.ErrorWrap(err, "error touching last_login_at") + } + tx.Commit() + + response := GetMeResponse{ + User: session.New(user, account), + } + handlers.RespondJSON(w, http.StatusOK, response) +} + +type createResetTokenPayload struct { + Email string `json:"email"` +} + +func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) { + var params createResetTokenPayload + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + var account database.Account + conn := a.App.DB.Where("email = ?", params.Email).First(&account) + if conn.RecordNotFound() { + return + } + if err := conn.Error; err != nil { + handlers.DoError(w, errors.Wrap(err, "finding account").Error(), nil, http.StatusInternalServerError) + return + } + + resetToken, err := token.Create(a.App.DB, account.UserID, database.TokenTypeResetPassword) + if err != nil { + handlers.DoError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError) + return + } + + if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil { + if errors.Cause(err) == mailer.ErrSMTPNotConfigured { + handlers.RespondInvalidSMTPConfig(w) + } else { + handlers.DoError(w, errors.Wrap(err, "sending password reset email").Error(), nil, http.StatusInternalServerError) + } + + return + } +} + +type resetPasswordPayload struct { + Password string `json:"password"` + Token string `json:"token"` +} + +func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { + var params resetPasswordPayload + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + var token database.Token + conn := a.App.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token) + if conn.RecordNotFound() { + http.Error(w, "invalid token", http.StatusBadRequest) + return + } + if err := conn.Error; err != nil { + handlers.DoError(w, errors.Wrap(err, "finding token").Error(), nil, http.StatusInternalServerError) + return + } + + if token.UsedAt != nil { + http.Error(w, "invalid token", http.StatusBadRequest) + return + } + + // Expire after 10 minutes + if time.Since(token.CreatedAt).Minutes() > 10 { + http.Error(w, "This link has been expired. Please request a new password reset link.", http.StatusGone) + return + } + + tx := a.App.DB.Begin() + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) + if err != nil { + tx.Rollback() + handlers.DoError(w, errors.Wrap(err, "hashing password").Error(), nil, http.StatusInternalServerError) + return + } + + var account database.Account + if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { + tx.Rollback() + handlers.DoError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) + return + } + + if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil { + tx.Rollback() + handlers.DoError(w, errors.Wrap(err, "updating password").Error(), nil, http.StatusInternalServerError) + return + } + if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { + tx.Rollback() + handlers.DoError(w, errors.Wrap(err, "updating password reset token").Error(), nil, http.StatusInternalServerError) + return + } + + tx.Commit() + + var user database.User + if err := a.App.DB.Where("id = ?", account.UserID).First(&user).Error; err != nil { + handlers.DoError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) + return + } + + a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK) + + if err := a.App.SendPasswordResetAlertEmail(account.Email.String); err != nil { + log.ErrorWrap(err, "sending password reset email") + } +} diff --git a/pkg/server/handlers/auth_test.go b/pkg/server/api/auth_test.go similarity index 84% rename from pkg/server/handlers/auth_test.go rename to pkg/server/api/auth_test.go index 8ca3574b..3f6beabd 100644 --- a/pkg/server/handlers/auth_test.go +++ b/pkg/server/api/auth_test.go @@ -16,9 +16,11 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( + "encoding/json" + "fmt" "net/http" "testing" "time" @@ -27,13 +29,15 @@ import ( "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/session" "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) func TestGetMe(t *testing.T) { testutils.InitTestDB() - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -41,26 +45,64 @@ func TestGetMe(t *testing.T) { }) defer server.Close() - u := testutils.SetupUserData() - testutils.SetupAccountData(u, "alice@example.com", "somepassword") + u1 := testutils.SetupUserData() + a1 := testutils.SetupAccountData(u1, "alice@example.com", "somepassword") - dat := `{"email": "alice@example.com"}` - req := testutils.MakeReq(server.URL, "POST", "/reset-token", dat) + u2 := testutils.SetupUserData() + testutils.MustExec(t, testutils.DB.Model(&u2).Update("cloud", false), "preparing u2 cloud") + a2 := testutils.SetupAccountData(u2, "bob@example.com", "somepassword") - // Execute - res := testutils.HTTPAuthDo(t, req, u) + testCases := []struct { + user database.User + account database.Account + expectedPro bool + }{ + { + user: u1, + account: a1, + expectedPro: true, + }, + { + user: u2, + account: a2, + expectedPro: false, + }, + } - // Test - assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach") + for _, tc := range testCases { + t.Run(fmt.Sprintf("user pro %t", tc.expectedPro), func(t *testing.T) { + // Execute + req := testutils.MakeReq(server.URL, "GET", "/me", "") + res := testutils.HTTPAuthDo(t, req, tc.user) - var user database.User - testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user") - assert.Equal(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch") + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload GetMeResponse + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + expectedPayload := GetMeResponse{ + User: session.Session{ + UUID: tc.user.UUID, + Pro: tc.expectedPro, + Email: tc.account.Email.String, + EmailVerified: tc.account.EmailVerified, + }, + } + assert.DeepEqual(t, payload, expectedPayload, "payload mismatch") + + var user database.User + testutils.MustExec(t, testutils.DB.Where("id = ?", tc.user.ID).First(&user), "finding user") + assert.NotEqual(t, user.LastLoginAt, nil, "LastLoginAt mismatch") + }) + } } func TestCreateResetToken(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -94,7 +136,7 @@ func TestCreateResetToken(t *testing.T) { t.Run("nonexistent email", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -124,7 +166,7 @@ func TestCreateResetToken(t *testing.T) { func TestResetPassword(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -171,7 +213,7 @@ func TestResetPassword(t *testing.T) { t.Run("nonexistent token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -210,7 +252,7 @@ func TestResetPassword(t *testing.T) { t.Run("expired token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -248,7 +290,7 @@ func TestResetPassword(t *testing.T) { t.Run("used token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -297,7 +339,7 @@ func TestResetPassword(t *testing.T) { t.Run("using wrong type token: email_verification", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ diff --git a/pkg/server/handlers/health.go b/pkg/server/api/health.go similarity index 98% rename from pkg/server/handlers/health.go rename to pkg/server/api/health.go index 496f753e..739e8859 100644 --- a/pkg/server/handlers/health.go +++ b/pkg/server/api/health.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "net/http" diff --git a/pkg/server/handlers/health_test.go b/pkg/server/api/health_test.go similarity index 98% rename from pkg/server/handlers/health_test.go rename to pkg/server/api/health_test.go index 32f34379..193a2cd9 100644 --- a/pkg/server/handlers/health_test.go +++ b/pkg/server/api/health_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "net/http" diff --git a/pkg/server/api/helpers.go b/pkg/server/api/helpers.go new file mode 100644 index 00000000..8137939f --- /dev/null +++ b/pkg/server/api/helpers.go @@ -0,0 +1,85 @@ +/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package api + +import ( + "net/http" + "strings" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func paginate(conn *gorm.DB, page int) *gorm.DB { + limit := 30 + + // Paginate + if page > 0 { + offset := limit * (page - 1) + conn = conn.Offset(offset) + } + + conn = conn.Limit(limit) + + return conn +} + +func getBookIDs(books []database.Book) []int { + ret := []int{} + + for _, book := range books { + ret = append(ret, book.ID) + } + + return ret +} + +func validatePassword(password string) error { + if len(password) < 8 { + return errors.New("Password should be longer than 8 characters") + } + + return nil +} + +func getClientType(r *http.Request) string { + origin := r.Header.Get("Origin") + + if strings.HasPrefix(origin, "moz-extension://") { + return "firefox-extension" + } + + if strings.HasPrefix(origin, "chrome-extension://") { + return "chrome-extension" + } + + userAgent := r.Header.Get("User-Agent") + if strings.HasPrefix(userAgent, "Go-http-client") { + return "cli" + } + + return "web" +} + +// notSupported is the handler for the route that is no longer supported +func (a *API) notSupported(w http.ResponseWriter, r *http.Request) { + http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone) + return +} diff --git a/web/src/components/Settings/Billing/PaymentMethodModal/Form.scss b/pkg/server/api/main_test.go similarity index 77% rename from web/src/components/Settings/Billing/PaymentMethodModal/Form.scss rename to pkg/server/api/main_test.go index 700c94bd..ee70a07d 100644 --- a/web/src/components/Settings/Billing/PaymentMethodModal/Form.scss +++ b/pkg/server/api/main_test.go @@ -16,12 +16,20 @@ * along with Dnote. If not, see . */ -@import '../../../App/rem'; -@import '../../../App/font'; -@import '../../../App/theme'; +package api -.input-row { - &:not(:first-child) { - margin-top: rem(10px); - } +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData(testutils.DB) + + os.Exit(code) } diff --git a/pkg/server/handlers/notes.go b/pkg/server/api/notes.go similarity index 89% rename from pkg/server/handlers/notes.go rename to pkg/server/api/notes.go index 1890914e..8a745e41 100644 --- a/pkg/server/handlers/notes.go +++ b/pkg/server/api/notes.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "fmt" @@ -27,6 +27,7 @@ import ( "time" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" @@ -77,7 +78,7 @@ ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), func respondWithNote(w http.ResponseWriter, note database.Note) { presentedNote := presenters.PresentNote(note) - respondJSON(w, http.StatusOK, presentedNote) + handlers.RespondJSON(w, http.StatusOK, presentedNote) } func parseSearchQuery(q url.Values) string { @@ -100,9 +101,9 @@ func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB { } func (a *API) getNote(w http.ResponseWriter, r *http.Request) { - user, _, err := AuthWithSession(a.App.DB, r, nil) + user, _, err := handlers.AuthWithSession(a.App.DB, r, nil) if err != nil { - HandleError(w, "authenticating", err, http.StatusInternalServerError) + handlers.DoError(w, "authenticating", err, http.StatusInternalServerError) return } @@ -111,11 +112,11 @@ func (a *API) getNote(w http.ResponseWriter, r *http.Request) { note, ok, err := operations.GetNote(a.App.DB, noteUUID, user) if !ok { - RespondNotFound(w) + handlers.RespondNotFound(w) return } if err != nil { - HandleError(w, "finding note", err, http.StatusInternalServerError) + handlers.DoError(w, "finding note", err, http.StatusInternalServerError) return } @@ -138,7 +139,7 @@ type dateRange struct { func (a *API) getNotes(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } query := r.URL.Query() @@ -157,7 +158,7 @@ func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseW var total int if err := conn.Model(database.Note{}).Count(&total).Error; err != nil { - HandleError(w, "counting total", err, http.StatusInternalServerError) + handlers.DoError(w, "counting total", err, http.StatusInternalServerError) return } @@ -168,7 +169,7 @@ func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseW conn = paginate(conn, q.Page) if err := conn.Find(¬es).Error; err != nil { - HandleError(w, "finding notes", err, http.StatusInternalServerError) + handlers.DoError(w, "finding notes", err, http.StatusInternalServerError) return } } @@ -177,7 +178,7 @@ func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseW Notes: presenters.PresentNotes(notes), Total: total, } - respondJSON(w, http.StatusOK, response) + handlers.RespondJSON(w, http.StatusOK, response) } type getNotesQuery struct { @@ -308,16 +309,16 @@ func escapeSearchQuery(searchQuery string) string { func (a *API) legacyGetNotes(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var notes []database.Note if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { - HandleError(w, "finding notes", err, http.StatusInternalServerError) + handlers.DoError(w, "finding notes", err, http.StatusInternalServerError) return } presented := presenters.PresentNotes(notes) - respondJSON(w, http.StatusOK, presented) + handlers.RespondJSON(w, http.StatusOK, presented) } diff --git a/pkg/server/handlers/notes_test.go b/pkg/server/api/notes_test.go similarity index 99% rename from pkg/server/handlers/notes_test.go rename to pkg/server/api/notes_test.go index 9f7734ab..d0b0617b 100644 --- a/pkg/server/handlers/notes_test.go +++ b/pkg/server/api/notes_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -55,7 +55,7 @@ func getExpectedNotePayload(n database.Note, b database.Book, u database.User) p } func TestGetNotes(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -165,7 +165,7 @@ func TestGetNotes(t *testing.T) { } func TestGetNote(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ diff --git a/pkg/server/api/routes.go b/pkg/server/api/routes.go new file mode 100644 index 00000000..a1d8385d --- /dev/null +++ b/pkg/server/api/routes.go @@ -0,0 +1,116 @@ +/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package api + +import ( + "net/http" + "os" + + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +// API is a web API configuration +type API struct { + App *app.App +} + +// init sets up the application based on the configuration +func (a *API) init() error { + if err := a.App.Validate(); err != nil { + return errors.Wrap(err, "validating the app parameters") + } + + return nil +} + +func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler { + ret := h + ret = handlers.Logging(ret) + + if rateLimit && os.Getenv("GO_ENV") != "TEST" { + ret = handlers.Limit(ret) + } + + return ret +} + +// NewRouter creates and returns a new router +func NewRouter(a *API) (*mux.Router, error) { + if err := a.init(); err != nil { + return nil, errors.Wrap(err, "initializing app") + } + + proOnly := handlers.AuthParams{ProOnly: true} + app := a.App + + var routes = []handlers.Route{ + // internal + {Method: "GET", Pattern: "/health", HandlerFunc: a.checkHealth, RateLimit: false}, + {Method: "GET", Pattern: "/me", HandlerFunc: handlers.Auth(app, a.getMe, nil), RateLimit: true}, + {Method: "POST", Pattern: "/verification-token", HandlerFunc: handlers.Auth(app, a.createVerificationToken, nil), RateLimit: true}, + {Method: "PATCH", Pattern: "/verify-email", HandlerFunc: a.verifyEmail, RateLimit: true}, + {Method: "POST", Pattern: "/reset-token", HandlerFunc: a.createResetToken, RateLimit: true}, + {Method: "PATCH", Pattern: "/reset-password", HandlerFunc: a.resetPassword, RateLimit: true}, + {Method: "PATCH", Pattern: "/account/profile", HandlerFunc: handlers.Auth(app, a.updateProfile, nil), RateLimit: true}, + {Method: "PATCH", Pattern: "/account/password", HandlerFunc: handlers.Auth(app, a.updatePassword, nil), RateLimit: true}, + {Method: "GET", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.getEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true}, + {Method: "PATCH", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.updateEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true}, + {Method: "GET", Pattern: "/notes", HandlerFunc: handlers.Auth(app, a.getNotes, nil), RateLimit: false}, + {Method: "GET", Pattern: "/notes/{noteUUID}", HandlerFunc: a.getNote, RateLimit: true}, + {Method: "GET", Pattern: "/calendar", HandlerFunc: handlers.Auth(app, a.getCalendar, nil), RateLimit: true}, + + // v3 + {Method: "GET", Pattern: "/v3/sync/fragment", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncFragment, &proOnly)), RateLimit: false}, + {Method: "GET", Pattern: "/v3/sync/state", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncState, &proOnly)), RateLimit: false}, + {Method: "OPTIONS", Pattern: "/v3/books", HandlerFunc: handlers.Cors(a.BooksOptions), RateLimit: true}, + {Method: "GET", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBooks, &proOnly)), RateLimit: true}, + {Method: "GET", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBook, &proOnly)), RateLimit: true}, + {Method: "POST", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateBook, &proOnly)), RateLimit: false}, + {Method: "PATCH", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.UpdateBook, &proOnly)), RateLimit: false}, + {Method: "DELETE", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.DeleteBook, &proOnly)), RateLimit: false}, + {Method: "OPTIONS", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(a.NotesOptions), RateLimit: true}, + {Method: "POST", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateNote, &proOnly)), RateLimit: false}, + {Method: "PATCH", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.UpdateNote, &proOnly), RateLimit: false}, + {Method: "DELETE", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.DeleteNote, &proOnly), RateLimit: false}, + {Method: "POST", Pattern: "/v3/signin", HandlerFunc: handlers.Cors(a.signin), RateLimit: true}, + {Method: "OPTIONS", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signoutOptions), RateLimit: true}, + {Method: "POST", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signout), RateLimit: true}, + {Method: "POST", Pattern: "/v3/register", HandlerFunc: a.register, RateLimit: true}, + } + + router := mux.NewRouter().StrictSlash(true) + + router.PathPrefix("/v1").Handler(applyMiddleware(handlers.NotSupported, true)) + router.PathPrefix("/v2").Handler(applyMiddleware(handlers.NotSupported, true)) + + for _, route := range routes { + handler := route.HandlerFunc + + router. + Methods(route.Method). + Path(route.Pattern). + Handler(applyMiddleware(handler, route.RateLimit)) + } + + return router, nil +} diff --git a/pkg/server/api/routes_test.go b/pkg/server/api/routes_test.go new file mode 100644 index 00000000..8a395f01 --- /dev/null +++ b/pkg/server/api/routes_test.go @@ -0,0 +1,161 @@ +/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package api + +import ( + "fmt" + "net/http" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/config" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func TestNotSupportedVersions(t *testing.T) { + testCases := []struct { + path string + }{ + // v1 + { + path: "/v1", + }, + { + path: "/v1/foo", + }, + { + path: "/v1/bar/baz", + }, + // v2 + { + path: "/v2", + }, + { + path: "/v2/foo", + }, + { + path: "/v2/bar/baz", + }, + } + + // setup + server := MustNewServer(t, &app.App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + }) + defer server.Close() + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + // execute + req := testutils.MakeReq(server.URL, "GET", tc.path, "") + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, http.StatusGone, "status code mismatch") + }) + } +} + +func TestNewRouter_AppValidate(t *testing.T) { + c := config.Load() + + configWithoutWebURL := config.Load() + configWithoutWebURL.WebURL = "" + + testCases := []struct { + app app.App + expectedErr error + }{ + { + app: app.App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: c, + }, + expectedErr: nil, + }, + { + app: app.App{ + DB: nil, + Clock: clock.NewMock(), + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: c, + }, + expectedErr: app.ErrEmptyDB, + }, + { + app: app.App{ + DB: &gorm.DB{}, + Clock: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: c, + }, + expectedErr: app.ErrEmptyClock, + }, + { + app: app.App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + EmailTemplates: nil, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: c, + }, + expectedErr: app.ErrEmptyEmailTemplates, + }, + { + app: app.App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + EmailTemplates: mailer.Templates{}, + EmailBackend: nil, + Config: c, + }, + expectedErr: app.ErrEmptyEmailBackend, + }, + { + app: app.App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: configWithoutWebURL, + }, + expectedErr: app.ErrEmptyWebURL, + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { + api := API{App: &tc.app} + _, err := NewRouter(&api) + + assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") + }) + } +} diff --git a/pkg/server/handlers/testutils.go b/pkg/server/api/testutils.go similarity index 96% rename from pkg/server/handlers/testutils.go rename to pkg/server/api/testutils.go index c19b7532..b8896475 100644 --- a/pkg/server/handlers/testutils.go +++ b/pkg/server/api/testutils.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "net/http/httptest" @@ -30,8 +30,7 @@ import ( // with the given app paratmers func MustNewServer(t *testing.T, appParams *app.App) *httptest.Server { api := NewTestAPI(appParams) - - r, err := api.NewRouter() + r, err := NewRouter(&api) if err != nil { t.Fatal(errors.Wrap(err, "initializing server")) } diff --git a/pkg/server/handlers/user.go b/pkg/server/api/user.go similarity index 75% rename from pkg/server/handlers/user.go rename to pkg/server/api/user.go index 578dc6da..04e41149 100644 --- a/pkg/server/handlers/user.go +++ b/pkg/server/api/user.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -24,10 +24,12 @@ import ( "time" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/presenters" + "github.com/dnote/dnote/pkg/server/session" "github.com/dnote/dnote/pkg/server/token" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -35,14 +37,21 @@ import ( ) type updateProfilePayload struct { - Email string `json:"email"` + Email string `json:"email"` + Password string `json:"password"` } // updateProfile updates user func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) + return + } + + var account database.Account + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + handlers.DoError(w, "getting account", nil, http.StatusInternalServerError) return } @@ -53,23 +62,25 @@ func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) { return } + password := []byte(params.Password) + if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil { + log.WithFields(log.Fields{ + "user_id": user.ID, + }).Warn("invalid email update attempt") + http.Error(w, "Wrong password", http.StatusUnauthorized) + return + } + // Validate if len(params.Email) > 60 { http.Error(w, "Email is too long", http.StatusBadRequest) return } - var account database.Account - err = a.App.DB.Where("user_id = ?", user.ID).First(&account).Error - if err != nil { - HandleError(w, "finding account", err, http.StatusInternalServerError) - return - } - tx := a.App.DB.Begin() if err := tx.Save(&user).Error; err != nil { tx.Rollback() - HandleError(w, "saving user", err, http.StatusInternalServerError) + handlers.DoError(w, "saving user", err, http.StatusInternalServerError) return } @@ -81,7 +92,7 @@ func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) { if err := tx.Save(&account).Error; err != nil { tx.Rollback() - HandleError(w, "saving account", err, http.StatusInternalServerError) + handlers.DoError(w, "saving account", err, http.StatusInternalServerError) return } @@ -104,7 +115,7 @@ func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) { Order("added_date DESC").Rows() if err != nil { - HandleError(w, "Failed to count lessons", err, http.StatusInternalServerError) + handlers.DoError(w, "Failed to count lessons", err, http.StatusInternalServerError) return } @@ -115,18 +126,18 @@ func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) { var d time.Time if err := rows.Scan(&count, &d); err != nil { - HandleError(w, "counting notes", err, http.StatusInternalServerError) + handlers.DoError(w, "counting notes", err, http.StatusInternalServerError) } payload[d.Format("2006-1-2")] = count } - respondJSON(w, http.StatusOK, payload) + handlers.RespondJSON(w, http.StatusOK, payload) } func (a *API) getCalendar(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -136,14 +147,14 @@ func (a *API) getCalendar(w http.ResponseWriter, r *http.Request) { func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var account database.Account err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error if err != nil { - HandleError(w, "finding account", err, http.StatusInternalServerError) + handlers.DoError(w, "finding account", err, http.StatusInternalServerError) return } @@ -158,15 +169,15 @@ func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) { tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification) if err != nil { - HandleError(w, "saving token", err, http.StatusInternalServerError) + handlers.DoError(w, "saving token", err, http.StatusInternalServerError) return } if err := a.App.SendVerificationEmail(account.Email.String, tok.Value); err != nil { if errors.Cause(err) == mailer.ErrSMTPNotConfigured { - respondInvalidSMTPConfig(w) + handlers.RespondInvalidSMTPConfig(w) } else { - HandleError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError) + handlers.DoError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError) } return @@ -182,7 +193,7 @@ type verifyEmailPayload struct { func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { var params verifyEmailPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } @@ -207,7 +218,7 @@ func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { var account database.Account if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { - HandleError(w, "finding account", err, http.StatusInternalServerError) + handlers.DoError(w, "finding account", err, http.StatusInternalServerError) return } if account.EmailVerified { @@ -219,24 +230,24 @@ func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { account.EmailVerified = true if err := tx.Save(&account).Error; err != nil { tx.Rollback() - HandleError(w, "updating email_verified", err, http.StatusInternalServerError) + handlers.DoError(w, "updating email_verified", err, http.StatusInternalServerError) return } if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() - HandleError(w, "updating reset token", err, http.StatusInternalServerError) + handlers.DoError(w, "updating reset token", err, http.StatusInternalServerError) return } tx.Commit() var user database.User if err := a.App.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil { - HandleError(w, "finding user", err, http.StatusInternalServerError) + handlers.DoError(w, "finding user", err, http.StatusInternalServerError) return } - session := makeSession(user, account) - respondJSON(w, http.StatusOK, session) + s := session.New(user, account) + handlers.RespondJSON(w, http.StatusOK, s) } type emailPreferernceParams struct { @@ -263,19 +274,19 @@ func (p emailPreferernceParams) getProductUpdate() bool { func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params emailPreferernceParams if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } var pref database.EmailPreference if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&pref).Error; err != nil { - HandleError(w, "finding pref", err, http.StatusInternalServerError) + handlers.DoError(w, "finding pref", err, http.StatusInternalServerError) return } @@ -290,7 +301,7 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { if err := tx.Save(&pref).Error; err != nil { tx.Rollback() - HandleError(w, "saving pref", err, http.StatusInternalServerError) + handlers.DoError(w, "saving pref", err, http.StatusInternalServerError) return } @@ -299,31 +310,31 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { // Mark token as used if the user was authenticated by token if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() - HandleError(w, "updating reset token", err, http.StatusInternalServerError) + handlers.DoError(w, "updating reset token", err, http.StatusInternalServerError) return } } tx.Commit() - respondJSON(w, http.StatusOK, pref) + handlers.RespondJSON(w, http.StatusOK, pref) } func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var pref database.EmailPreference if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil { - HandleError(w, "finding pref", err, http.StatusInternalServerError) + handlers.DoError(w, "finding pref", err, http.StatusInternalServerError) return } presented := presenters.PresentEmailPreference(pref) - respondJSON(w, http.StatusOK, presented) + handlers.RespondJSON(w, http.StatusOK, presented) } type updatePasswordPayload struct { @@ -334,7 +345,7 @@ type updatePasswordPayload struct { func (a *API) updatePassword(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -350,7 +361,7 @@ func (a *API) updatePassword(w http.ResponseWriter, r *http.Request) { var account database.Account if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - HandleError(w, "getting user", nil, http.StatusInternalServerError) + handlers.DoError(w, "getting account", nil, http.StatusInternalServerError) return } diff --git a/pkg/server/handlers/user_test.go b/pkg/server/api/user_test.go similarity index 91% rename from pkg/server/handlers/user_test.go rename to pkg/server/api/user_test.go index 986dbe28..19376300 100644 --- a/pkg/server/handlers/user_test.go +++ b/pkg/server/api/user_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -37,8 +37,7 @@ import ( func TestUpdatePassword(t *testing.T) { t.Run("success", func(t *testing.T) { - - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -65,8 +64,7 @@ func TestUpdatePassword(t *testing.T) { }) t.Run("old password mismatch", func(t *testing.T) { - - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -92,8 +90,7 @@ func TestUpdatePassword(t *testing.T) { }) t.Run("password too short", func(t *testing.T) { - - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -121,7 +118,7 @@ func TestUpdatePassword(t *testing.T) { func TestCreateVerificationToken(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup emailBackend := testutils.MockEmailbackendImplementation{} @@ -157,7 +154,7 @@ func TestCreateVerificationToken(t *testing.T) { t.Run("already verified", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -191,7 +188,7 @@ func TestCreateVerificationToken(t *testing.T) { func TestVerifyEmail(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -232,7 +229,7 @@ func TestVerifyEmail(t *testing.T) { t.Run("used token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -276,7 +273,7 @@ func TestVerifyEmail(t *testing.T) { t.Run("expired token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -318,7 +315,7 @@ func TestVerifyEmail(t *testing.T) { t.Run("already verified", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -362,7 +359,7 @@ func TestVerifyEmail(t *testing.T) { func TestUpdateEmail(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -376,7 +373,7 @@ func TestUpdateEmail(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&a), "updating email_verified") // Execute - dat := `{"email": "alice-new@example.com"}` + dat := `{"email": "alice-new@example.com", "password": "pass1234"}` req := testutils.MakeReq(server.URL, "PATCH", "/account/profile", dat) res := testutils.HTTPAuthDo(t, req, u) @@ -391,11 +388,42 @@ func TestUpdateEmail(t *testing.T) { assert.Equal(t, account.Email.String, "alice-new@example.com", "email mismatch") assert.Equal(t, account.EmailVerified, false, "EmailVerified mismatch") }) + + t.Run("password mismatch", func(t *testing.T) { + defer testutils.ClearData(testutils.DB) + + // Setup + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), + }) + defer server.Close() + + u := testutils.SetupUserData() + a := testutils.SetupAccountData(u, "alice@example.com", "pass1234") + a.EmailVerified = true + testutils.MustExec(t, testutils.DB.Save(&a), "updating email_verified") + + // Execute + dat := `{"email": "alice-new@example.com", "password": "wrongpassword"}` + req := testutils.MakeReq(server.URL, "PATCH", "/account/profile", dat) + res := testutils.HTTPAuthDo(t, req, u) + + // Test + assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "Status code mismsatch") + + var user database.User + var account database.Account + testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user") + testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account") + + assert.Equal(t, account.Email.String, "alice@example.com", "email mismatch") + assert.Equal(t, account.EmailVerified, true, "EmailVerified mismatch") + }) } func TestUpdateEmailPreference(t *testing.T) { t.Run("with login", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -420,7 +448,7 @@ func TestUpdateEmailPreference(t *testing.T) { }) t.Run("with an unused token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -459,7 +487,7 @@ func TestUpdateEmailPreference(t *testing.T) { }) t.Run("with nonexistent token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -492,7 +520,7 @@ func TestUpdateEmailPreference(t *testing.T) { }) t.Run("with expired token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -529,7 +557,7 @@ func TestUpdateEmailPreference(t *testing.T) { t.Run("with a used but unexpired token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -566,7 +594,7 @@ func TestUpdateEmailPreference(t *testing.T) { t.Run("no user and no token", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -593,7 +621,7 @@ func TestUpdateEmailPreference(t *testing.T) { t.Run("create a record if not exists", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -630,7 +658,7 @@ func TestUpdateEmailPreference(t *testing.T) { } func TestGetEmailPreference(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ diff --git a/pkg/server/handlers/v3_auth.go b/pkg/server/api/v3_auth.go similarity index 84% rename from pkg/server/handlers/v3_auth.go rename to pkg/server/api/v3_auth.go index 23b03267..1c9ab145 100644 --- a/pkg/server/handlers/v3_auth.go +++ b/pkg/server/api/v3_auth.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -24,6 +24,7 @@ import ( "time" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/log" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -50,20 +51,6 @@ func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) { http.SetCookie(w, &cookie) } -func unsetSessionCookie(w http.ResponseWriter) { - expire := time.Now().Add(time.Hour * -24 * 30) - cookie := http.Cookie{ - Name: "id", - Value: "", - Expires: expire, - Path: "/", - HttpOnly: true, - } - - w.Header().Set("Cache-Control", "no-cache") - http.SetCookie(w, &cookie) -} - func touchLastLoginAt(db *gorm.DB, user database.User) error { t := time.Now() if err := db.Model(&user).Update(database.User{LastLoginAt: &t}).Error; err != nil { @@ -82,7 +69,7 @@ func (a *API) signin(w http.ResponseWriter, r *http.Request) { var params signinPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } if params.Email == "" || params.Password == "" { @@ -96,7 +83,7 @@ func (a *API) signin(w http.ResponseWriter, r *http.Request) { http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized) return } else if conn.Error != nil { - HandleError(w, "getting user", err, http.StatusInternalServerError) + handlers.DoError(w, "getting user", err, http.StatusInternalServerError) return } @@ -110,7 +97,7 @@ func (a *API) signin(w http.ResponseWriter, r *http.Request) { var user database.User err = a.App.DB.Where("id = ?", account.UserID).First(&user).Error if err != nil { - HandleError(w, "finding user", err, http.StatusInternalServerError) + handlers.DoError(w, "finding user", err, http.StatusInternalServerError) return } @@ -129,9 +116,9 @@ func (a *API) signoutOptions(w http.ResponseWriter, r *http.Request) { } func (a *API) signout(w http.ResponseWriter, r *http.Request) { - key, err := getCredential(r) + key, err := handlers.GetCredential(r) if err != nil { - HandleError(w, "getting credential", nil, http.StatusInternalServerError) + handlers.DoError(w, "getting credential", nil, http.StatusInternalServerError) return } @@ -142,11 +129,11 @@ func (a *API) signout(w http.ResponseWriter, r *http.Request) { err = a.App.DeleteSession(key) if err != nil { - HandleError(w, "deleting session", nil, http.StatusInternalServerError) + handlers.DoError(w, "deleting session", nil, http.StatusInternalServerError) return } - unsetSessionCookie(w) + handlers.UnsetSessionCookie(w) w.WriteHeader(http.StatusNoContent) } @@ -177,7 +164,7 @@ func parseRegisterPaylaod(r *http.Request) (registerPayload, error) { func (a *API) register(w http.ResponseWriter, r *http.Request) { if a.App.Config.DisableRegistration { - respondForbidden(w) + handlers.RespondForbidden(w) return } @@ -193,7 +180,7 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { var count int if err := a.App.DB.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil { - HandleError(w, "checking duplicate user", err, http.StatusInternalServerError) + handlers.DoError(w, "checking duplicate user", err, http.StatusInternalServerError) return } if count > 0 { @@ -203,7 +190,7 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { user, err := a.App.CreateUser(params.Email, params.Password) if err != nil { - HandleError(w, "creating user", err, http.StatusInternalServerError) + handlers.DoError(w, "creating user", err, http.StatusInternalServerError) return } @@ -219,7 +206,7 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { func (a *API) respondWithSession(db *gorm.DB, w http.ResponseWriter, userID int, statusCode int) { session, err := a.App.CreateSession(userID) if err != nil { - HandleError(w, "creating session", nil, http.StatusBadRequest) + handlers.DoError(w, "creating session", nil, http.StatusBadRequest) return } @@ -233,7 +220,7 @@ func (a *API) respondWithSession(db *gorm.DB, w http.ResponseWriter, userID int, w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(response); err != nil { - HandleError(w, "encoding response", err, http.StatusInternalServerError) + handlers.DoError(w, "encoding response", err, http.StatusInternalServerError) return } } diff --git a/pkg/server/handlers/v3_auth_test.go b/pkg/server/api/v3_auth_test.go similarity index 96% rename from pkg/server/handlers/v3_auth_test.go rename to pkg/server/api/v3_auth_test.go index 964a6c6f..f08eea2b 100644 --- a/pkg/server/handlers/v3_auth_test.go +++ b/pkg/server/api/v3_auth_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -94,7 +94,7 @@ func TestRegister(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("register %s %s", tc.email, tc.password), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) c := config.Load() c.SetOnPremise(tc.onPremise) @@ -127,7 +127,6 @@ func TestRegister(t *testing.T) { var user database.User testutils.MustExec(t, testutils.DB.Where("id = ?", account.UserID).First(&user), "finding user") assert.Equal(t, user.Cloud, tc.expectedPro, "Cloud mismatch") - assert.Equal(t, user.StripeCustomerID, "", "StripeCustomerID mismatch") assert.Equal(t, user.MaxUSN, 0, "MaxUSN mismatch") // welcome email @@ -142,7 +141,7 @@ func TestRegister(t *testing.T) { func TestRegisterMissingParams(t *testing.T) { t.Run("missing email", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -169,7 +168,7 @@ func TestRegisterMissingParams(t *testing.T) { t.Run("missing password", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -196,7 +195,7 @@ func TestRegisterMissingParams(t *testing.T) { } func TestRegisterDuplicateEmail(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -231,7 +230,7 @@ func TestRegisterDuplicateEmail(t *testing.T) { } func TestRegisterDisabled(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) c := config.Load() c.DisableRegistration = true @@ -262,7 +261,7 @@ func TestRegisterDisabled(t *testing.T) { func TestSignIn(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -292,7 +291,7 @@ func TestSignIn(t *testing.T) { }) t.Run("wrong password", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -324,7 +323,7 @@ func TestSignIn(t *testing.T) { t.Run("wrong email", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -356,7 +355,7 @@ func TestSignIn(t *testing.T) { t.Run("nonexistent email", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -383,7 +382,7 @@ func TestSignIn(t *testing.T) { func TestSignout(t *testing.T) { t.Run("authenticated", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) aliceUser := testutils.SetupUserData() testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234") @@ -435,7 +434,7 @@ func TestSignout(t *testing.T) { t.Run("unauthenticated", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) aliceUser := testutils.SetupUserData() testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234") diff --git a/pkg/server/handlers/v3_books.go b/pkg/server/api/v3_books.go similarity index 81% rename from pkg/server/handlers/v3_books.go rename to pkg/server/api/v3_books.go index 5b76fb40..962d38af 100644 --- a/pkg/server/handlers/v3_books.go +++ b/pkg/server/api/v3_books.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -25,6 +25,7 @@ import ( "net/url" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" @@ -59,13 +60,13 @@ func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) { var params createBookPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } err = validateCreateBookPayload(params) if err != nil { - HandleError(w, "validating payload", err, http.StatusBadRequest) + handlers.DoError(w, "validating payload", err, http.StatusBadRequest) return } @@ -74,7 +75,7 @@ func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) { Where("user_id = ? AND label = ?", user.ID, params.Name). Count(&bookCount).Error if err != nil { - HandleError(w, "checking duplicate", err, http.StatusInternalServerError) + handlers.DoError(w, "checking duplicate", err, http.StatusInternalServerError) return } if bookCount > 0 { @@ -84,12 +85,12 @@ func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) { book, err := a.App.CreateBook(user, params.Name) if err != nil { - HandleError(w, "inserting book", err, http.StatusInternalServerError) + handlers.DoError(w, "inserting book", err, http.StatusInternalServerError) } resp := CreateBookResp{ Book: presenters.PresentBook(book), } - respondJSON(w, http.StatusCreated, resp) + handlers.RespondJSON(w, http.StatusCreated, resp) } // BooksOptions is a handler for OPTIONS endpoint for notes @@ -120,12 +121,12 @@ func respondWithBooks(db *gorm.DB, userID int, query url.Values, w http.Response } if err := conn.Find(&books).Error; err != nil { - HandleError(w, "finding books", err, http.StatusInternalServerError) + handlers.DoError(w, "finding books", err, http.StatusInternalServerError) return } presentedBooks := presenters.PresentBooks(books) - respondJSON(w, http.StatusOK, presentedBooks) + handlers.RespondJSON(w, http.StatusOK, presentedBooks) } // GetBooks returns books for the user @@ -158,12 +159,12 @@ func (a *API) GetBook(w http.ResponseWriter, r *http.Request) { return } if err := conn.Error; err != nil { - HandleError(w, "finding book", err, http.StatusInternalServerError) + handlers.DoError(w, "finding book", err, http.StatusInternalServerError) return } p := presenters.PresentBook(book) - respondJSON(w, http.StatusOK, p) + handlers.RespondJSON(w, http.StatusOK, p) } type updateBookPayload struct { @@ -189,21 +190,21 @@ func (a *API) UpdateBook(w http.ResponseWriter, r *http.Request) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { - HandleError(w, "finding book", err, http.StatusInternalServerError) + handlers.DoError(w, "finding book", err, http.StatusInternalServerError) return } var params updateBookPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } book, err = a.App.UpdateBook(tx, user, book, params.Name) if err != nil { tx.Rollback() - HandleError(w, "updating a book", err, http.StatusInternalServerError) + handlers.DoError(w, "updating a book", err, http.StatusInternalServerError) } tx.Commit() @@ -211,7 +212,7 @@ func (a *API) UpdateBook(w http.ResponseWriter, r *http.Request) { resp := UpdateBookResp{ Book: presenters.PresentBook(book), } - respondJSON(w, http.StatusOK, resp) + handlers.RespondJSON(w, http.StatusOK, resp) } // DeleteBookResp is the response from create book api @@ -234,25 +235,25 @@ func (a *API) DeleteBook(w http.ResponseWriter, r *http.Request) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { - HandleError(w, "finding book", err, http.StatusInternalServerError) + handlers.DoError(w, "finding book", err, http.StatusInternalServerError) return } var notes []database.Note if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(¬es).Error; err != nil { - HandleError(w, "finding notes", err, http.StatusInternalServerError) + handlers.DoError(w, "finding notes", err, http.StatusInternalServerError) return } for _, note := range notes { if _, err := a.App.DeleteNote(tx, user, note); err != nil { - HandleError(w, "deleting a note", err, http.StatusInternalServerError) + handlers.DoError(w, "deleting a note", err, http.StatusInternalServerError) return } } b, err := a.App.DeleteBook(tx, user, book) if err != nil { - HandleError(w, "deleting book", err, http.StatusInternalServerError) + handlers.DoError(w, "deleting book", err, http.StatusInternalServerError) return } @@ -262,5 +263,5 @@ func (a *API) DeleteBook(w http.ResponseWriter, r *http.Request) { Status: http.StatusOK, Book: presenters.PresentBook(b), } - respondJSON(w, http.StatusOK, resp) + handlers.RespondJSON(w, http.StatusOK, resp) } diff --git a/pkg/server/handlers/v3_books_test.go b/pkg/server/api/v3_books_test.go similarity index 98% rename from pkg/server/handlers/v3_books_test.go rename to pkg/server/api/v3_books_test.go index 2603cfab..0dbeaae3 100644 --- a/pkg/server/handlers/v3_books_test.go +++ b/pkg/server/api/v3_books_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -35,7 +35,7 @@ import ( func TestGetBooks(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -115,7 +115,7 @@ func TestGetBooks(t *testing.T) { func TestGetBooksByName(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -201,7 +201,7 @@ func TestDeleteBook(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -352,7 +352,7 @@ func TestDeleteBook(t *testing.T) { func TestCreateBook(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -412,7 +412,7 @@ func TestCreateBook(t *testing.T) { func TestCreateBookDuplicate(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -492,7 +492,7 @@ func TestUpdateBook(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ diff --git a/pkg/server/handlers/v3_notes.go b/pkg/server/api/v3_notes.go similarity index 78% rename from pkg/server/handlers/v3_notes.go rename to pkg/server/api/v3_notes.go index 14f2478b..b6e7c750 100644 --- a/pkg/server/handlers/v3_notes.go +++ b/pkg/server/api/v3_notes.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "encoding/json" @@ -25,6 +25,7 @@ import ( "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" @@ -53,25 +54,25 @@ func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params updateNotePayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - HandleError(w, "decoding params", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding params", err, http.StatusInternalServerError) return } if ok := validateUpdateNotePayload(params); !ok { - HandleError(w, "Invalid payload", nil, http.StatusBadRequest) + handlers.DoError(w, "Invalid payload", nil, http.StatusBadRequest) return } var note database.Note if err := a.App.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(¬e).Error; err != nil { - HandleError(w, "finding note", err, http.StatusInternalServerError) + handlers.DoError(w, "finding note", err, http.StatusInternalServerError) return } @@ -84,14 +85,14 @@ func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) { }) if err != nil { tx.Rollback() - HandleError(w, "updating note", err, http.StatusInternalServerError) + handlers.DoError(w, "updating note", err, http.StatusInternalServerError) return } var book database.Book if err := tx.Where("uuid = ? AND user_id = ?", note.BookUUID, user.ID).First(&book).Error; err != nil { tx.Rollback() - HandleError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError) + handlers.DoError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError) return } @@ -105,7 +106,7 @@ func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) { Status: http.StatusOK, Result: presenters.PresentNote(note), } - respondJSON(w, http.StatusOK, resp) + handlers.RespondJSON(w, http.StatusOK, resp) } type deleteNoteResp struct { @@ -120,13 +121,13 @@ func (a *API) DeleteNote(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var note database.Note if err := a.App.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(¬e).Error; err != nil { - HandleError(w, "finding note", err, http.StatusInternalServerError) + handlers.DoError(w, "finding note", err, http.StatusInternalServerError) return } @@ -135,7 +136,7 @@ func (a *API) DeleteNote(w http.ResponseWriter, r *http.Request) { n, err := a.App.DeleteNote(tx, user, note) if err != nil { tx.Rollback() - HandleError(w, "deleting note", err, http.StatusInternalServerError) + handlers.DoError(w, "deleting note", err, http.StatusInternalServerError) return } @@ -145,7 +146,7 @@ func (a *API) DeleteNote(w http.ResponseWriter, r *http.Request) { Status: http.StatusNoContent, Result: presenters.PresentNote(n), } - respondJSON(w, http.StatusOK, resp) + handlers.RespondJSON(w, http.StatusOK, resp) } type createNotePayload struct { @@ -172,33 +173,33 @@ type CreateNoteResp struct { func (a *API) CreateNote(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params createNotePayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - HandleError(w, "decoding payload", err, http.StatusInternalServerError) + handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError) return } err = validateCreateNotePayload(params) if err != nil { - HandleError(w, "validating payload", err, http.StatusBadRequest) + handlers.DoError(w, "validating payload", err, http.StatusBadRequest) return } var book database.Book if err := a.App.DB.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil { - HandleError(w, "finding book", err, http.StatusInternalServerError) + handlers.DoError(w, "finding book", err, http.StatusInternalServerError) return } client := getClientType(r) note, err := a.App.CreateNote(user, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false, client) if err != nil { - HandleError(w, "creating note", err, http.StatusInternalServerError) + handlers.DoError(w, "creating note", err, http.StatusInternalServerError) return } @@ -209,7 +210,7 @@ func (a *API) CreateNote(w http.ResponseWriter, r *http.Request) { resp := CreateNoteResp{ Result: presenters.PresentNote(note), } - respondJSON(w, http.StatusCreated, resp) + handlers.RespondJSON(w, http.StatusCreated, resp) } // NotesOptions is a handler for OPTIONS endpoint for notes diff --git a/pkg/server/handlers/v3_notes_test.go b/pkg/server/api/v3_notes_test.go similarity index 98% rename from pkg/server/handlers/v3_notes_test.go rename to pkg/server/api/v3_notes_test.go index e17126bb..3c57fe27 100644 --- a/pkg/server/handlers/v3_notes_test.go +++ b/pkg/server/api/v3_notes_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "fmt" @@ -32,7 +32,7 @@ import ( func TestCreateNote(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -237,7 +237,7 @@ func TestUpdateNote(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ @@ -333,7 +333,7 @@ func TestDeleteNote(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Setup server := MustNewServer(t, &app.App{ diff --git a/pkg/server/handlers/v3_sync.go b/pkg/server/api/v3_sync.go similarity index 93% rename from pkg/server/handlers/v3_sync.go rename to pkg/server/api/v3_sync.go index dfe73d22..a04f41fa 100644 --- a/pkg/server/handlers/v3_sync.go +++ b/pkg/server/api/v3_sync.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "fmt" @@ -27,6 +27,7 @@ import ( "time" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" "github.com/pkg/errors" @@ -250,26 +251,26 @@ type GetSyncFragmentResp struct { func (a *API) GetSyncFragment(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } afterUSN, limit, err := parseGetSyncFragmentQuery(r.URL.Query()) if err != nil { - HandleError(w, "parsing query params", err, http.StatusInternalServerError) + handlers.DoError(w, "parsing query params", err, http.StatusInternalServerError) return } fragment, err := a.newFragment(user.ID, user.MaxUSN, afterUSN, limit) if err != nil { - HandleError(w, "getting fragment", err, http.StatusInternalServerError) + handlers.DoError(w, "getting fragment", err, http.StatusInternalServerError) return } response := GetSyncFragmentResp{ Fragment: fragment, } - respondJSON(w, http.StatusOK, response) + handlers.RespondJSON(w, http.StatusOK, response) } // GetSyncStateResp represents a response from GetSyncFragment handler @@ -283,7 +284,7 @@ type GetSyncStateResp struct { func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) + handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -299,5 +300,5 @@ func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) { "resp": response, }).Info("getting sync state") - respondJSON(w, http.StatusOK, response) + handlers.RespondJSON(w, http.StatusOK, response) } diff --git a/pkg/server/handlers/v3_sync_test.go b/pkg/server/api/v3_sync_test.go similarity index 99% rename from pkg/server/handlers/v3_sync_test.go rename to pkg/server/api/v3_sync_test.go index d40acc14..2da52aac 100644 --- a/pkg/server/handlers/v3_sync_test.go +++ b/pkg/server/api/v3_sync_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package handlers +package api import ( "fmt" diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go index 04d23153..8f1b50f8 100644 --- a/pkg/server/app/app.go +++ b/pkg/server/app/app.go @@ -24,7 +24,6 @@ import ( "github.com/dnote/dnote/pkg/server/mailer" "github.com/jinzhu/gorm" "github.com/pkg/errors" - "github.com/stripe/stripe-go" ) var ( @@ -42,12 +41,11 @@ var ( // App is an application context type App struct { - DB *gorm.DB - Clock clock.Clock - StripeAPIBackend stripe.Backend - EmailTemplates mailer.Templates - EmailBackend mailer.Backend - Config config.Config + DB *gorm.DB + Clock clock.Clock + EmailTemplates mailer.Templates + EmailBackend mailer.Backend + Config config.Config } // Validate validates the app configuration diff --git a/pkg/server/app/books_test.go b/pkg/server/app/books_test.go index 1d08d7d5..17c2ddcb 100644 --- a/pkg/server/app/books_test.go +++ b/pkg/server/app/books_test.go @@ -54,7 +54,7 @@ func TestCreateBook(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) @@ -120,7 +120,7 @@ func TestDeleteBook(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) @@ -198,7 +198,7 @@ func TestUpdateBook(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) diff --git a/pkg/server/app/helpers_test.go b/pkg/server/app/helpers_test.go index 510fa087..b0b32d23 100644 --- a/pkg/server/app/helpers_test.go +++ b/pkg/server/app/helpers_test.go @@ -46,7 +46,7 @@ func TestIncremenetUserUSN(t *testing.T) { // set up for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.maxUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) diff --git a/pkg/server/app/main_test.go b/pkg/server/app/main_test.go index b9f388eb..b3e34574 100644 --- a/pkg/server/app/main_test.go +++ b/pkg/server/app/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/app/notes_test.go b/pkg/server/app/notes_test.go index e589dc7e..4ce6c701 100644 --- a/pkg/server/app/notes_test.go +++ b/pkg/server/app/notes_test.go @@ -74,7 +74,7 @@ func TestCreateNote(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) @@ -137,7 +137,7 @@ func TestUpdateNote(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), "preparing user max_usn for test case") @@ -212,7 +212,7 @@ func TestDeleteNote(t *testing.T) { for idx, tc := range testCases { func() { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) diff --git a/pkg/server/app/subscriptions.go b/pkg/server/app/subscriptions.go deleted file mode 100644 index b20078b5..00000000 --- a/pkg/server/app/subscriptions.go +++ /dev/null @@ -1,80 +0,0 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -package app - -import ( - "github.com/dnote/dnote/pkg/server/database" - "github.com/pkg/errors" - - "github.com/stripe/stripe-go" - "github.com/stripe/stripe-go/sub" -) - -// ErrSubscriptionActive is an error indicating that the subscription is active -// and therefore cannot be reactivated -var ErrSubscriptionActive = errors.New("The subscription is currently active") - -// CancelSub cancels the subscription of the given user -func (a *App) CancelSub(subscriptionID string, user database.User) error { - updateParams := &stripe.SubscriptionParams{ - CancelAtPeriodEnd: stripe.Bool(true), - } - - _, err := sub.Update(subscriptionID, updateParams) - if err != nil { - return errors.Wrap(err, "updating subscription on Stripe") - } - - return nil -} - -// ReactivateSub reactivates the subscription of the given user -func (a *App) ReactivateSub(subscriptionID string, user database.User) error { - s, err := sub.Get(subscriptionID, nil) - if err != nil { - return errors.Wrap(err, "fetching subscription") - } - - if !s.CancelAtPeriodEnd { - return ErrSubscriptionActive - } - - updateParams := &stripe.SubscriptionParams{ - CancelAtPeriodEnd: stripe.Bool(false), - } - if _, err := sub.Update(subscriptionID, updateParams); err != nil { - return errors.Wrap(err, "updating subscription on Stripe") - } - - return nil -} - -// MarkUnsubscribed marks the user unsubscribed -func (a *App) MarkUnsubscribed(stripeCustomerID string) error { - var user database.User - if err := a.DB.Where("stripe_customer_id = ?", stripeCustomerID).First(&user).Error; err != nil { - return errors.Wrap(err, "finding user") - } - - if err := a.DB.Model(&user).Update("cloud", false).Error; err != nil { - return errors.Wrap(err, "updating user") - } - - return nil -} diff --git a/pkg/server/app/testutils.go b/pkg/server/app/testutils.go index 162fd56b..8accf480 100644 --- a/pkg/server/app/testutils.go +++ b/pkg/server/app/testutils.go @@ -34,12 +34,11 @@ func NewTest(appParams *App) App { c.SetOnPremise(false) a := App{ - DB: testutils.DB, - Clock: clock.NewMock(), - EmailTemplates: mailer.NewTemplates(&emailTmplDir), - EmailBackend: &testutils.MockEmailbackendImplementation{}, - StripeAPIBackend: nil, - Config: c, + DB: testutils.DB, + Clock: clock.NewMock(), + EmailTemplates: mailer.NewTemplates(&emailTmplDir), + EmailBackend: &testutils.MockEmailbackendImplementation{}, + Config: c, } // Allow to override with appParams @@ -52,9 +51,6 @@ func NewTest(appParams *App) App { if appParams != nil && appParams.EmailTemplates != nil { a.EmailTemplates = appParams.EmailTemplates } - if appParams != nil && appParams.StripeAPIBackend != nil { - a.StripeAPIBackend = appParams.StripeAPIBackend - } if appParams != nil && appParams.Config.OnPremise { a.Config.OnPremise = appParams.Config.OnPremise } diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go index 68f73935..ca902775 100644 --- a/pkg/server/app/users.go +++ b/pkg/server/app/users.go @@ -19,8 +19,6 @@ package app import ( - "time" - "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/token" "github.com/jinzhu/gorm" @@ -30,7 +28,7 @@ import ( // TouchLastLoginAt updates the last login timestamp func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error { - t := time.Now() + t := a.Clock.Now() if err := tx.Model(&user).Update(database.User{LastLoginAt: &t}).Error; err != nil { return errors.Wrap(err, "updating last_login_at") } diff --git a/pkg/server/app/users_test.go b/pkg/server/app/users_test.go index a7b08b1e..580bed25 100644 --- a/pkg/server/app/users_test.go +++ b/pkg/server/app/users_test.go @@ -49,7 +49,7 @@ func TestCreateUser(t *testing.T) { c := config.Load() c.SetOnPremise(tc.onPremise) - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) a := NewTest(&App{ Config: c, diff --git a/pkg/server/handlers/auth.go b/pkg/server/handlers/auth.go index 05b762b1..2917e1b7 100644 --- a/pkg/server/handlers/auth.go +++ b/pkg/server/handlers/auth.go @@ -1,196 +1,168 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - package handlers import ( - "encoding/json" + "context" "net/http" + "strings" "time" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" - "github.com/dnote/dnote/pkg/server/mailer" - "github.com/dnote/dnote/pkg/server/token" + "github.com/jinzhu/gorm" "github.com/pkg/errors" - "golang.org/x/crypto/bcrypt" ) -// Session represents user session -type Session struct { - UUID string `json:"uuid"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Pro bool `json:"pro"` -} +func authWithToken(db *gorm.DB, r *http.Request, tokenType string, p *AuthParams) (database.User, database.Token, bool, error) { + var user database.User + var token database.Token -func makeSession(user database.User, account database.Account) Session { - return Session{ - UUID: user.UUID, - Pro: user.Cloud, - Email: account.Email.String, - EmailVerified: account.EmailVerified, - } -} - -func (a *API) getMe(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return + query := r.URL.Query() + tokenValue := query.Get("token") + if tokenValue == "" { + return user, token, false, nil } - var account database.Account - if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - HandleError(w, "finding account", err, http.StatusInternalServerError) - return - } - - session := makeSession(user, account) - - response := struct { - User Session `json:"user"` - }{ - User: session, - } - - tx := a.App.DB.Begin() - if err := a.App.TouchLastLoginAt(user, tx); err != nil { - tx.Rollback() - // In case of an error, gracefully continue to avoid disturbing the service - log.ErrorWrap(err, "error touching last_login_at") - } - tx.Commit() - - respondJSON(w, http.StatusOK, response) -} - -type createResetTokenPayload struct { - Email string `json:"email"` -} - -func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) { - var params createResetTokenPayload - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - - var account database.Account - conn := a.App.DB.Where("email = ?", params.Email).First(&account) + conn := db.Where("value = ? AND type = ?", tokenValue, tokenType).First(&token) if conn.RecordNotFound() { - return - } - if err := conn.Error; err != nil { - HandleError(w, errors.Wrap(err, "finding account").Error(), nil, http.StatusInternalServerError) - return + return user, token, false, nil + } else if err := conn.Error; err != nil { + return user, token, false, errors.Wrap(err, "finding token") } - resetToken, err := token.Create(a.App.DB, account.UserID, database.TokenTypeResetPassword) - if err != nil { - HandleError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError) - return + if token.UsedAt != nil && time.Since(*token.UsedAt).Minutes() > 10 { + return user, token, false, nil } - if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil { - if errors.Cause(err) == mailer.ErrSMTPNotConfigured { - respondInvalidSMTPConfig(w) - } else { - HandleError(w, errors.Wrap(err, "sending password reset email").Error(), nil, http.StatusInternalServerError) + if err := db.Where("id = ?", token.UserID).First(&user).Error; err != nil { + return user, token, false, errors.Wrap(err, "finding user") + } + + return user, token, true, nil +} + +// Cors allows browser extensions to load resources +func Cors(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Allow browser extensions + if strings.HasPrefix(origin, "moz-extension://") || strings.HasPrefix(origin, "chrome-extension://") { + w.Header().Set("Access-Control-Allow-Origin", origin) } - return - } + next.ServeHTTP(w, r) + }) } -type resetPasswordPayload struct { - Password string `json:"password"` - Token string `json:"token"` +// AuthParams is the params for the authentication middleware +type AuthParams struct { + ProOnly bool + RedirectGuestsToLogin bool } -func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { - var params resetPasswordPayload - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } +// Auth is an authentication middleware +func Auth(a *app.App, next http.HandlerFunc, p *AuthParams) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok, err := AuthWithSession(a.DB, r, p) + if !ok { + if p != nil && p.RedirectGuestsToLogin { + http.Redirect(w, r, "/login", http.StatusFound) + return + } - var token database.Token - conn := a.App.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token) - if conn.RecordNotFound() { - http.Error(w, "invalid token", http.StatusBadRequest) - return - } - if err := conn.Error; err != nil { - HandleError(w, errors.Wrap(err, "finding token").Error(), nil, http.StatusInternalServerError) - return - } + RespondUnauthorized(w) + return + } + if err != nil { + DoError(w, "authenticating with session", err, http.StatusInternalServerError) + return + } - if token.UsedAt != nil { - http.Error(w, "invalid token", http.StatusBadRequest) - return - } + if p != nil && p.ProOnly { + if !user.Cloud { + RespondForbidden(w) + return + } + } - // Expire after 10 minutes - if time.Since(token.CreatedAt).Minutes() > 10 { - http.Error(w, "This link has been expired. Please request a new password reset link.", http.StatusGone) - return - } + ctx := context.WithValue(r.Context(), helpers.KeyUser, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} - tx := a.App.DB.Begin() +// TokenAuth is an authentication middleware with token +func TokenAuth(a *app.App, next http.HandlerFunc, tokenType string, p *AuthParams) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, token, ok, err := authWithToken(a.DB, r, tokenType, p) + if err != nil { + // log the error and continue + log.ErrorWrap(err, "authenticating with token") + } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) - if err != nil { - tx.Rollback() - HandleError(w, errors.Wrap(err, "hashing password").Error(), nil, http.StatusInternalServerError) - return - } + ctx := r.Context() - var account database.Account - if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { - tx.Rollback() - HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) - return - } + if ok { + ctx = context.WithValue(ctx, helpers.KeyToken, token) + } else { + // If token-based auth fails, fall back to session-based auth + user, ok, err = AuthWithSession(a.DB, r, p) + if err != nil { + DoError(w, "authenticating with session", err, http.StatusInternalServerError) + return + } - if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil { - tx.Rollback() - HandleError(w, errors.Wrap(err, "updating password").Error(), nil, http.StatusInternalServerError) - return - } - if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { - tx.Rollback() - HandleError(w, errors.Wrap(err, "updating password reset token").Error(), nil, http.StatusInternalServerError) - return - } + if !ok { + RespondUnauthorized(w) + return + } + } - tx.Commit() + if p != nil && p.ProOnly { + if !user.Cloud { + RespondForbidden(w) + return + } + } + ctx = context.WithValue(ctx, helpers.KeyUser, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// AuthWithSession performs user authentication with session +func AuthWithSession(db *gorm.DB, r *http.Request, p *AuthParams) (database.User, bool, error) { var user database.User - if err := a.App.DB.Where("id = ?", account.UserID).First(&user).Error; err != nil { - HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) - return + + sessionKey, err := GetCredential(r) + if err != nil { + return user, false, errors.Wrap(err, "getting credential") + } + if sessionKey == "" { + return user, false, nil } - a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK) + var session database.Session + conn := db.Where("key = ?", sessionKey).First(&session) - if err := a.App.SendPasswordResetAlertEmail(account.Email.String); err != nil { - log.ErrorWrap(err, "sending password reset email") + if conn.RecordNotFound() { + return user, false, nil + } else if err := conn.Error; err != nil { + return user, false, errors.Wrap(err, "finding session") } + + if session.ExpiresAt.Before(time.Now()) { + return user, false, nil + } + + conn = db.Where("id = ?", session.UserID).First(&user) + + if conn.RecordNotFound() { + return user, false, nil + } else if err := conn.Error; err != nil { + return user, false, errors.Wrap(err, "finding user from token") + } + + return user, true, nil } diff --git a/pkg/server/handlers/helpers.go b/pkg/server/handlers/helpers.go index 10becb81..4a274941 100644 --- a/pkg/server/handlers/helpers.go +++ b/pkg/server/handlers/helpers.go @@ -1,87 +1,62 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - package handlers import ( "encoding/json" "net/http" "strings" + "time" - "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) -func paginate(conn *gorm.DB, page int) *gorm.DB { - limit := 30 - - // Paginate - if page > 0 { - offset := limit * (page - 1) - conn = conn.Offset(offset) - } - - conn = conn.Limit(limit) - - return conn +// Route represents a single route +type Route struct { + Method string + Pattern string + HandlerFunc http.HandlerFunc + RateLimit bool } -func getBookIDs(books []database.Book) []int { - ret := []int{} - - for _, book := range books { - ret = append(ret, book.ID) - } - - return ret +// RespondForbidden responds with forbidden +func RespondForbidden(w http.ResponseWriter) { + http.Error(w, "forbidden", http.StatusForbidden) } -func validatePassword(password string) error { - if len(password) < 8 { - return errors.New("Password should be longer than 8 characters") - } - - return nil +// RespondUnauthorized responds with unauthorized +func RespondUnauthorized(w http.ResponseWriter) { + UnsetSessionCookie(w) + w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) } -func getClientType(r *http.Request) string { - origin := r.Header.Get("Origin") - - if strings.HasPrefix(origin, "moz-extension://") { - return "firefox-extension" - } - - if strings.HasPrefix(origin, "chrome-extension://") { - return "chrome-extension" - } - - userAgent := r.Header.Get("User-Agent") - if strings.HasPrefix(userAgent, "Go-http-client") { - return "cli" - } - - return "web" +// RespondNotFound responds with not found +func RespondNotFound(w http.ResponseWriter) { + http.Error(w, "not found", http.StatusNotFound) } -// HandleError logs the error and responds with the given status code with a generic status text -func HandleError(w http.ResponseWriter, msg string, err error, statusCode int) { +// RespondInvalidSMTPConfig responds with invalid SMTP config error +func RespondInvalidSMTPConfig(w http.ResponseWriter) { + http.Error(w, "SMTP is not configured", http.StatusInternalServerError) +} + +// UnsetSessionCookie unsets the session cookie +func UnsetSessionCookie(w http.ResponseWriter) { + expire := time.Now().Add(time.Hour * -24 * 30) + cookie := http.Cookie{ + Name: "id", + Value: "", + Expires: expire, + Path: "/", + HttpOnly: true, + } + + w.Header().Set("Cache-Control", "no-cache") + http.SetCookie(w, &cookie) +} + +// DoError logs the error and responds with the given status code with a generic status text +func DoError(w http.ResponseWriter, msg string, err error, statusCode int) { var message string if err == nil { message = msg @@ -97,37 +72,90 @@ func HandleError(w http.ResponseWriter, msg string, err error, statusCode int) { http.Error(w, statusText, statusCode) } -// respondJSON encodes the given payload into a JSON format and writes it to the given response writer -func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) { +// RespondJSON encodes the given payload into a JSON format and writes it to the given response writer +func RespondJSON(w http.ResponseWriter, statusCode int, payload interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(payload); err != nil { - HandleError(w, "encoding response", err, http.StatusInternalServerError) + DoError(w, "encoding response", err, http.StatusInternalServerError) } } -// notSupported is the handler for the route that is no longer supported -func (a *API) notSupported(w http.ResponseWriter, r *http.Request) { +// NotSupported is the handler for the route that is no longer supported +func NotSupported(w http.ResponseWriter, r *http.Request) { http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone) return } -func respondForbidden(w http.ResponseWriter) { - http.Error(w, "forbidden", http.StatusForbidden) +// getSessionKeyFromCookie reads and returns a session key from the cookie sent by the +// request. If no session key is found, it returns an empty string +func getSessionKeyFromCookie(r *http.Request) (string, error) { + c, err := r.Cookie("id") + + if err == http.ErrNoCookie { + return "", nil + } else if err != nil { + return "", errors.Wrap(err, "reading cookie") + } + + return c.Value, nil } -func respondUnauthorized(w http.ResponseWriter) { - unsetSessionCookie(w) - w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) +type authHeader struct { + scheme string + credential string } -// RespondNotFound responds with not found -func RespondNotFound(w http.ResponseWriter) { - http.Error(w, "not found", http.StatusNotFound) +func parseAuthHeader(h string) (authHeader, error) { + parts := strings.Split(h, " ") + + if len(parts) != 2 { + return authHeader{}, errors.New("Invalid authorization header") + } + + parsed := authHeader{ + scheme: parts[0], + credential: parts[1], + } + + return parsed, nil } -func respondInvalidSMTPConfig(w http.ResponseWriter) { - http.Error(w, "SMTP is not configured", http.StatusInternalServerError) +// getSessionKeyFromAuth reads and returns a session key from the Authorization header +func getSessionKeyFromAuth(r *http.Request) (string, error) { + h := r.Header.Get("Authorization") + if h == "" { + return "", nil + } + + payload, err := parseAuthHeader(h) + if err != nil { + return "", errors.Wrap(err, "parsing the authorization header") + } + if payload.scheme != "Bearer" { + return "", errors.New("unsupported scheme") + } + + return payload.credential, nil +} + +// GetCredential extracts a session key from the request from the request header. Concretely, +// it first looks at the 'Cookie' and then the 'Authorization' header. If no credential is found, +// it returns an empty string. +func GetCredential(r *http.Request) (string, error) { + ret, err := getSessionKeyFromCookie(r) + if err != nil { + return "", errors.Wrap(err, "getting session key from cookie") + } + if ret != "" { + return ret, nil + } + + ret, err = getSessionKeyFromAuth(r) + if err != nil { + return "", errors.Wrap(err, "getting session key from Authorization header") + } + + return ret, nil } diff --git a/pkg/server/handlers/routes_test.go b/pkg/server/handlers/helpers_test.go similarity index 79% rename from pkg/server/handlers/routes_test.go rename to pkg/server/handlers/helpers_test.go index 07a1683c..9c1121bc 100644 --- a/pkg/server/handlers/routes_test.go +++ b/pkg/server/handlers/helpers_test.go @@ -26,13 +26,9 @@ import ( "time" "github.com/dnote/dnote/pkg/assert" - "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/app" - "github.com/dnote/dnote/pkg/server/config" "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -175,7 +171,7 @@ func TestGetCredential(t *testing.T) { for _, tc := range testCases { // execute - got, err := getCredential(tc.request) + got, err := GetCredential(tc.request) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } @@ -185,7 +181,7 @@ func TestGetCredential(t *testing.T) { } func TestAuthMiddleware(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() session := database.Session{ @@ -204,8 +200,8 @@ func TestAuthMiddleware(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - api := API{App: &app.App{DB: testutils.DB}} - server := httptest.NewServer(api.auth(handler, nil)) + a := &app.App{DB: testutils.DB} + server := httptest.NewServer(Auth(a, handler, nil)) defer server.Close() t.Run("with header", func(t *testing.T) { @@ -298,7 +294,7 @@ func TestAuthMiddleware(t *testing.T) { } func TestAuthMiddleware_ProOnly(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("cloud", false), "preparing session") @@ -312,10 +308,12 @@ func TestAuthMiddleware_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - api := API{App: &app.App{DB: testutils.DB}} - server := httptest.NewServer(api.auth(handler, &AuthMiddlewareParams{ + + a := &app.App{DB: testutils.DB} + server := httptest.NewServer(Auth(a, handler, &AuthParams{ ProOnly: true, })) + defer server.Close() t.Run("with header", func(t *testing.T) { @@ -385,8 +383,56 @@ func TestAuthMiddleware_ProOnly(t *testing.T) { }) } +func TestAuthMiddleware_RedirectGuestsToLogin(t *testing.T) { + defer testutils.ClearData(testutils.DB) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + } + + a := &app.App{DB: testutils.DB} + server := httptest.NewServer(Auth(a, handler, &AuthParams{ + RedirectGuestsToLogin: true, + })) + + defer server.Close() + + t.Run("guest", func(t *testing.T) { + req := testutils.MakeReq(server.URL, "GET", "/", "") + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, http.StatusFound, "status code mismatch") + assert.Equal(t, res.Header.Get("Location"), "/login", "location header mismatch") + }) + + t.Run("logged in user", func(t *testing.T) { + req := testutils.MakeReq(server.URL, "GET", "/", "") + + user := testutils.SetupUserData() + testutils.MustExec(t, testutils.DB.Model(&user).Update("cloud", false), "preparing session") + session := database.Session{ + Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=", + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Hour * 24), + } + testutils.MustExec(t, testutils.DB.Save(&session), "preparing session") + + // execute + res := testutils.HTTPAuthDo(t, req, user) + req.Header.Set("Authorization", session.Key) + + // test + assert.Equal(t, res.StatusCode, http.StatusOK, "status code mismatch") + assert.Equal(t, res.Header.Get("Location"), "", "location header mismatch") + }) + +} + func TestTokenAuthMiddleWare(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() tok := database.Token{ @@ -405,8 +451,9 @@ func TestTokenAuthMiddleWare(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - api := API{App: &app.App{DB: testutils.DB}} - server := httptest.NewServer(api.tokenAuth(handler, database.TokenTypeEmailPreference, nil)) + + a := &app.App{DB: testutils.DB} + server := httptest.NewServer(TokenAuth(a, handler, database.TokenTypeEmailPreference, nil)) defer server.Close() t.Run("with token", func(t *testing.T) { @@ -515,7 +562,7 @@ func TestTokenAuthMiddleWare(t *testing.T) { } func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&user).Update("cloud", false), "preparing session") @@ -535,10 +582,12 @@ func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - api := API{App: &app.App{DB: testutils.DB}} - server := httptest.NewServer(api.tokenAuth(handler, database.TokenTypeEmailPreference, &AuthMiddlewareParams{ + + a := &app.App{DB: testutils.DB} + server := httptest.NewServer(TokenAuth(a, handler, database.TokenTypeEmailPreference, &AuthParams{ ProOnly: true, })) + defer server.Close() t.Run("with token", func(t *testing.T) { @@ -645,136 +694,3 @@ func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { assert.Equal(t, res.StatusCode, http.StatusUnauthorized, "status code mismatch") }) } - -func TestNotSupportedVersions(t *testing.T) { - testCases := []struct { - path string - }{ - // v1 - { - path: "/v1", - }, - { - path: "/v1/foo", - }, - { - path: "/v1/bar/baz", - }, - // v2 - { - path: "/v2", - }, - { - path: "/v2/foo", - }, - { - path: "/v2/bar/baz", - }, - } - - // setup - server := MustNewServer(t, &app.App{ - DB: &gorm.DB{}, - Clock: clock.NewMock(), - }) - defer server.Close() - - for _, tc := range testCases { - t.Run(tc.path, func(t *testing.T) { - // execute - req := testutils.MakeReq(server.URL, "GET", tc.path, "") - res := testutils.HTTPDo(t, req) - - // test - assert.Equal(t, res.StatusCode, http.StatusGone, "status code mismatch") - }) - } -} - -func TestNewRouter_AppValidate(t *testing.T) { - c := config.Load() - - configWithoutWebURL := config.Load() - configWithoutWebURL.WebURL = "" - - testCases := []struct { - app app.App - expectedErr error - }{ - { - app: app.App{ - DB: &gorm.DB{}, - Clock: clock.NewMock(), - StripeAPIBackend: nil, - EmailTemplates: mailer.Templates{}, - EmailBackend: &testutils.MockEmailbackendImplementation{}, - Config: c, - }, - expectedErr: nil, - }, - { - app: app.App{ - DB: nil, - Clock: clock.NewMock(), - StripeAPIBackend: nil, - EmailTemplates: mailer.Templates{}, - EmailBackend: &testutils.MockEmailbackendImplementation{}, - Config: c, - }, - expectedErr: app.ErrEmptyDB, - }, - { - app: app.App{ - DB: &gorm.DB{}, - Clock: nil, - StripeAPIBackend: nil, - EmailTemplates: mailer.Templates{}, - EmailBackend: &testutils.MockEmailbackendImplementation{}, - Config: c, - }, - expectedErr: app.ErrEmptyClock, - }, - { - app: app.App{ - DB: &gorm.DB{}, - Clock: clock.NewMock(), - StripeAPIBackend: nil, - EmailTemplates: nil, - EmailBackend: &testutils.MockEmailbackendImplementation{}, - Config: c, - }, - expectedErr: app.ErrEmptyEmailTemplates, - }, - { - app: app.App{ - DB: &gorm.DB{}, - Clock: clock.NewMock(), - StripeAPIBackend: nil, - EmailTemplates: mailer.Templates{}, - EmailBackend: nil, - Config: c, - }, - expectedErr: app.ErrEmptyEmailBackend, - }, - { - app: app.App{ - DB: &gorm.DB{}, - Clock: clock.NewMock(), - StripeAPIBackend: nil, - EmailTemplates: mailer.Templates{}, - EmailBackend: &testutils.MockEmailbackendImplementation{}, - Config: configWithoutWebURL, - }, - expectedErr: app.ErrEmptyWebURL, - }, - } - - for idx, tc := range testCases { - t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - api := API{App: &tc.app} - _, err := api.NewRouter() - - assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") - }) - } -} diff --git a/pkg/server/handlers/limit.go b/pkg/server/handlers/limit.go index 662b0e64..91d8685a 100644 --- a/pkg/server/handlers/limit.go +++ b/pkg/server/handlers/limit.go @@ -105,8 +105,8 @@ func lookupIP(r *http.Request) string { return r.RemoteAddr } -// limit is a middleware to rate limit the handler -func limit(next http.Handler) http.HandlerFunc { +// Limit is a middleware to rate limit the handler +func Limit(next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { identifier := lookupIP(r) limiter := getVisitor(identifier) diff --git a/pkg/server/handlers/logging.go b/pkg/server/handlers/logging.go new file mode 100644 index 00000000..d809fd1f --- /dev/null +++ b/pkg/server/handlers/logging.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/dnote/dnote/pkg/server/log" +) + +// logResponseWriter wraps http.ResponseWriter to expose HTTP status code for logging. +// The optional interfaces of http.ResponseWriter are lost because of the wrapping, and +// such interfaces should be implemented if needed. (i.e. http.Pusher, http.Flusher, etc.) +type logResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *logResponseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +// Logging is a logging middleware +func Logging(inner http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + lw := logResponseWriter{w, http.StatusOK} + inner.ServeHTTP(&lw, r) + + log.WithFields(log.Fields{ + "origin": r.Header.Get("Origin"), + "remoteAddr": lookupIP(r), + "uri": r.RequestURI, + "statusCode": lw.statusCode, + "method": r.Method, + "duration": fmt.Sprintf("%dms", time.Since(start)/1000000), + "userAgent": r.Header.Get("User-Agent"), + }).Info("incoming request") + } +} diff --git a/pkg/server/handlers/main_test.go b/pkg/server/handlers/main_test.go index aeb0a5eb..1263b952 100644 --- a/pkg/server/handlers/main_test.go +++ b/pkg/server/handlers/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/handlers/routes.go b/pkg/server/handlers/routes.go deleted file mode 100644 index aeac4e30..00000000 --- a/pkg/server/handlers/routes.go +++ /dev/null @@ -1,390 +0,0 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -package handlers - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "time" - - "github.com/dnote/dnote/pkg/server/app" - "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/log" - "github.com/gorilla/mux" - "github.com/jinzhu/gorm" - "github.com/pkg/errors" - "github.com/stripe/stripe-go" -) - -// Route represents a single route -type Route struct { - Method string - Pattern string - HandlerFunc http.HandlerFunc - RateLimit bool -} - -type authHeader struct { - scheme string - credential string -} - -func parseAuthHeader(h string) (authHeader, error) { - parts := strings.Split(h, " ") - - if len(parts) != 2 { - return authHeader{}, errors.New("Invalid authorization header") - } - - parsed := authHeader{ - scheme: parts[0], - credential: parts[1], - } - - return parsed, nil -} - -// getSessionKeyFromCookie reads and returns a session key from the cookie sent by the -// request. If no session key is found, it returns an empty string -func getSessionKeyFromCookie(r *http.Request) (string, error) { - c, err := r.Cookie("id") - - if err == http.ErrNoCookie { - return "", nil - } else if err != nil { - return "", errors.Wrap(err, "reading cookie") - } - - return c.Value, nil -} - -// getSessionKeyFromAuth reads and returns a session key from the Authorization header -func getSessionKeyFromAuth(r *http.Request) (string, error) { - h := r.Header.Get("Authorization") - if h == "" { - return "", nil - } - - payload, err := parseAuthHeader(h) - if err != nil { - return "", errors.Wrap(err, "parsing the authorization header") - } - if payload.scheme != "Bearer" { - return "", errors.New("unsupported scheme") - } - - return payload.credential, nil -} - -// getCredential extracts a session key from the request from the request header. Concretely, -// it first looks at the 'Cookie' and then the 'Authorization' header. If no credential is found, -// it returns an empty string. -func getCredential(r *http.Request) (string, error) { - ret, err := getSessionKeyFromCookie(r) - if err != nil { - return "", errors.Wrap(err, "getting session key from cookie") - } - if ret != "" { - return ret, nil - } - - ret, err = getSessionKeyFromAuth(r) - if err != nil { - return "", errors.Wrap(err, "getting session key from Authorization header") - } - - return ret, nil -} - -// AuthWithSession performs user authentication with session -func AuthWithSession(db *gorm.DB, r *http.Request, p *AuthMiddlewareParams) (database.User, bool, error) { - var user database.User - - sessionKey, err := getCredential(r) - if err != nil { - return user, false, errors.Wrap(err, "getting credential") - } - if sessionKey == "" { - return user, false, nil - } - - var session database.Session - conn := db.Where("key = ?", sessionKey).First(&session) - - if conn.RecordNotFound() { - return user, false, nil - } else if err := conn.Error; err != nil { - return user, false, errors.Wrap(err, "finding session") - } - - if session.ExpiresAt.Before(time.Now()) { - return user, false, nil - } - - conn = db.Where("id = ?", session.UserID).First(&user) - - if conn.RecordNotFound() { - return user, false, nil - } else if err := conn.Error; err != nil { - return user, false, errors.Wrap(err, "finding user from token") - } - - return user, true, nil -} - -func authWithToken(db *gorm.DB, r *http.Request, tokenType string, p *AuthMiddlewareParams) (database.User, database.Token, bool, error) { - var user database.User - var token database.Token - - query := r.URL.Query() - tokenValue := query.Get("token") - if tokenValue == "" { - return user, token, false, nil - } - - conn := db.Where("value = ? AND type = ?", tokenValue, tokenType).First(&token) - if conn.RecordNotFound() { - return user, token, false, nil - } else if err := conn.Error; err != nil { - return user, token, false, errors.Wrap(err, "finding token") - } - - if token.UsedAt != nil && time.Since(*token.UsedAt).Minutes() > 10 { - return user, token, false, nil - } - - if err := db.Where("id = ?", token.UserID).First(&user).Error; err != nil { - return user, token, false, errors.Wrap(err, "finding user") - } - - return user, token, true, nil -} - -// AuthMiddlewareParams is the params for the authentication middleware -type AuthMiddlewareParams struct { - ProOnly bool -} - -func (a *API) auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok, err := AuthWithSession(a.App.DB, r, p) - if !ok { - respondUnauthorized(w) - return - } - if err != nil { - HandleError(w, "authenticating with session", err, http.StatusInternalServerError) - return - } - - if p != nil && p.ProOnly { - if !user.Cloud { - respondForbidden(w) - return - } - } - - ctx := context.WithValue(r.Context(), helpers.KeyUser, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -func (a *API) tokenAuth(next http.HandlerFunc, tokenType string, p *AuthMiddlewareParams) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, token, ok, err := authWithToken(a.App.DB, r, tokenType, p) - if err != nil { - // log the error and continue - log.ErrorWrap(err, "authenticating with token") - } - - ctx := r.Context() - - if ok { - ctx = context.WithValue(ctx, helpers.KeyToken, token) - } else { - // If token-based auth fails, fall back to session-based auth - user, ok, err = AuthWithSession(a.App.DB, r, p) - if err != nil { - HandleError(w, "authenticating with session", err, http.StatusInternalServerError) - return - } - - if !ok { - respondUnauthorized(w) - return - } - } - - if p != nil && p.ProOnly { - if !user.Cloud { - respondForbidden(w) - return - } - } - - ctx = context.WithValue(ctx, helpers.KeyUser, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -func cors(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - - // Allow browser extensions - if strings.HasPrefix(origin, "moz-extension://") || strings.HasPrefix(origin, "chrome-extension://") { - w.Header().Set("Access-Control-Allow-Origin", origin) - } - - next.ServeHTTP(w, r) - }) -} - -// logResponseWriter wraps http.ResponseWriter to expose HTTP status code for logging. -// The optional interfaces of http.ResponseWriter are lost because of the wrapping, and -// such interfaces should be implemented if needed. (i.e. http.Pusher, http.Flusher, etc.) -type logResponseWriter struct { - http.ResponseWriter - statusCode int -} - -func (w *logResponseWriter) WriteHeader(code int) { - w.statusCode = code - w.ResponseWriter.WriteHeader(code) -} - -func logging(inner http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - lw := logResponseWriter{w, http.StatusOK} - inner.ServeHTTP(&lw, r) - - log.WithFields(log.Fields{ - "origin": r.Header.Get("Origin"), - "remoteAddr": lookupIP(r), - "uri": r.RequestURI, - "statusCode": lw.statusCode, - "method": r.Method, - "duration": fmt.Sprintf("%dms", time.Since(start)/1000000), - "userAgent": r.Header.Get("User-Agent"), - }).Info("incoming request") - } -} - -func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler { - ret := h - ret = logging(ret) - - if rateLimit && os.Getenv("GO_ENV") != "TEST" { - ret = limit(ret) - } - - return ret -} - -// API is a web API configuration -type API struct { - App *app.App -} - -// init sets up the application based on the configuration -func (a *API) init() error { - if err := a.App.Validate(); err != nil { - return errors.Wrap(err, "validating the app parameters") - } - - stripe.Key = os.Getenv("StripeSecretKey") - - if a.App.StripeAPIBackend != nil { - stripe.SetBackend(stripe.APIBackend, a.App.StripeAPIBackend) - } - - return nil -} - -// NewRouter creates and returns a new router -func (a *API) NewRouter() (*mux.Router, error) { - if err := a.init(); err != nil { - return nil, errors.Wrap(err, "initializing app") - } - - proOnly := AuthMiddlewareParams{ProOnly: true} - - var routes = []Route{ - // internal - {"GET", "/health", a.checkHealth, false}, - {"GET", "/me", a.auth(a.getMe, nil), true}, - {"POST", "/verification-token", a.auth(a.createVerificationToken, nil), true}, - {"PATCH", "/verify-email", a.verifyEmail, true}, - {"POST", "/reset-token", a.createResetToken, true}, - {"PATCH", "/reset-password", a.resetPassword, true}, - {"PATCH", "/account/profile", a.auth(a.updateProfile, nil), true}, - {"PATCH", "/account/password", a.auth(a.updatePassword, nil), true}, - {"GET", "/account/email-preference", a.tokenAuth(a.getEmailPreference, database.TokenTypeEmailPreference, nil), true}, - {"PATCH", "/account/email-preference", a.tokenAuth(a.updateEmailPreference, database.TokenTypeEmailPreference, nil), true}, - {"POST", "/subscriptions", a.auth(a.createSub, nil), true}, - {"PATCH", "/subscriptions", a.auth(a.updateSub, nil), true}, - {"POST", "/webhooks/stripe", a.stripeWebhook, true}, - {"GET", "/subscriptions", a.auth(a.getSub, nil), true}, - {"GET", "/stripe_source", a.auth(a.getStripeSource, nil), true}, - {"PATCH", "/stripe_source", a.auth(a.updateStripeSource, nil), true}, - {"GET", "/notes", a.auth(a.getNotes, nil), false}, - {"GET", "/notes/{noteUUID}", a.getNote, true}, - {"GET", "/calendar", a.auth(a.getCalendar, nil), true}, - - // v3 - {"GET", "/v3/sync/fragment", cors(a.auth(a.GetSyncFragment, &proOnly)), false}, - {"GET", "/v3/sync/state", cors(a.auth(a.GetSyncState, &proOnly)), false}, - {"OPTIONS", "/v3/books", cors(a.BooksOptions), true}, - {"GET", "/v3/books", cors(a.auth(a.GetBooks, &proOnly)), true}, - {"GET", "/v3/books/{bookUUID}", cors(a.auth(a.GetBook, &proOnly)), true}, - {"POST", "/v3/books", cors(a.auth(a.CreateBook, &proOnly)), false}, - {"PATCH", "/v3/books/{bookUUID}", cors(a.auth(a.UpdateBook, &proOnly)), false}, - {"DELETE", "/v3/books/{bookUUID}", cors(a.auth(a.DeleteBook, &proOnly)), false}, - {"OPTIONS", "/v3/notes", cors(a.NotesOptions), true}, - {"POST", "/v3/notes", cors(a.auth(a.CreateNote, &proOnly)), false}, - {"PATCH", "/v3/notes/{noteUUID}", a.auth(a.UpdateNote, &proOnly), false}, - {"DELETE", "/v3/notes/{noteUUID}", a.auth(a.DeleteNote, &proOnly), false}, - {"POST", "/v3/signin", cors(a.signin), true}, - {"OPTIONS", "/v3/signout", cors(a.signoutOptions), true}, - {"POST", "/v3/signout", cors(a.signout), true}, - {"POST", "/v3/register", a.register, true}, - } - - router := mux.NewRouter().StrictSlash(true) - - router.PathPrefix("/v1").Handler(applyMiddleware(a.notSupported, true)) - router.PathPrefix("/v2").Handler(applyMiddleware(a.notSupported, true)) - - for _, route := range routes { - handler := route.HandlerFunc - - router. - Methods(route.Method). - Path(route.Pattern). - Handler(applyMiddleware(handler, route.RateLimit)) - } - - return router, nil -} diff --git a/pkg/server/handlers/semver.go b/pkg/server/handlers/semver.go deleted file mode 100644 index 398a60cd..00000000 --- a/pkg/server/handlers/semver.go +++ /dev/null @@ -1,62 +0,0 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -package handlers - -import ( - "regexp" - "strconv" - - "github.com/pkg/errors" -) - -type semver struct { - Major int - Minor int - Patch int -} - -func parseSemver(version string) (semver, error) { - re := regexp.MustCompile(`(\d*)\.(\d*)\.(\d*)`) - match := re.FindStringSubmatch(version) - - if len(match) != 4 { - return semver{}, errors.Errorf("invalid semver %s", version) - } - - major, err := strconv.Atoi(match[1]) - if err != nil { - return semver{}, errors.Wrap(err, "converting major version to int") - } - minor, err := strconv.Atoi(match[2]) - if err != nil { - return semver{}, errors.Wrap(err, "converting minor version to int") - } - patch, err := strconv.Atoi(match[3]) - if err != nil { - return semver{}, errors.Wrap(err, "converting patch version to int") - } - - ret := semver{ - Major: major, - Minor: minor, - Patch: patch, - } - - return ret, nil -} diff --git a/pkg/server/handlers/subscription.go b/pkg/server/handlers/subscription.go deleted file mode 100644 index 783447d8..00000000 --- a/pkg/server/handlers/subscription.go +++ /dev/null @@ -1,561 +0,0 @@ -/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -package handlers - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "os" - "strings" - - "github.com/dnote/dnote/pkg/server/app" - "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/log" - "github.com/jinzhu/gorm" - "github.com/pkg/errors" - "github.com/stripe/stripe-go" - "github.com/stripe/stripe-go/card" - "github.com/stripe/stripe-go/customer" - "github.com/stripe/stripe-go/paymentsource" - "github.com/stripe/stripe-go/source" - "github.com/stripe/stripe-go/sub" - "github.com/stripe/stripe-go/webhook" -) - -func getOrCreateStripeCustomer(tx *gorm.DB, user database.User) (*stripe.Customer, error) { - if user.StripeCustomerID != "" { - c, err := customer.Get(user.StripeCustomerID, nil) - if err != nil { - return nil, errors.Wrap(err, "getting customer") - } - - return c, nil - } - - var account database.Account - if err := tx.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - return nil, errors.Wrap(err, "finding account") - } - - customerParams := &stripe.CustomerParams{ - Email: &account.Email.String, - } - c, err := customer.New(customerParams) - if err != nil { - return nil, errors.Wrap(err, "creating customer") - } - - user.StripeCustomerID = c.ID - if err := tx.Save(&user).Error; err != nil { - return nil, errors.Wrap(err, "updating user") - } - - return c, nil -} - -func addCustomerSource(customerID, sourceID string) (*stripe.PaymentSource, error) { - params := &stripe.CustomerSourceParams{ - Customer: stripe.String(customerID), - Source: &stripe.SourceParams{ - Token: stripe.String(sourceID), - }, - } - - src, err := paymentsource.New(params) - if err != nil { - return nil, errors.Wrap(err, "creating source for customer") - } - - return src, nil -} - -func removeCustomerSource(customerID, sourceID string) (*stripe.Source, error) { - params := &stripe.SourceObjectDetachParams{ - Customer: stripe.String(customerID), - } - s, err := source.Detach(sourceID, params) - if err != nil { - return nil, err - } - - return s, nil -} - -func createCustomerSubscription(customerID, planID string) (*stripe.Subscription, error) { - subParams := &stripe.SubscriptionParams{ - Customer: stripe.String(customerID), - Items: []*stripe.SubscriptionItemsParams{ - { - Plan: stripe.String(planID), - }, - }, - } - - s, err := sub.New(subParams) - if err != nil { - return nil, errors.Wrap(err, "creating subscription for customer") - } - - return s, nil -} - -type createSubPayload struct { - Yearly bool `json:"yearly"` - Source stripe.Source `json:"source"` - Country string `json:"country"` -} - -// createSub creates a subscription for a the current user -func (a *API) createSub(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return - } - var account database.Account - if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - HandleError(w, "getting user", err, http.StatusInternalServerError) - return - } - - var payload createSubPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - HandleError(w, "decoding params", err, http.StatusBadRequest) - return - } - - tx := a.App.DB.Begin() - - if err := tx.Model(&user). - Update(map[string]interface{}{ - "cloud": true, - "billing_country": payload.Country, - }).Error; err != nil { - tx.Rollback() - HandleError(w, "updating user", err, http.StatusInternalServerError) - return - } - - customer, err := getOrCreateStripeCustomer(tx, user) - if err != nil { - tx.Rollback() - HandleError(w, "getting customer", err, http.StatusInternalServerError) - return - } - - if _, err = addCustomerSource(customer.ID, payload.Source.ID); err != nil { - tx.Rollback() - HandleError(w, "attaching source", err, http.StatusInternalServerError) - return - } - - var planID string - if payload.Yearly { - planID = os.Getenv("StripeYearlyPlanID") - } else { - planID = os.Getenv("StripeMonthlyPlanID") - } - - if _, err := createCustomerSubscription(customer.ID, planID); err != nil { - tx.Rollback() - HandleError(w, "creating subscription", err, http.StatusInternalServerError) - return - } - - if err := tx.Commit().Error; err != nil { - HandleError(w, "committing a subscription transaction", err, http.StatusInternalServerError) - return - } - - if err := a.App.SendSubscriptionConfirmationEmail(account.Email.String); err != nil { - log.ErrorWrap(err, "sending subscription confirmation email") - } - - w.WriteHeader(http.StatusOK) -} - -type updateSubPayload struct { - StripeSubcriptionID string `json:"stripe_subscription_id"` - Op string `json:"op"` - Body *interface{} `json:"body"` -} - -var ( - updateSubOpCancel = "cancel" - updateSubOpReactivate = "reactivate" -) - -var validUpdateSubOp = []string{ - updateSubOpCancel, - updateSubOpReactivate, -} - -func validateUpdateSubPayload(p updateSubPayload) error { - var isOpValid bool - - for _, op := range validUpdateSubOp { - if p.Op == op { - isOpValid = true - break - } - } - - if !isOpValid { - return errors.Errorf("Invalid operation %s", p.Op) - } - - if p.StripeSubcriptionID == "" { - return errors.New("stripe_subscription_id is required") - } - - return nil -} - -func (a *API) updateSub(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return - } - if user.StripeCustomerID == "" { - HandleError(w, "Customer does not exist", nil, http.StatusForbidden) - return - } - - var payload updateSubPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - HandleError(w, "decoding params", err, http.StatusBadRequest) - return - } - if err := validateUpdateSubPayload(payload); err != nil { - HandleError(w, "invalid payload", err, http.StatusBadRequest) - return - } - - var err error - if payload.Op == updateSubOpCancel { - err = a.App.CancelSub(payload.StripeSubcriptionID, user) - } else if payload.Op == updateSubOpReactivate { - err = a.App.ReactivateSub(payload.StripeSubcriptionID, user) - } - - if err != nil { - var statusCode int - if err == app.ErrSubscriptionActive { - statusCode = http.StatusBadRequest - } else { - statusCode = http.StatusInternalServerError - } - - HandleError(w, fmt.Sprintf("during operation %s", payload.Op), err, statusCode) - return - } - - w.WriteHeader(http.StatusOK) -} - -// GetSubResponseItem represents a subscription item in the response for get subscription -type GetSubResponseItem struct { - PlanID string `json:"plan_id"` - ProductID string `json:"product_id"` -} - -// GetSubResponse is a response for getSub -type GetSubResponse struct { - SubscriptionID string `json:"id"` - Items []GetSubResponseItem `json:"items"` - CurrentPeriodStart int64 `json:"current_period_start"` - CurrentPeriodEnd int64 `json:"current_period_end"` - Status stripe.SubscriptionStatus `json:"status"` - CancelAtPeriodEnd bool `json:"cancel_at_period_end"` -} - -func respondWithEmptySub(w http.ResponseWriter) { - emptyGetSubResponse := GetSubResponse{ - Items: []GetSubResponseItem{}, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(emptyGetSubResponse); err != nil { - HandleError(w, "encoding response", err, http.StatusInternalServerError) - return - } -} - -func (a *API) getSub(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return - } - if user.StripeCustomerID == "" { - respondWithEmptySub(w) - return - } - - listParams := &stripe.SubscriptionListParams{} - listParams.Filters.AddFilter("customer", "", user.StripeCustomerID) - listParams.Filters.AddFilter("status", "", "active") - i := sub.List(listParams) - - if !i.Next() { - if err := i.Err(); err != nil { - HandleError(w, "fetching subscription", err, http.StatusInternalServerError) - return - } - - // If no active subscription exists, respond with an empty subscription - respondWithEmptySub(w) - return - } - - s := i.Subscription() - - resp := GetSubResponse{ - SubscriptionID: s.ID, - CurrentPeriodStart: s.CurrentPeriodStart, - CurrentPeriodEnd: s.CurrentPeriodEnd, - Status: s.Status, - CancelAtPeriodEnd: s.CancelAtPeriodEnd, - } - - for _, item := range s.Items.Data { - i := GetSubResponseItem{ - PlanID: item.Plan.ID, - ProductID: item.Plan.Product.ID, - } - resp.Items = append(resp.Items, i) - } - - respondJSON(w, http.StatusOK, resp) -} - -// GetStripeSourceResponse is a response for getStripeToken -type GetStripeSourceResponse struct { - Brand string `json:"brand"` - Last4 string `json:"last4"` - ExpMonth uint8 `json:"exp_month"` - ExpYear uint16 `json:"exp_year"` -} - -func respondWithEmptyStripeToken(w http.ResponseWriter) { - var resp GetStripeSourceResponse - - respondJSON(w, http.StatusOK, resp) -} - -// getStripeCard retrieves card information from stripe and returns a stripe.Card -// It handles legacy 'card' resource which have 'card_' prefixes, as well as the -// more up-to-date 'source' resources which have 'src_' prefixes. -func getStripeCard(stripeCustomerID, sourceID string) (*stripe.Card, error) { - if strings.HasPrefix(sourceID, "card_") { - params := &stripe.CardParams{ - Customer: stripe.String(stripeCustomerID), - } - cd, err := card.Get(sourceID, params) - if err != nil { - return nil, errors.Wrap(err, "fetching card") - } - - return cd, nil - } else if strings.HasPrefix(sourceID, "src_") { - src, err := source.Get(sourceID, nil) - if err != nil { - return nil, errors.Wrap(err, "fetching source") - } - - brand, ok := src.TypeData["brand"].(string) - if !ok { - return nil, errors.New("casting brand") - } - last4, ok := src.TypeData["last4"].(string) - if !ok { - return nil, errors.New("casting last4") - } - expMonth, ok := src.TypeData["exp_month"].(float64) - if !ok { - return nil, errors.New("casting exp_month") - } - expYear, ok := src.TypeData["exp_year"].(float64) - if !ok { - return nil, errors.New("casting exp_year") - } - - cd := &stripe.Card{ - Brand: stripe.CardBrand(brand), - Last4: last4, - ExpMonth: uint8(expMonth), - ExpYear: uint16(expYear), - } - - return cd, nil - } - - return nil, errors.Errorf("malformed sourceID %s", sourceID) -} - -type updateStripeSourcePayload struct { - Source stripe.Source `json:"source"` - Country string `json:"country"` -} - -func validateUpdateStripeSourcePayload(p updateStripeSourcePayload) error { - if p.Source.ID == "" { - return errors.New("empty source id") - } - if p.Country == "" { - return errors.New("empty country") - } - - return nil -} - -func (a *API) updateStripeSource(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return - } - - var payload updateStripeSourcePayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - HandleError(w, "decoding params", err, http.StatusBadRequest) - return - } - if err := validateUpdateStripeSourcePayload(payload); err != nil { - http.Error(w, errors.Wrap(err, "validating payload").Error(), http.StatusBadRequest) - return - } - - tx := a.App.DB.Begin() - - if err := tx.Model(&user). - Update(map[string]interface{}{ - "billing_country": payload.Country, - }).Error; err != nil { - tx.Rollback() - HandleError(w, "updating user", err, http.StatusInternalServerError) - return - } - - c, err := customer.Get(user.StripeCustomerID, nil) - if err != nil { - tx.Rollback() - HandleError(w, "retriving customer", err, http.StatusInternalServerError) - return - } - - if _, err := removeCustomerSource(user.StripeCustomerID, c.DefaultSource.ID); err != nil { - tx.Rollback() - HandleError(w, "removing source", err, http.StatusInternalServerError) - return - } - - if _, err := addCustomerSource(user.StripeCustomerID, payload.Source.ID); err != nil { - tx.Rollback() - HandleError(w, "attaching source", err, http.StatusInternalServerError) - return - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - HandleError(w, "committing transaction", err, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (a *API) getStripeSource(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(helpers.KeyUser).(database.User) - if !ok { - HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) - return - } - if user.StripeCustomerID == "" { - respondWithEmptyStripeToken(w) - return - } - - c, err := customer.Get(user.StripeCustomerID, nil) - if err != nil { - HandleError(w, "fetching stripe customer", err, http.StatusInternalServerError) - return - } - - if c.DefaultSource == nil { - respondWithEmptyStripeToken(w) - return - } - - cd, err := getStripeCard(user.StripeCustomerID, c.DefaultSource.ID) - if err != nil { - HandleError(w, "fetching stripe source", err, http.StatusInternalServerError) - return - } - - resp := GetStripeSourceResponse{ - Brand: string(cd.Brand), - Last4: cd.Last4, - ExpMonth: cd.ExpMonth, - ExpYear: cd.ExpYear, - } - - respondJSON(w, http.StatusOK, resp) -} - -func (a *API) stripeWebhook(w http.ResponseWriter, req *http.Request) { - body, err := ioutil.ReadAll(req.Body) - if err != nil { - HandleError(w, "reading body", err, http.StatusServiceUnavailable) - return - } - - webhookSecret := os.Getenv("StripeWebhookSecret") - event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), webhookSecret) - if err != nil { - HandleError(w, "verifying stripe webhook signature", err, http.StatusBadRequest) - return - } - - switch event.Type { - case "customer.subscription.deleted": - { - var subscription stripe.Subscription - if json.Unmarshal(event.Data.Raw, &subscription); err != nil { - HandleError(w, "unmarshaling payload", err, http.StatusBadRequest) - return - } - - a.App.MarkUnsubscribed(subscription.Customer.ID) - } - default: - { - msg := fmt.Sprintf("Unsupported webhook event type %s", event.Type) - HandleError(w, msg, err, http.StatusBadRequest) - return - } - } - - // Return a response to acknowledge receipt of the event - w.WriteHeader(http.StatusOK) -} diff --git a/pkg/server/job/remind/inactive_test.go b/pkg/server/job/remind/inactive_test.go index d8380d5f..2f63cdcf 100644 --- a/pkg/server/job/remind/inactive_test.go +++ b/pkg/server/job/remind/inactive_test.go @@ -46,7 +46,7 @@ func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) } func TestDoInactive(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) t1 := time.Now() @@ -119,7 +119,7 @@ func TestDoInactive(t *testing.T) { } func TestDoInactive_Cooldown(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // setup sets up an inactive user setup := func(t *testing.T, now time.Time, email string) database.User { diff --git a/pkg/server/job/remind/main_test.go b/pkg/server/job/remind/main_test.go index 8a6f5f73..c92e1bef 100644 --- a/pkg/server/job/remind/main_test.go +++ b/pkg/server/job/remind/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/main.go b/pkg/server/main.go index 504fdf9d..a3108760 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -25,10 +25,10 @@ import ( "net/http" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/api" "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/config" "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/job" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/web" @@ -68,8 +68,7 @@ func initWebContext(db *gorm.DB) web.Context { } func initServer(a app.App) (*http.ServeMux, error) { - api := handlers.API{App: &a} - apiRouter, err := api.NewRouter() + apiRouter, err := api.NewRouter(&api.API{App: &a}) if err != nil { return nil, errors.Wrap(err, "initializing router") } @@ -104,12 +103,11 @@ func initApp(c config.Config) app.App { db := initDB(c) return app.App{ - DB: db, - Clock: clock.New(), - StripeAPIBackend: nil, - EmailTemplates: mailer.NewTemplates(nil), - EmailBackend: &mailer.SimpleBackendImplementation{}, - Config: c, + DB: db, + Clock: clock.New(), + EmailTemplates: mailer.NewTemplates(nil), + EmailBackend: &mailer.SimpleBackendImplementation{}, + Config: c, } } diff --git a/pkg/server/operations/main_test.go b/pkg/server/operations/main_test.go index 1bd6c07e..5421d78f 100644 --- a/pkg/server/operations/main_test.go +++ b/pkg/server/operations/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/operations/notes_test.go b/pkg/server/operations/notes_test.go index 415950cb..a9f1e816 100644 --- a/pkg/server/operations/notes_test.go +++ b/pkg/server/operations/notes_test.go @@ -31,7 +31,7 @@ func TestGetNote(t *testing.T) { user := testutils.SetupUserData() anotherUser := testutils.SetupUserData() - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) b1 := database.Book{ UserID: user.ID, @@ -121,7 +121,7 @@ func TestGetNote(t *testing.T) { func TestGetNote_nonexistent(t *testing.T) { user := testutils.SetupUserData() - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) b1 := database.Book{ UserID: user.ID, diff --git a/pkg/server/permissions/permissions_test.go b/pkg/server/permissions/permissions_test.go index 60b46ffe..3508400c 100644 --- a/pkg/server/permissions/permissions_test.go +++ b/pkg/server/permissions/permissions_test.go @@ -31,7 +31,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } @@ -40,7 +40,7 @@ func TestViewNote(t *testing.T) { user := testutils.SetupUserData() anotherUser := testutils.SetupUserData() - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) b1 := database.Book{ UserID: user.ID, diff --git a/pkg/server/session/session.go b/pkg/server/session/session.go new file mode 100644 index 00000000..cf0eacd2 --- /dev/null +++ b/pkg/server/session/session.go @@ -0,0 +1,23 @@ +package session + +import ( + "github.com/dnote/dnote/pkg/server/database" +) + +// Session represents user session +type Session struct { + UUID string `json:"uuid"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Pro bool `json:"pro"` +} + +// New returns a new session for the given user +func New(user database.User, account database.Account) Session { + return Session{ + UUID: user.UUID, + Pro: user.Cloud, + Email: account.Email.String, + EmailVerified: account.EmailVerified, + } +} diff --git a/pkg/server/session/session_test.go b/pkg/server/session/session_test.go new file mode 100644 index 00000000..bd05f51e --- /dev/null +++ b/pkg/server/session/session_test.go @@ -0,0 +1,67 @@ +/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package session + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" +) + +func TestNew(t *testing.T) { + u1 := database.User{UUID: "0f5f0054-d23f-4be1-b5fb-57673109e9cb", Cloud: true} + a1 := database.Account{Email: database.ToNullString("alice@example.com"), EmailVerified: false} + + u2 := database.User{UUID: "718a1041-bbe6-496e-bbe4-ea7e572c295e", Cloud: false} + a2 := database.Account{Email: database.ToNullString("bob@example.com"), EmailVerified: false} + + testCases := []struct { + user database.User + account database.Account + expectedPro bool + }{ + { + user: u1, + account: a1, + expectedPro: true, + }, + { + user: u2, + account: a2, + expectedPro: false, + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("user pro %t", tc.expectedPro), func(t *testing.T) { + // Execute + got := New(tc.user, tc.account) + expected := Session{ + UUID: tc.user.UUID, + Pro: tc.expectedPro, + Email: tc.account.Email.String, + EmailVerified: tc.account.EmailVerified, + } + + assert.DeepEqual(t, got, expected, "result mismatch") + }) + } +} diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index a37f538c..ba3cd5ae 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -25,7 +25,6 @@ import ( "fmt" "math/rand" "net/http" - "net/http/httptest" "strings" "sync" "testing" @@ -35,7 +34,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/jinzhu/gorm" "github.com/pkg/errors" - "github.com/stripe/stripe-go" "golang.org/x/crypto/bcrypt" ) @@ -59,29 +57,29 @@ func InitTestDB() { } // ClearData deletes all records from the database -func ClearData() { - if err := DB.Delete(&database.Book{}).Error; err != nil { +func ClearData(db *gorm.DB) { + if err := db.Delete(&database.Book{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear books")) } - if err := DB.Delete(&database.Note{}).Error; err != nil { + if err := db.Delete(&database.Note{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear notes")) } - if err := DB.Delete(&database.Notification{}).Error; err != nil { + if err := db.Delete(&database.Notification{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear notifications")) } - if err := DB.Delete(&database.User{}).Error; err != nil { + if err := db.Delete(&database.User{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear users")) } - if err := DB.Delete(&database.Account{}).Error; err != nil { + if err := db.Delete(&database.Account{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear accounts")) } - if err := DB.Delete(&database.Token{}).Error; err != nil { + if err := db.Delete(&database.Token{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear tokens")) } - if err := DB.Delete(&database.EmailPreference{}).Error; err != nil { + if err := db.Delete(&database.EmailPreference{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear email preferences")) } - if err := DB.Delete(&database.Session{}).Error; err != nil { + if err := db.Delete(&database.Session{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear sessions")) } } @@ -168,8 +166,8 @@ func HTTPDo(t *testing.T, req *http.Request) *http.Response { return res } -// HTTPAuthDo makes an HTTP request with an appropriate authorization header for a user -func HTTPAuthDo(t *testing.T, req *http.Request, user database.User) *http.Response { +// SetReqAuthHeader sets the authorization header in the given request for the given user +func SetReqAuthHeader(t *testing.T, req *http.Request, user database.User) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { t.Fatal(errors.Wrap(err, "reading random bits")) @@ -185,6 +183,11 @@ func HTTPAuthDo(t *testing.T, req *http.Request, user database.User) *http.Respo } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session.Key)) +} + +// HTTPAuthDo makes an HTTP request with an appropriate authorization header for a user +func HTTPAuthDo(t *testing.T, req *http.Request, user database.User) *http.Response { + SetReqAuthHeader(t, req, user) return HTTPDo(t, req) @@ -223,20 +226,6 @@ func GetCookieByName(cookies []*http.Cookie, name string) *http.Cookie { return ret } -// CreateMockStripeBackend returns a mock stripe backend that uses -// the given test server -func CreateMockStripeBackend(ts *httptest.Server) stripe.Backend { - stripeMockBackend := stripe.GetBackendWithConfig( - stripe.APIBackend, - &stripe.BackendConfig{ - URL: ts.URL, - HTTPClient: ts.Client(), - }, - ) - - return stripeMockBackend -} - // MustRespondJSON responds with the JSON-encoding of the given interface. If the encoding // fails, the test fails. It is used by test servers. func MustRespondJSON(t *testing.T, w http.ResponseWriter, i interface{}, message string) { diff --git a/pkg/server/tmpl/app_test.go b/pkg/server/tmpl/app_test.go index fd8c013f..6a1f08f5 100644 --- a/pkg/server/tmpl/app_test.go +++ b/pkg/server/tmpl/app_test.go @@ -50,7 +50,7 @@ func TestAppShellExecute(t *testing.T) { }) t.Run("note", func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) user := testutils.SetupUserData() b1 := database.Book{ diff --git a/pkg/server/tmpl/main_test.go b/pkg/server/tmpl/main_test.go index 13041126..25a9741c 100644 --- a/pkg/server/tmpl/main_test.go +++ b/pkg/server/tmpl/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/token/main_test.go b/pkg/server/token/main_test.go index e6440ef2..b9cfb697 100644 --- a/pkg/server/token/main_test.go +++ b/pkg/server/token/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/pkg/server/token/token_test.go b/pkg/server/token/token_test.go index 92a4c23b..79c643da 100644 --- a/pkg/server/token/token_test.go +++ b/pkg/server/token/token_test.go @@ -39,7 +39,7 @@ func TestCreate(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("token type %s", tc.kind), func(t *testing.T) { - defer testutils.ClearData() + defer testutils.ClearData(testutils.DB) // Set up u := testutils.SetupUserData() diff --git a/pkg/server/web/handlers.go b/pkg/server/web/handlers.go index ba7a8de4..870408a8 100644 --- a/pkg/server/web/handlers.go +++ b/pkg/server/web/handlers.go @@ -108,7 +108,7 @@ func getRootHandler(c Context) http.HandlerFunc { if errors.Cause(err) == tmpl.ErrNotFound { handlers.RespondNotFound(w) } else { - handlers.HandleError(w, "executing app shell", err, http.StatusInternalServerError) + handlers.DoError(w, "executing app shell", err, http.StatusInternalServerError) } return } diff --git a/pkg/server/web/main_test.go b/pkg/server/web/main_test.go index 2d2b3e78..b398db05 100644 --- a/pkg/server/web/main_test.go +++ b/pkg/server/web/main_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testutils.InitTestDB() code := m.Run() - testutils.ClearData() + testutils.ClearData(testutils.DB) os.Exit(code) } diff --git a/scripts/web/build-prod.sh b/scripts/web/build-prod.sh index 5372700a..7680ed35 100755 --- a/scripts/web/build-prod.sh +++ b/scripts/web/build-prod.sh @@ -17,5 +17,6 @@ ASSET_BASE_URL="$assetBaseUrl" \ ROOT_URL="$rootUrl" \ PUBLIC_PATH="$publicPath" \ COMPILED_PATH="$compiledPath" \ +STANDALONE=true \ VERSION="$VERSION" \ "$dir/build.sh" diff --git a/scripts/web/build.sh b/scripts/web/build.sh index 94945e10..2c1f24ff 100755 --- a/scripts/web/build.sh +++ b/scripts/web/build.sh @@ -4,7 +4,7 @@ set -ex dir=$(dirname "${BASH_SOURCE[0]}") basePath="$dir/../.." -isTest=${IS_TEST:-false} +standalone=${STANDALONE:-true} set -u rm -rf "$basePath/web/public" @@ -22,7 +22,7 @@ pushd "$basePath/web" "$basePath"/web/node_modules/.bin/webpack\ --colors\ --display-error-details\ - --env.isTest="$isTest"\ + --env.standalone="$standalone"\ --config "$(realpath "$basePath/web/webpack/prod.config.js")" NODE_ENV=PRODUCTION \ diff --git a/scripts/web/dev.sh b/scripts/web/dev.sh index cbe972fa..96cfc6f7 100755 --- a/scripts/web/dev.sh +++ b/scripts/web/dev.sh @@ -29,7 +29,7 @@ set +a COMPILED_PATH="$appPath"/compiled \ PUBLIC_PATH="$appPath"/public \ COMPILED_PATH="$basePath/web/compiled" \ - IS_TEST=true \ + STANDALONE=true \ VERSION="$VERSION" \ WEBPACK_HOST="0.0.0.0" \ "$dir/webpack-dev.sh" diff --git a/scripts/web/webpack-dev.sh b/scripts/web/webpack-dev.sh index 6506dae2..7a44bc88 100755 --- a/scripts/web/webpack-dev.sh +++ b/scripts/web/webpack-dev.sh @@ -16,13 +16,12 @@ appPath="$basePath/web" ASSET_BASE_URL=$ASSET_BASE_URL \ COMPILED_PATH=$COMPILED_PATH \ PUBLIC_PATH=$PUBLIC_PATH \ - IS_TEST=true \ node "$dir/placeholder.js" && ROOT_URL=$ROOT_URL \ VERSION="$VERSION" \ "$appPath"/node_modules/.bin/webpack-dev-server \ - --env.isTest="$IS_TEST" \ + --env.standalone="$STANDALONE" \ --host "$WEBPACK_HOST" \ --config "$appPath"/webpack/dev.config.js ) diff --git a/web/declrations.d.ts b/web/declrations.d.ts index 7d93a96b..08b5aef0 100644 --- a/web/declrations.d.ts +++ b/web/declrations.d.ts @@ -20,7 +20,7 @@ declare module '*.scss'; // globals defined by webpack-define-plugin -declare const __STRIPE_PUBLIC_KEY__: string; declare const __ROOT_URL__: string; declare const __CDN_URL__: string; declare const __VERSION__: string; +declare const __STANDALONE__: string; diff --git a/web/package-lock.json b/web/package-lock.json index c853b243..812a22be 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10631,14 +10631,6 @@ "shallowequal": "^1.0.1" } }, - "react-stripe-elements": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/react-stripe-elements/-/react-stripe-elements-5.1.0.tgz", - "integrity": "sha512-4UlzOLNdbJsZr4JwbTdJjxhedAfalDDtfEYLHEBo0MKG0KgSLRAeIJup0/6NSpKtBLK4ieOMAwvyln9ICOSilQ==", - "requires": { - "prop-types": "15.7.2" - } - }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", diff --git a/web/package.json b/web/package.json index 3eb1b68d..8745a296 100644 --- a/web/package.json +++ b/web/package.json @@ -65,7 +65,6 @@ "react-router": "^5.1.2", "react-router-config": "^5.1.1", "react-router-dom": "^5.1.2", - "react-stripe-elements": "^5.1.0", "redux": "^4.0.5", "redux-thunk": "^2.1.0", "regenerator-runtime": "^0.13.5", diff --git a/web/src/components/App/index.tsx b/web/src/components/App/index.tsx index db3e74cf..092b77e3 100644 --- a/web/src/components/App/index.tsx +++ b/web/src/components/App/index.tsx @@ -31,8 +31,7 @@ import { homePathDef, noFooterPaths, noHeaderPaths, - notePathDef, - subscriptionPaths + notePathDef } from 'web/libs/paths'; import render from '../../routes'; import { useDispatch, useSelector } from '../../store'; @@ -45,7 +44,6 @@ import MobileMenu from '../Common/MobileMenu'; import SystemMessage from '../Common/SystemMessage'; import NormalHeader from '../Header/Normal'; import NoteHeader from '../Header/Note'; -import SubscriptionHeader from '../Header/SubscriptionHeader'; import Splash from '../Splash'; import TabBar from '../TabBar'; import './App.global.scss'; @@ -179,7 +177,6 @@ const App: React.FunctionComponent = ({ location }) => { - diff --git a/web/src/components/Common/Note/Footer.tsx b/web/src/components/Common/Note/Footer.tsx index 72fc26d2..e9cc9bc8 100644 --- a/web/src/components/Common/Note/Footer.tsx +++ b/web/src/components/Common/Note/Footer.tsx @@ -20,13 +20,11 @@ import React from 'react'; import { NoteData } from 'jslib/operations/types'; import Time from '../../Common/Time'; -import { nanosecToMillisec } from '../../../helpers/time'; import formatTime from '../../../helpers/time/format'; import { timeAgo } from '../../../helpers/time'; import styles from './Note.scss'; -function formatAddedOn(ts: number): string { - const ms = nanosecToMillisec(ts); +function formatAddedOn(ms: number): string { const d = new Date(ms); return formatTime(d, '%MMMM %DD, %YYYY'); @@ -49,11 +47,13 @@ const Footer: React.FunctionComponent = ({ return null; } + const updatedAt = new Date(note.updatedAt).getTime(); + let timeText; if (useTimeAgo) { - timeText = timeAgo(nanosecToMillisec(note.addedOn)); + timeText = timeAgo(updatedAt); } else { - timeText = formatAddedOn(note.addedOn); + timeText = formatAddedOn(updatedAt); } return ( @@ -63,7 +63,7 @@ const Footer: React.FunctionComponent = ({