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:
Sung Won Cho 2020-05-22 16:30:05 +10:00 committed by GitHub
commit 6acc2936e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
120 changed files with 1411 additions and 4772 deletions

View file

@ -60,6 +60,7 @@
"__STRIPE_PUBLIC_KEY__": true,
"__ROOT_URL__": true,
"__CDN_URL__": true,
"__STANDALONE__": true,
"socket": true,
"webpackIsomorphicTools": true,
"StripeCheckout": true,

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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
};
}

View file

@ -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);
}
};
}

View file

@ -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);

View file

@ -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) {

View file

@ -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
View 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(&params); 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(&params); 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")
}
}

View file

@ -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{

View file

@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package handlers
package api
import (
"net/http"

View file

@ -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
View 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
}

View file

@ -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)
}

View file

@ -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(&notes).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(&notes).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)
}

View file

@ -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
View 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
}

View 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")
})
}
}

View file

@ -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"))
}

View file

@ -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(&params); 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(&params); 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
}

View file

@ -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{

View file

@ -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(&params)
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
}
}

View file

@ -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")

View file

@ -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(&params)
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(&params)
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(&notes).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)
}

View file

@ -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{

View file

@ -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(&params)
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(&note).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(&note).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(&params)
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

View file

@ -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{

View file

@ -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)
}

View file

@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package handlers
package api
import (
"fmt"

View file

@ -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

View file

@ -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))

View file

@ -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))

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -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))

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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,

View file

@ -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(&params); 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(&params); 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
}

View file

@ -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
}

View file

@ -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")
})
}
}

View file

@ -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)

View 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")
}
}

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -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,
}
}

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -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,

View file

@ -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,

View 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,
}
}

View 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")
})
}
}

View file

@ -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) {

View file

@ -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{

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -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()

View file

@ -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
}

View file

@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -17,5 +17,6 @@ ASSET_BASE_URL="$assetBaseUrl" \
ROOT_URL="$rootUrl" \
PUBLIC_PATH="$publicPath" \
COMPILED_PATH="$compiledPath" \
STANDALONE=true \
VERSION="$VERSION" \
"$dir/build.sh"

View file

@ -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 \

View file

@ -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"

View file

@ -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
)

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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>

View file

@ -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"
/>

View file

@ -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>
);

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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>}

View file

@ -64,7 +64,8 @@ const EmailModal: React.FunctionComponent<Props> = ({
}
await services.users.updateProfile({
email: emailVal
email: emailVal,
password: passwordVal
});
await dispatch(getCurrentUser({ refresh: true }));

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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