mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
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
This commit is contained in:
parent
91d5c710ed
commit
6acc2936e3
120 changed files with 1411 additions and 4772 deletions
|
|
@ -60,6 +60,7 @@
|
|||
"__STRIPE_PUBLIC_KEY__": true,
|
||||
"__ROOT_URL__": true,
|
||||
"__CDN_URL__": true,
|
||||
"__STANDALONE__": true,
|
||||
"socket": true,
|
||||
"webpackIsomorphicTools": true,
|
||||
"StripeCheckout": true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
browser/src/global.d.ts
vendored
1
browser/src/global.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
23
go.mod
23
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
|
||||
)
|
||||
|
|
|
|||
96
go.sum
96
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
8
pkg/server/.gitignore
vendored
8
pkg/server/.gitignore
vendored
|
|
@ -6,11 +6,3 @@ test-dnote
|
|||
/dist
|
||||
/build
|
||||
server
|
||||
|
||||
# Elastic Beanstalk Files
|
||||
/tmp
|
||||
application.zip
|
||||
test-api
|
||||
/dump
|
||||
api
|
||||
/build
|
||||
|
|
|
|||
181
pkg/server/api/auth.go
Normal file
181
pkg/server/api/auth.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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{
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
85
pkg/server/api/helpers.go
Normal file
85
pkg/server/api/helpers.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -16,12 +16,20 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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{
|
||||
116
pkg/server/api/routes.go
Normal file
116
pkg/server/api/routes.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
161
pkg/server/api/routes_test.go
Normal file
161
pkg/server/api/routes_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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"))
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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{
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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{
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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{
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
42
pkg/server/handlers/logging.go
Normal file
42
pkg/server/handlers/logging.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
pkg/server/session/session.go
Normal file
23
pkg/server/session/session.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
67
pkg/server/session/session_test.go
Normal file
67
pkg/server/session/session_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
|
|||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
testutils.ClearData(testutils.DB)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ ASSET_BASE_URL="$assetBaseUrl" \
|
|||
ROOT_URL="$rootUrl" \
|
||||
PUBLIC_PATH="$publicPath" \
|
||||
COMPILED_PATH="$compiledPath" \
|
||||
STANDALONE=true \
|
||||
VERSION="$VERSION" \
|
||||
"$dir/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 \
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
2
web/declrations.d.ts
vendored
2
web/declrations.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Props> = ({ location }) => {
|
|||
|
||||
<Switch>
|
||||
<Route path={noHeaderPaths} exact component={null} />
|
||||
<Route path={subscriptionPaths} exact component={SubscriptionHeader} />
|
||||
<Route path={notePathDef} exact component={NoteHeader} />
|
||||
<Route path={homePathDef} component={NormalHeader} />
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -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<Props> = ({
|
|||
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<Props> = ({
|
|||
<Time
|
||||
id="note-ts"
|
||||
text={timeText}
|
||||
ms={nanosecToMillisec(note.addedOn)}
|
||||
ms={updatedAt}
|
||||
tooltipAlignment="left"
|
||||
tooltipDirection="bottom"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@
|
|||
|
||||
import React, { Fragment } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getSubscriptionPath } from 'web/libs/paths';
|
||||
import LockIcon from '../Icons/Lock';
|
||||
import { useSelector } from '../../store';
|
||||
|
||||
|
|
@ -51,12 +49,9 @@ const PayWall: React.FunctionComponent<Props> = ({
|
|||
<h1 className={styles.lead}>Please unlock Dnote Cloud to use.</h1>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link
|
||||
to={getSubscriptionPath()}
|
||||
className="button button-normal button-first"
|
||||
>
|
||||
<a href="/subscriptions" className="button button-normal button-first">
|
||||
Get started
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,94 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { CardElement } from 'react-stripe-elements';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import styles from './PaymentInput.scss';
|
||||
|
||||
const elementStyles = {
|
||||
base: {
|
||||
color: '#32325D',
|
||||
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
|
||||
fontSize: '16px',
|
||||
fontSmoothing: 'antialiased',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#CFD7DF'
|
||||
},
|
||||
':-webkit-autofill': {
|
||||
color: '#e39f48'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
color: '#E25950',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#FFCCA5'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface Props {
|
||||
cardElementRef?: React.MutableRefObject<any>;
|
||||
setCardElementLoaded: (boolean) => void;
|
||||
containerClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const Card: React.FunctionComponent<Props> = ({
|
||||
cardElementRef,
|
||||
setCardElementLoaded,
|
||||
containerClassName,
|
||||
labelClassName
|
||||
}) => {
|
||||
const [cardElementFocused, setCardElementFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={classnames(styles['card-row'], containerClassName)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="card-number" className={styles.number}>
|
||||
<span className={classnames(labelClassName)}>Card Number</span>
|
||||
|
||||
<CardElement
|
||||
id="card"
|
||||
className={classnames(styles['card-number'], styles.input, {
|
||||
[styles['card-number-active']]: cardElementFocused
|
||||
})}
|
||||
onFocus={() => {
|
||||
setCardElementFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setCardElementFocused(false);
|
||||
}}
|
||||
onReady={el => {
|
||||
if (cardElementRef) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
cardElementRef.current = el;
|
||||
}
|
||||
setCardElementLoaded(true);
|
||||
}}
|
||||
style={elementStyles}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
@ -1,58 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import CountrySelect from './CountrySelect';
|
||||
import styles from './PaymentInput.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onUpdate: (string) => void;
|
||||
containerClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const Country: React.FunctionComponent<Props> = ({
|
||||
value,
|
||||
onUpdate,
|
||||
containerClassName,
|
||||
labelClassName
|
||||
}) => {
|
||||
return (
|
||||
<div className={classnames(containerClassName)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="billing-country" className="label-full">
|
||||
<span className={classnames(labelClassName)}>Country</span>
|
||||
<CountrySelect
|
||||
id="billing-country"
|
||||
className={classnames(styles['countries-select'], styles.input)}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onUpdate(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Country;
|
||||
|
|
@ -1,50 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../App/responsive';
|
||||
@import '../../App/theme';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
select.select {
|
||||
// match the height of the stripe element with other inputs
|
||||
padding: rem(7px) rem(12px);
|
||||
border-radius: 0;
|
||||
border: 2px solid $border-color;
|
||||
background-color: #ffffff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-position: right 16px center;
|
||||
|
||||
&:focus {
|
||||
border: 2px solid $third;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.caret {
|
||||
position: absolute;
|
||||
right: rem(16px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
|
@ -1,63 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable jsx-a11y/control-has-associated-label */
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { countries } from 'web/libs/countries';
|
||||
|
||||
import styles from './CountrySelect.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
className: string;
|
||||
onChange: (string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CountrySelect: React.FunctionComponent<Props> = ({
|
||||
id,
|
||||
className,
|
||||
onChange,
|
||||
value
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<select
|
||||
id={id}
|
||||
className={classnames(className, styles.select, 'form-select')}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<option value="" />
|
||||
|
||||
{countries.map(country => {
|
||||
return (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountrySelect;
|
||||
|
|
@ -1,60 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import styles from './PaymentInput.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onUpdate: (string) => void;
|
||||
containerClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const NameOnCard: React.FunctionComponent<Props> = ({
|
||||
value,
|
||||
onUpdate,
|
||||
containerClassName,
|
||||
labelClassName
|
||||
}) => {
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<label htmlFor="name-on-card" className="label-full">
|
||||
<span className={classnames(labelClassName)}>Name on Card</span>
|
||||
<input
|
||||
autoFocus
|
||||
id="name-on-card"
|
||||
className={classnames(
|
||||
'text-input text-input-stretch text-input-medium',
|
||||
styles.input
|
||||
)}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onUpdate(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NameOnCard;
|
||||
|
|
@ -1,51 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../App/responsive';
|
||||
@import '../../App/theme';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
input.input {
|
||||
margin-top: rem(8px);
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.number {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
// match the height of the stripe element with other inputs
|
||||
padding: rem(10.4px) rem(12px);
|
||||
border: 2px solid $border-color;
|
||||
|
||||
&.card-number-active {
|
||||
border: 2px solid $third;
|
||||
}
|
||||
}
|
||||
|
||||
.countries-select {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -1,59 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../App/responsive';
|
||||
@import '../App/theme';
|
||||
@import '../App/rem';
|
||||
@import '../App/font';
|
||||
|
||||
.wrapper {
|
||||
height: 60px;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-bottom: 1px solid $border-color;
|
||||
align-items: center;
|
||||
height: $header-height;
|
||||
padding-left: rem(20px);
|
||||
padding-right: rem(20px);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email {
|
||||
@include font-size('regular');
|
||||
color: $gray;
|
||||
}
|
||||
|
|
@ -1,54 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getHomePath } from 'web/libs/paths';
|
||||
import { useSelector } from '../../store';
|
||||
import Logo from '../Icons/LogoWithText';
|
||||
import styles from './SubscriptionHeader.scss';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SubscriptionsHeader: React.FunctionComponent<Props> = () => {
|
||||
const { user } = useSelector(state => {
|
||||
return {
|
||||
user: state.auth.user.data
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<header className={styles.wrapper}>
|
||||
<div className={styles.content}>
|
||||
<Link to={getHomePath({})} className={styles.brand}>
|
||||
<Logo
|
||||
id="subscription-header-logo"
|
||||
width={88}
|
||||
fill="black"
|
||||
className={styles.logo}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className={styles.email}>{user.email}</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsHeader;
|
||||
|
|
@ -45,7 +45,7 @@ const About: React.FunctionComponent<Props> = () => {
|
|||
<h2 className={styles['section-heading']}>Software</h2>
|
||||
|
||||
<SettingRow name="Version" value={config.version} />
|
||||
{user.pro && (
|
||||
{!__STANDALONE__ && user.pro && (
|
||||
<SettingRow
|
||||
name="Support"
|
||||
value={<a href="mailto:sung@getdnote.com">sung@getdnote.com</a>}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ const EmailModal: React.FunctionComponent<Props> = ({
|
|||
}
|
||||
|
||||
await services.users.updateProfile({
|
||||
email: emailVal
|
||||
email: emailVal,
|
||||
password: passwordVal
|
||||
});
|
||||
|
||||
await dispatch(getCurrentUser({ refresh: true }));
|
||||
|
|
|
|||
|
|
@ -1,108 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch } from '../../../store';
|
||||
import Button from '../../Common/Button';
|
||||
import Modal, { Header, Body } from '../../Common/Modal';
|
||||
import modalStyles from '../../Common/Modal/Modal.scss';
|
||||
import { getSubscription } from '../../../store/auth';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
subscriptionId: string;
|
||||
setSuccessMsg: (string) => void;
|
||||
setFailureMsg: (string) => void;
|
||||
}
|
||||
|
||||
const CancelPlanModal: React.FunctionComponent<Props> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
subscriptionId,
|
||||
setSuccessMsg,
|
||||
setFailureMsg
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
setSuccessMsg('');
|
||||
setFailureMsg('');
|
||||
setInProgress(true);
|
||||
|
||||
try {
|
||||
await services.payment.cancelSubscription({ subscriptionId });
|
||||
await dispatch(getSubscription());
|
||||
|
||||
setSuccessMsg(
|
||||
'Your subscription is cancelled. You can still continue using Dnote until the end of billing cycle.'
|
||||
);
|
||||
setInProgress(false);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setFailureMsg(`Failed to cancel the subscription. Error: ${err.message}`);
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labelId = 'plan-cancel-modal';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onDismiss={onDismiss} ariaLabelledBy={labelId}>
|
||||
<Header
|
||||
labelId={labelId}
|
||||
heading="Cancel the plan"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
|
||||
<Body>
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
<div>Sorry to see you go. Hope Dnote was helpful to you.</div>
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
kind="first"
|
||||
size="normal"
|
||||
isBusy={inProgress}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
No, I changed my mind. Go back.
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
kind="second"
|
||||
size="normal"
|
||||
isBusy={inProgress}
|
||||
>
|
||||
Cancel my plan.
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CancelPlanModal;
|
||||
|
|
@ -1,151 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { injectStripe } from 'react-stripe-elements';
|
||||
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch } from '../../../../store';
|
||||
import { getSource } from '../../../../store/auth';
|
||||
import Button from '../../../Common/Button';
|
||||
import NameOnCardInput from '../../../Common/PaymentInput/NameOnCard';
|
||||
import CardInput from '../../../Common/PaymentInput/Card';
|
||||
import CountryInput from '../../../Common/PaymentInput/Country';
|
||||
import settingsStyles from '../../Settings.scss';
|
||||
import styles from './Form.scss';
|
||||
|
||||
interface Props {
|
||||
stripe: any;
|
||||
nameOnCard: string;
|
||||
setNameOnCard: (string) => void;
|
||||
billingCountry: string;
|
||||
setBillingCountry: (string) => void;
|
||||
inProgress: boolean;
|
||||
onDismiss: () => void;
|
||||
setSuccessMsg: (string) => void;
|
||||
setInProgress: (boolean) => void;
|
||||
setErrMessage: (string) => void;
|
||||
}
|
||||
|
||||
const Form: React.FunctionComponent<Props> = ({
|
||||
stripe,
|
||||
nameOnCard,
|
||||
setNameOnCard,
|
||||
billingCountry,
|
||||
setBillingCountry,
|
||||
inProgress,
|
||||
onDismiss,
|
||||
setSuccessMsg,
|
||||
setInProgress,
|
||||
setErrMessage
|
||||
}) => {
|
||||
const [cardElementLoaded, setCardElementLoaded] = useState(false);
|
||||
const cardElementRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!cardElementLoaded) {
|
||||
return;
|
||||
}
|
||||
if (!nameOnCard) {
|
||||
setErrMessage('Please enter the name on card');
|
||||
return;
|
||||
}
|
||||
if (!billingCountry) {
|
||||
setErrMessage('Please enter the country');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccessMsg('');
|
||||
setErrMessage('');
|
||||
setInProgress(true);
|
||||
|
||||
try {
|
||||
const { source, error } = await stripe.createSource({
|
||||
type: 'card',
|
||||
currency: 'usd',
|
||||
owner: {
|
||||
name: nameOnCard
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await services.payment.updateSource({ source, country: billingCountry });
|
||||
await dispatch(getSource());
|
||||
|
||||
setSuccessMsg('Your payment method was successfully updated.');
|
||||
setInProgress(false);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setErrMessage(`An error occurred: ${err.message}`);
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} id="T-payment-method-form">
|
||||
<div>
|
||||
<NameOnCardInput
|
||||
value={nameOnCard}
|
||||
onUpdate={setNameOnCard}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
|
||||
<CardInput
|
||||
cardElementRef={cardElementRef}
|
||||
setCardElementLoaded={setCardElementLoaded}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
|
||||
<CountryInput
|
||||
value={billingCountry}
|
||||
onUpdate={setBillingCountry}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={settingsStyles.actions}>
|
||||
<Button
|
||||
type="submit"
|
||||
kind="first"
|
||||
size="normal"
|
||||
isBusy={!cardElementLoaded || inProgress}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="second"
|
||||
size="normal"
|
||||
disabled={inProgress}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectStripe(Form);
|
||||
|
|
@ -1,91 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { StripeProvider, Elements } from 'react-stripe-elements';
|
||||
|
||||
import Modal, { Header, Body } from '../../../Common/Modal';
|
||||
import Flash from '../../../Common/Flash';
|
||||
import Form from './Form';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
setSuccessMsg: (string) => void;
|
||||
stripe: any;
|
||||
}
|
||||
|
||||
const PaymentMethodModal: React.FunctionComponent<Props> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
setSuccessMsg,
|
||||
stripe
|
||||
}) => {
|
||||
const [nameOnCard, setNameOnCard] = useState('');
|
||||
const [billingCountry, setBillingCountry] = useState('');
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
|
||||
const labelId = 'payment-method-modal';
|
||||
|
||||
function handleDismiss() {
|
||||
setNameOnCard('');
|
||||
setBillingCountry('');
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onDismiss={handleDismiss} ariaLabelledBy={labelId}>
|
||||
<Header
|
||||
labelId={labelId}
|
||||
heading="Update payment method"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
|
||||
<Flash
|
||||
when={errMessage !== ''}
|
||||
kind="danger"
|
||||
onDismiss={() => {
|
||||
setErrMessage('');
|
||||
}}
|
||||
>
|
||||
{errMessage}
|
||||
</Flash>
|
||||
|
||||
<Body>
|
||||
<StripeProvider stripe={stripe}>
|
||||
<Elements>
|
||||
<Form
|
||||
nameOnCard={nameOnCard}
|
||||
setNameOnCard={setNameOnCard}
|
||||
billingCountry={billingCountry}
|
||||
setBillingCountry={setBillingCountry}
|
||||
inProgress={inProgress}
|
||||
onDismiss={handleDismiss}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setInProgress={setInProgress}
|
||||
setErrMessage={setErrMessage}
|
||||
/>
|
||||
</Elements>
|
||||
</StripeProvider>
|
||||
</Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodModal;
|
||||
|
|
@ -1,66 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import SettingRow from '../../SettingRow';
|
||||
import { SourceData } from '../../../../store/auth/type';
|
||||
import styles from '../../Settings.scss';
|
||||
|
||||
interface Props {
|
||||
stripeLoaded: boolean;
|
||||
source: SourceData;
|
||||
setIsPaymentMethodModalOpen: (bool) => void;
|
||||
}
|
||||
|
||||
const PaymentMethodRow: React.FunctionComponent<Props> = ({
|
||||
stripeLoaded,
|
||||
source,
|
||||
setIsPaymentMethodModalOpen
|
||||
}) => {
|
||||
let value;
|
||||
if (source.brand) {
|
||||
value = `${source.brand} ending in ${source.last4}. expiry ${source.exp_month}/${source.exp_year}`;
|
||||
} else {
|
||||
value = 'No payment method';
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
id="T-payment-method-row"
|
||||
name="Payment method"
|
||||
value={value}
|
||||
actionContent={
|
||||
<button
|
||||
id="T-update-payment-method-button"
|
||||
className={classnames('button-no-ui', styles.edit)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPaymentMethodModalOpen(true);
|
||||
}}
|
||||
disabled={!stripeLoaded}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodRow;
|
||||
|
|
@ -1,49 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../../App/rem';
|
||||
@import '../../../App/font';
|
||||
@import '../../../App/theme';
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: rem(16px) rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
.content-line1 {
|
||||
width: rem(120px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
|
||||
.content-line2 {
|
||||
width: rem(180px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
|
||||
.content-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -1,51 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import settingsStyles from '../../Settings.scss';
|
||||
import styles from './Placeholder.scss';
|
||||
|
||||
const Placeholder: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="container-wide">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-12 col-lg-10">
|
||||
<section className={settingsStyles.section}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles['content-left']}>
|
||||
<div
|
||||
className={classnames('holder', styles['content-line1'])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles['content-right']}>
|
||||
<div
|
||||
className={classnames('holder', styles['content-line2'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Placeholder;
|
||||
|
|
@ -1,52 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { SourceData } from '../../../../store/auth/type';
|
||||
import PaymentMethodRow from './PaymentMethodRow';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
interface Props {
|
||||
source: SourceData;
|
||||
setIsPaymentMethodModalOpen: (boolean) => void;
|
||||
stripeLoaded: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
const PaymentSection: React.FunctionComponent<Props> = ({
|
||||
source,
|
||||
setIsPaymentMethodModalOpen,
|
||||
stripeLoaded,
|
||||
isFetched
|
||||
}) => {
|
||||
if (!isFetched) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<PaymentMethodRow
|
||||
source={source}
|
||||
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
|
||||
stripeLoaded={stripeLoaded}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSection;
|
||||
|
|
@ -1,49 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import SettingRow from '../../SettingRow';
|
||||
import styles from '../../Settings.scss';
|
||||
|
||||
interface Props {
|
||||
setIsPlanModalOpen: (bool) => void;
|
||||
}
|
||||
|
||||
const CancelRow: React.FunctionComponent<Props> = ({ setIsPlanModalOpen }) => {
|
||||
return (
|
||||
<SettingRow
|
||||
name="Cancel current plan"
|
||||
desc="If you cancel, the plan will expire at the end of current billing period."
|
||||
actionContent={
|
||||
<button
|
||||
className={classnames('button-no-ui', styles.edit)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPlanModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel plan
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CancelRow;
|
||||
|
|
@ -1,41 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../../App/rem';
|
||||
@import '../../../App/font';
|
||||
@import '../../../App/theme';
|
||||
|
||||
.content1 {
|
||||
position: relative;
|
||||
padding: rem(48px) rem(12px);
|
||||
}
|
||||
|
||||
.content1-line1 {
|
||||
width: rem(140px);
|
||||
height: rem(16px);
|
||||
}
|
||||
.content1-line2 {
|
||||
width: rem(240px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
.content1-line3 {
|
||||
width: rem(80px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
|
|
@ -1,60 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import settingsStyles from '../../Settings.scss';
|
||||
import styles from './Placeholder.scss';
|
||||
|
||||
const Placeholder: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="container-wide">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-12 col-lg-10">
|
||||
<section className={settingsStyles.section}>
|
||||
<div className={styles.content1}>
|
||||
<div className={classnames('holder', styles['content1-line1'])} />
|
||||
<div className={classnames('holder', styles['content1-line2'])} />
|
||||
<div className={classnames('holder', styles['content1-line3'])} />
|
||||
</div>
|
||||
|
||||
<div className={styles.content2}>
|
||||
<div className={styles['content2-left']}>
|
||||
<div
|
||||
className={classnames('holder', styles['content2-line1'])}
|
||||
/>
|
||||
<div
|
||||
className={classnames('holder', styles['content2-line2'])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles['content2-right']}>
|
||||
<div
|
||||
className={classnames('holder', styles['content2-line2'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Placeholder;
|
||||
|
|
@ -1,52 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../../App/rem';
|
||||
@import '../../../App/font';
|
||||
@import '../../../App/theme';
|
||||
|
||||
.wrapper {
|
||||
padding-top: rem(48px);
|
||||
padding-bottom: rem(48px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-left: rem(8px);
|
||||
}
|
||||
|
||||
.label {
|
||||
@include font-size('regular');
|
||||
}
|
||||
|
||||
.desc {
|
||||
@include font-size('small');
|
||||
color: $gray;
|
||||
max-width: rem(400px);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: rem(8px);
|
||||
}
|
||||
|
|
@ -1,91 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { getPlanLabel } from 'web/libs/subscription';
|
||||
import { SECOND } from 'web/helpers/time';
|
||||
import formatDate from 'web/helpers/time/format';
|
||||
import LogoIcon from '../../../Icons/Logo';
|
||||
import styles from './PlanRow.scss';
|
||||
import settingRowStyles from '../../SettingRow.scss';
|
||||
|
||||
function getPlanPeriodMessage(subscription: any): string {
|
||||
if (!subscription.id) {
|
||||
return 'You do not have a subscription.';
|
||||
}
|
||||
|
||||
const label = getPlanLabel(subscription);
|
||||
|
||||
const endDate = new Date(subscription.current_period_end * SECOND);
|
||||
|
||||
if (subscription.cancel_at_period_end) {
|
||||
return `Your ${label} plan will end on ${formatDate(
|
||||
endDate,
|
||||
'%YYYY %MMM %Do'
|
||||
)} and will not renew.`;
|
||||
}
|
||||
|
||||
const renewDate = new Date(endDate);
|
||||
renewDate.setDate(endDate.getDate() + 1);
|
||||
return `Your ${label} plan will renew on ${formatDate(
|
||||
renewDate,
|
||||
'%YYYY %MMM %Do'
|
||||
)}.`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subscription: any;
|
||||
}
|
||||
|
||||
const PlanRow: React.FunctionComponent<Props> = ({ subscription }) => {
|
||||
return (
|
||||
<div className={classnames(settingRowStyles.row, styles.wrapper)}>
|
||||
<div className={styles.content}>
|
||||
<LogoIcon width={40} height={40} />
|
||||
<div className={styles.detail}>
|
||||
<div>
|
||||
<strong className={styles.label}>
|
||||
{getPlanLabel(subscription)}
|
||||
</strong>
|
||||
|
||||
{subscription.cancel_at_period_end && (
|
||||
<span className={styles.status}>(cancelled)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.desc}>{getPlanPeriodMessage(subscription)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!subscription.id && (
|
||||
<Link
|
||||
className="button button-normal button-first"
|
||||
to="/subscriptions"
|
||||
>
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanRow;
|
||||
|
|
@ -1,79 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch } from '../../../../store';
|
||||
import { getSubscription } from '../../../../store/auth';
|
||||
import SettingRow from '../../SettingRow';
|
||||
import styles from '../../Settings.scss';
|
||||
|
||||
interface Props {
|
||||
subscriptionId: string;
|
||||
setSuccessMsg: (string) => void;
|
||||
setFailureMsg: (string) => void;
|
||||
}
|
||||
|
||||
const ReactivateRow: React.FunctionComponent<Props> = ({
|
||||
subscriptionId,
|
||||
setSuccessMsg,
|
||||
setFailureMsg
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name="Reactivate your plan"
|
||||
desc="You can reactivate your plan if you have changed your mind."
|
||||
actionContent={
|
||||
<button
|
||||
className={classnames('button-no-ui', styles.edit)}
|
||||
type="button"
|
||||
disabled={inProgress}
|
||||
onClick={() => {
|
||||
setInProgress(true);
|
||||
|
||||
services.payment
|
||||
.reactivateSubscription({ subscriptionId })
|
||||
.then(() => {
|
||||
return dispatch(getSubscription()).then(() => {
|
||||
setSuccessMsg(
|
||||
'Your plan was reactivated. The billing cycle will be the same.'
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
setFailureMsg(
|
||||
`Failed to reactivate the plan. Error: ${err.message}. Please contact sung@getdnote.com.`
|
||||
);
|
||||
setInProgress(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{inProgress ? 'Reactivating...' : 'Reactivate plan'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactivateRow;
|
||||
|
|
@ -1,64 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import PlanRow from './PlanRow';
|
||||
import CancelRow from './CancelRow';
|
||||
import ReactivateRow from './ReactivateRow';
|
||||
import { SubscriptionData } from '../../../../store/auth/type';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
interface Props {
|
||||
subscription: SubscriptionData;
|
||||
setIsPlanModalOpen: (boolean) => void;
|
||||
setSuccessMsg: (string) => void;
|
||||
setFailureMsg: (string) => void;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
const PlanSection: React.FunctionComponent<Props> = ({
|
||||
subscription,
|
||||
isFetched,
|
||||
setIsPlanModalOpen,
|
||||
setSuccessMsg,
|
||||
setFailureMsg
|
||||
}) => {
|
||||
if (!isFetched) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<PlanRow subscription={subscription} />
|
||||
|
||||
{subscription.id && !subscription.cancel_at_period_end && (
|
||||
<CancelRow setIsPlanModalOpen={setIsPlanModalOpen} />
|
||||
)}
|
||||
{subscription.id && subscription.cancel_at_period_end && (
|
||||
<ReactivateRow
|
||||
subscriptionId={subscription.id}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanSection;
|
||||
|
|
@ -1,176 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { useScript } from 'web/libs/hooks';
|
||||
import { useSelector, useDispatch } from '../../../store';
|
||||
import Flash from '../../Common/Flash';
|
||||
import CancelPlanModal from './CancelPlanModal';
|
||||
import PaymentMethodModal from './PaymentMethodModal';
|
||||
import {
|
||||
getSubscription,
|
||||
clearSubscription,
|
||||
getSource,
|
||||
clearSource
|
||||
} from '../../../store/auth';
|
||||
import PlanSection from './PlanSection';
|
||||
import PaymentSection from './PaymentSection';
|
||||
import styles from '../Settings.scss';
|
||||
|
||||
const Billing: React.FunctionComponent = () => {
|
||||
const [isPlanModalOpen, setIsPlanModalOpen] = useState(false);
|
||||
const [isPaymentMethodModalOpen, setIsPaymentMethodModalOpen] = useState(
|
||||
false
|
||||
);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [failureMsg, setFailureMsg] = useState('');
|
||||
const [stripeLoaded, stripeLoadError] = useScript('https://js.stripe.com/v3');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getSubscription());
|
||||
dispatch(getSource());
|
||||
|
||||
return () => {
|
||||
dispatch(clearSubscription());
|
||||
dispatch(clearSource());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const { subscriptionData, sourceData } = useSelector(state => {
|
||||
return {
|
||||
subscriptionData: state.auth.subscription,
|
||||
sourceData: state.auth.source
|
||||
};
|
||||
});
|
||||
|
||||
const subscription = subscriptionData.data;
|
||||
|
||||
const key = `${__STRIPE_PUBLIC_KEY__}`;
|
||||
|
||||
let stripe = null;
|
||||
if (stripeLoaded) {
|
||||
stripe = (window as any).Stripe(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Billing</title>
|
||||
</Helmet>
|
||||
|
||||
<Flash
|
||||
when={subscriptionData.errorMessage !== ''}
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
<div>Failed to fetch the billing information</div>
|
||||
{subscriptionData.errorMessage}
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
when={sourceData.errorMessage !== ''}
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
<div>Failed to fetch the payment source</div>
|
||||
{sourceData.errorMessage}
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
when={stripeLoadError !== ''}
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
<div>Failed to load Stripe</div>
|
||||
{stripeLoadError}
|
||||
</Flash>
|
||||
|
||||
<div>
|
||||
<Flash
|
||||
when={successMsg !== ''}
|
||||
kind="success"
|
||||
wrapperClassName={styles.flash}
|
||||
onDismiss={() => {
|
||||
setSuccessMsg('');
|
||||
}}
|
||||
>
|
||||
{successMsg}
|
||||
</Flash>
|
||||
<Flash
|
||||
when={failureMsg !== ''}
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
onDismiss={() => {
|
||||
setFailureMsg('');
|
||||
}}
|
||||
>
|
||||
{failureMsg}
|
||||
</Flash>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles['section-heading']}>Plan</h2>
|
||||
|
||||
<PlanSection
|
||||
subscription={subscriptionData.data}
|
||||
setIsPlanModalOpen={setIsPlanModalOpen}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
isFetched={subscriptionData.isFetched}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles['section-heading']}>Payment</h2>
|
||||
|
||||
<PaymentSection
|
||||
source={sourceData.data}
|
||||
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
|
||||
stripeLoaded={stripeLoaded}
|
||||
isFetched={sourceData.isFetched}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CancelPlanModal
|
||||
isOpen={isPlanModalOpen}
|
||||
onDismiss={() => {
|
||||
setIsPlanModalOpen(false);
|
||||
}}
|
||||
subscriptionId={subscription.id}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
/>
|
||||
|
||||
<PaymentMethodModal
|
||||
isOpen={isPaymentMethodModalOpen}
|
||||
onDismiss={() => {
|
||||
setIsPaymentMethodModalOpen(false);
|
||||
}}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
stripe={stripe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
|
|
@ -38,15 +38,13 @@ const Sidebar: React.FunctionComponent<Props> = () => {
|
|||
Account
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className={styles.item}
|
||||
activeClassName={styles.active}
|
||||
to={getSettingsPath(SettingSections.billing)}
|
||||
>
|
||||
Billing
|
||||
</NavLink>
|
||||
</li>
|
||||
{__STANDALONE__ ? null : (
|
||||
<li>
|
||||
<a className={styles.item} href="/subscriptions/manage">
|
||||
Billing
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<NavLink
|
||||
className={styles.item}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue