Compare commits

...

20 commits

Author SHA1 Message Date
Sung Won Cho
0f2d42607c e2e 2022-04-23 19:06:11 +10:00
Sung Won Cho
98491c9a52 Add CTA 2022-04-23 16:46:54 +10:00
Sung Won Cho
45aa7dc37c Backward compatible verify email 2022-04-23 16:01:23 +10:00
Sung Won Cho
b6572a5d13 Allow to send email verification email 2022-04-23 15:58:32 +10:00
Sung Won Cho
9c1c812ea1 Create verification token 2022-04-23 15:40:26 +10:00
Sung Won Cho
f2873075d3 Email verification 2022-04-23 14:50:46 +10:00
Sung Won Cho
9b9c06c703 Remove hardcoded base url 2022-04-23 13:38:13 +10:00
Sung Won Cho
9cf33a9632 Allow to build assets without watching 2022-04-23 13:17:38 +10:00
Sung Won Cho
8c13525177 Allow specify base for asset url 2022-04-18 17:08:08 +10:00
Sung Won Cho
0e6cc54ed9 Allow to specify baseDir 2022-04-18 16:39:02 +10:00
Sung Won Cho
7b1700ba3f Serve robots 2022-04-18 14:30:05 +10:00
Sung Won Cho
adb2b0a940 Fix test 2022-04-18 12:33:43 +10:00
Sung Won Cho
b4126aa82b Remove unused 2022-04-18 09:54:48 +10:00
Sung Won Cho
28596667fb Test email update 2022-04-17 18:34:39 +10:00
Sung Won Cho
f265e2a1fe Test password update 2022-04-17 18:30:37 +10:00
Sung Won Cho
529e8e7dea Email update form 2022-04-17 18:11:34 +10:00
Sung Won Cho
0a40fef084 Password change 2022-04-17 16:52:55 +10:00
Sung Won Cho
7af65c1eee Implement basic settings layout 2022-04-17 15:02:50 +10:00
Sung Won Cho
348bf8398c Fix test and remove api package 2022-04-17 15:02:50 +10:00
Sung Won Cho
cd5d094c25 Implement MVC 2022-04-17 10:47:43 +10:00
166 changed files with 12611 additions and 5165 deletions

12
go.mod
View file

@ -8,34 +8,36 @@ require (
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/packr/v2 v2.8.1
github.com/google/go-cmp v0.5.4
github.com/google/go-github v17.0.0+incompatible
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.1.3
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/csrf v1.6.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/jinzhu/gorm v1.9.16
github.com/joho/godotenv v1.3.0
github.com/karrick/godirwalk v1.16.1 // indirect
github.com/lib/pq v1.9.0
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-sqlite3 v1.14.6
github.com/nadproject/nad v0.0.0-20200124233812-f1a4e763ee2f
github.com/pkg/errors v0.9.1
github.com/radovskyb/watcher v1.0.7
github.com/robfig/cron v1.2.0
github.com/rogpeppe/go-internal v1.6.2 // indirect
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351
github.com/sergi/go-diff v1.1.0
github.com/sirupsen/logrus v1.7.0 // indirect
github.com/spf13/cobra v1.1.1
github.com/yuin/goldmark v1.4.0
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a // indirect
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect
golang.org/x/sys v0.0.0-20201231184435-2d18734c6014 // indirect
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v2 v2.4.0
)

49
go.sum
View file

@ -1,5 +1,6 @@
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/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
@ -15,6 +16,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
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/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/PuerkitoBio/goquery v1.6.0 h1:j7taAbelrdcsOlGeMenZxc2AWXD5fieT1/znArdnx94=
@ -27,6 +29,7 @@ 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/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
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=
@ -73,6 +76,7 @@ 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/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=
@ -82,8 +86,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=
@ -113,12 +115,16 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1/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/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/logger v1.0.3 h1:YaXOTHNPCvkqqA7w05A4v0k2tCdpr+sgFlgINbQ6gqc=
github.com/gobuffalo/logger v1.0.3/go.mod h1:SoeejUwldiS7ZsyCBphOGURmWdwUFXs0J7TCjEhjKxM=
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/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
github.com/gobuffalo/packr/v2 v2.8.1 h1:tkQpju6i3EtMXJ9uoF5GT6kB+LMTimDWD8Xvbz6zDVA=
github.com/gobuffalo/packr/v2 v2.8.1/go.mod h1:c/PLlOuTU+p3SybaJATW3H6lX/iK7xEz5OeMf+NnJpg=
@ -158,18 +164,27 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.3 h1:twObb+9XcuH5B9V1TBCvvvZoO6iEdILi2a76PYn5rJI=
github.com/google/uuid v1.1.3/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
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/csrf v1.6.2 h1:QqQ/OWwuFp4jMKgBFAzJVW3FMULdyUW7JoM4pEWuqKg=
github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
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/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.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@ -206,6 +221,7 @@ 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/go.mod h1:Kh6hTsSGffh4ui079FHrR5Gg+5D0hgihqDcsDN2BBJY=
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -222,6 +238,9 @@ github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
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/go.mod h1:xqGOmDZzLOG7+q/CgsbXv10g4tgPsbjhmAxyaTJMvis=
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.8/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
@ -255,17 +274,17 @@ github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ
github.com/mattn/go-colorable v0.1.8/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/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/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@ -281,6 +300,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nadproject/color v1.7.0/go.mod h1:p2KusS2iX8Q7ncpngDmtva/kZmiad9Hv5MFS4SLuCZQ=
github.com/nadproject/nad v0.0.0-20200124233812-f1a4e763ee2f h1:Vq2SFUt+Mrle7Irf7rLOnYBegSVF3tyNbsMnDomWfH8=
github.com/nadproject/nad v0.0.0-20200124233812-f1a4e763ee2f/go.mod h1:mGl2lRU9Xo49kzVYj46FwP+pEP/Um+nIqTdCmPHtI5k=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
@ -354,13 +376,18 @@ github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
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/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rubenv/sql-migrate v0.0.0-20190618074426-f4d34eae5a5c/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY=
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351 h1:HXr/qUllAWv9riaI4zh2eXWKmCSDqVS/XH1MRHLKRwk=
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/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/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
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=
@ -398,6 +425,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
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=
@ -407,6 +435,8 @@ 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.4.0 h1:OtISOGfH6sOWa1/qXqqAiOIAO6Z5J3AEAE18WAq6BiQ=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
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=
@ -493,8 +523,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -505,6 +535,7 @@ 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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
@ -552,6 +583,7 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
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-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -562,11 +594,14 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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/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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@ -581,6 +616,7 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww
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-20190418145605-e7d98fc518a7/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-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -610,6 +646,7 @@ gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qS
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/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=

View file

@ -6,3 +6,4 @@ test-dnote
/dist
/build
server
/static

View file

@ -1,187 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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
}
if err := a.App.DeleteUserSessions(tx, account.UserID); err != nil {
tx.Rollback()
handlers.DoError(w, errors.Wrap(err, "deleting user sessions").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

@ -1,408 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"net/http"
"testing"
"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/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(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u1 := testutils.SetupUserData()
a1 := testutils.SetupAccountData(u1, "alice@example.com", "somepassword")
u2 := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&u2).Update("cloud", false), "preparing u2 cloud")
a2 := testutils.SetupAccountData(u2, "bob@example.com", "somepassword")
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
req := testutils.MakeReq(server.URL, "GET", "/me", "")
res := testutils.HTTPAuthDo(t, req, tc.user)
// 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(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
dat := `{"email": "alice@example.com"}`
req := testutils.MakeReq(server.URL, "POST", "/reset-token", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach")
var tokenCount int
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting tokens")
var resetToken database.Token
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", u.ID, database.TokenTypeResetPassword).First(&resetToken), "finding reset token")
assert.Equal(t, tokenCount, 1, "reset_token count mismatch")
assert.NotEqual(t, resetToken.Value, nil, "reset_token value mismatch")
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "reset_token UsedAt mismatch")
})
t.Run("nonexistent email", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
dat := `{"email": "bob@example.com"}`
req := testutils.MakeReq(server.URL, "POST", "/reset-token", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach")
var tokenCount int
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting tokens")
assert.Equal(t, tokenCount, 0, "reset_token count mismatch")
})
}
func TestResetPassword(t *testing.T) {
t.Run("success", 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", "oldpassword")
tok := database.Token{
UserID: u.ID,
Value: "MivFxYiSMMA4An9dP24DNQ==",
Type: database.TokenTypeResetPassword,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
otherTok := database.Token{
UserID: u.ID,
Value: "somerandomvalue",
Type: database.TokenTypeEmailVerification,
}
testutils.MustExec(t, testutils.DB.Save(&otherTok), "preparing another token")
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "newpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
s1 := database.Session{
Key: "some-session-key-1",
UserID: u.ID,
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
}
testutils.MustExec(t, testutils.DB.Save(&s1), "preparing user session 1")
s2 := &database.Session{
Key: "some-session-key-2",
UserID: u.ID,
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
}
testutils.MustExec(t, testutils.DB.Save(&s2), "preparing user session 2")
anotherUser := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Save(&database.Session{
Key: "some-session-key-3",
UserID: anotherUser.ID,
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
}), "preparing anotherUser session 1")
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismatch")
var resetToken, verificationToken database.Token
var account database.Account
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "finding reset token")
testutils.MustExec(t, testutils.DB.Where("value = ?", "somerandomvalue").First(&verificationToken), "finding reset token")
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "finding account")
assert.NotEqual(t, resetToken.UsedAt, nil, "reset_token UsedAt mismatch")
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte("newpassword"))
assert.Equal(t, passwordErr, nil, "Password mismatch")
assert.Equal(t, verificationToken.UsedAt, (*time.Time)(nil), "verificationToken UsedAt mismatch")
var s1Count, s2Count int
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("id = ?", s1.ID).Count(&s1Count), "counting s1")
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("id = ?", s2.ID).Count(&s2Count), "counting s2")
assert.Equal(t, s1Count, 0, "s1 should have been deleted")
assert.Equal(t, s2Count, 0, "s2 should have been deleted")
var userSessionCount, anotherUserSessionCount int
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("user_id = ?", u.ID).Count(&userSessionCount), "counting user session")
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("user_id = ?", anotherUser.ID).Count(&anotherUserSessionCount), "counting anotherUser session")
assert.Equal(t, userSessionCount, 1, "should have created a new user session")
assert.Equal(t, anotherUserSessionCount, 1, "anotherUser session count mismatch")
})
t.Run("nonexistent token", 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", "somepassword")
tok := database.Token{
UserID: u.ID,
Value: "MivFxYiSMMA4An9dP24DNQ==",
Type: database.TokenTypeResetPassword,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"token": "-ApMnyvpg59uOU5b-Kf5uQ==", "password": "oldpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
var resetToken database.Token
var account database.Account
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "finding reset token")
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "finding account")
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
})
t.Run("expired token", 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", "somepassword")
tok := database.Token{
UserID: u.ID,
Value: "MivFxYiSMMA4An9dP24DNQ==",
Type: database.TokenTypeResetPassword,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusGone, "Status code mismatch")
var resetToken database.Token
var account database.Account
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
})
t.Run("used token", 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", "somepassword")
usedAt := time.Now().Add(time.Hour * -11).UTC()
tok := database.Token{
UserID: u.ID,
Value: "MivFxYiSMMA4An9dP24DNQ==",
Type: database.TokenTypeResetPassword,
UsedAt: &usedAt,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
var resetToken database.Token
var account database.Account
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
if resetToken.UsedAt.Year() != usedAt.Year() ||
resetToken.UsedAt.Month() != usedAt.Month() ||
resetToken.UsedAt.Day() != usedAt.Day() ||
resetToken.UsedAt.Hour() != usedAt.Hour() ||
resetToken.UsedAt.Minute() != usedAt.Minute() ||
resetToken.UsedAt.Second() != usedAt.Second() {
t.Errorf("used_at should be %+v but got: %+v", usedAt, resetToken.UsedAt)
}
})
t.Run("using wrong type token: email_verification", 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", "somepassword")
tok := database.Token{
UserID: u.ID,
Value: "MivFxYiSMMA4An9dP24DNQ==",
Type: database.TokenTypeEmailVerification,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "Failed to prepare reset_token")
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
var resetToken database.Token
var account database.Account
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
})
}

View file

@ -1,85 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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

@ -1,324 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"net/url"
"strconv"
"strings"
"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"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
type ftsParams struct {
HighlightAll bool
}
func getHeadlineOptions(params *ftsParams) string {
headlineOptions := []string{
"StartSel=<dnotehl>",
"StopSel=</dnotehl>",
"ShortWord=0",
}
if params != nil && params.HighlightAll {
headlineOptions = append(headlineOptions, "HighlightAll=true")
} else {
headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
}
return strings.Join(headlineOptions, ",")
}
func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
headlineOpts := getHeadlineOptions(params)
return conn.Select(`
notes.id,
notes.uuid,
notes.created_at,
notes.updated_at,
notes.book_uuid,
notes.user_id,
notes.added_on,
notes.edited_on,
notes.usn,
notes.deleted,
notes.encrypted,
ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
`, search, headlineOpts)
}
func respondWithNote(w http.ResponseWriter, note database.Note) {
presentedNote := presenters.PresentNote(note)
handlers.RespondJSON(w, http.StatusOK, presentedNote)
}
func parseSearchQuery(q url.Values) string {
searchStr := q.Get("q")
return escapeSearchQuery(searchStr)
}
func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB {
var conn *gorm.DB
if search != "" {
conn = selectFTSFields(db, search, &ftsParams{HighlightAll: true})
} else {
conn = db
}
conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false)
return conn
}
func (a *API) getNote(w http.ResponseWriter, r *http.Request) {
user, _, err := handlers.AuthWithSession(a.App.DB, r, nil)
if err != nil {
handlers.DoError(w, "authenticating", err, http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
note, ok, err := operations.GetNote(a.App.DB, noteUUID, user)
if !ok {
handlers.RespondNotFound(w)
return
}
if err != nil {
handlers.DoError(w, "finding note", err, http.StatusInternalServerError)
return
}
respondWithNote(w, note)
}
/**** getNotesHandler */
// GetNotesResponse is a reponse by getNotesHandler
type GetNotesResponse struct {
Notes []presenters.Note `json:"notes"`
Total int `json:"total"`
}
type dateRange struct {
lower int64
upper int64
}
func (a *API) getNotes(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
}
query := r.URL.Query()
respondGetNotes(a.App.DB, user.ID, query, w)
}
func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) {
q, err := parseGetNotesQuery(query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
conn := getNotesBaseQuery(db, userID, q)
var total int
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
handlers.DoError(w, "counting total", err, http.StatusInternalServerError)
return
}
notes := []database.Note{}
if total != 0 {
conn = orderGetNotes(conn)
conn = database.PreloadNote(conn)
conn = paginate(conn, q.Page)
if err := conn.Find(&notes).Error; err != nil {
handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
return
}
}
response := GetNotesResponse{
Notes: presenters.PresentNotes(notes),
Total: total,
}
handlers.RespondJSON(w, http.StatusOK, response)
}
type getNotesQuery struct {
Year int
Month int
Page int
Books []string
Search string
Encrypted bool
}
func parseGetNotesQuery(q url.Values) (getNotesQuery, error) {
yearStr := q.Get("year")
monthStr := q.Get("month")
books := q["book"]
pageStr := q.Get("page")
encryptedStr := q.Get("encrypted")
fmt.Println("books", books)
var page int
if len(pageStr) > 0 {
p, err := strconv.Atoi(pageStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
}
if p < 1 {
return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
}
page = p
} else {
page = 1
}
var year int
if len(yearStr) > 0 {
y, err := strconv.Atoi(yearStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid year %s", yearStr)
}
year = y
}
var month int
if len(monthStr) > 0 {
m, err := strconv.Atoi(monthStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
}
if m < 1 || m > 12 {
return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
}
month = m
}
var encrypted bool
if strings.ToLower(encryptedStr) == "true" {
encrypted = true
} else {
encrypted = false
}
ret := getNotesQuery{
Year: year,
Month: month,
Page: page,
Search: parseSearchQuery(q),
Books: books,
Encrypted: encrypted,
}
return ret, nil
}
func getDateBounds(year, month int) (int64, int64) {
var yearUpperbound, monthUpperbound int
if month == 12 {
monthUpperbound = 1
yearUpperbound = year + 1
} else {
monthUpperbound = month + 1
yearUpperbound = year
}
lower := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixNano()
upper := time.Date(yearUpperbound, time.Month(monthUpperbound), 1, 0, 0, 0, 0, time.UTC).UnixNano()
return lower, upper
}
func getNotesBaseQuery(db *gorm.DB, userID int, q getNotesQuery) *gorm.DB {
conn := db.Where(
"notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
userID, false, q.Encrypted,
)
if q.Search != "" {
conn = selectFTSFields(conn, q.Search, nil)
conn = conn.Where("tsv @@ plainto_tsquery('english_nostop', ?)", q.Search)
}
if len(q.Books) > 0 {
conn = conn.Joins("INNER JOIN books ON books.uuid = notes.book_uuid").
Where("books.label in (?)", q.Books)
}
if q.Year != 0 || q.Month != 0 {
dateLowerbound, dateUpperbound := getDateBounds(q.Year, q.Month)
conn = conn.Where("notes.added_on >= ? AND notes.added_on < ?", dateLowerbound, dateUpperbound)
}
return conn
}
func orderGetNotes(conn *gorm.DB) *gorm.DB {
return conn.Order("notes.updated_at DESC, notes.id DESC")
}
// escapeSearchQuery escapes the query for full text search
func escapeSearchQuery(searchQuery string) string {
return strings.Join(strings.Fields(searchQuery), "&")
}
func (a *API) legacyGetNotes(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 notes []database.Note
if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(&notes).Error; err != nil {
handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
return
}
presented := presenters.PresentNotes(notes)
handlers.RespondJSON(w, http.StatusOK, presented)
}

View file

@ -1,357 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"io/ioutil"
"net/http"
"testing"
"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/database"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
return presenters.Note{
UUID: n.UUID,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
Body: n.Body,
AddedOn: n.AddedOn,
Public: n.Public,
USN: n.USN,
Book: presenters.NoteBook{
UUID: b.UUID,
Label: b.Label,
},
User: presenters.NoteUser{
UUID: u.UUID,
},
}
}
func TestGetNotes(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n1 content",
USN: 11,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
n2 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n2 content",
USN: 14,
Deleted: false,
AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
n3 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n3 content",
USN: 17,
Deleted: false,
AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
n4 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: "n4 content",
USN: 18,
Deleted: false,
AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
n5 := database.Note{
UserID: anotherUser.ID,
BookUUID: b3.UUID,
Body: "n5 content",
USN: 19,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
n6 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "",
USN: 11,
Deleted: true,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
// Execute
req := testutils.MakeReq(server.URL, "GET", "/notes?year=2018&month=8", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload GetNotesResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record, n1Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
expected := GetNotesResponse{
Notes: []presenters.Note{
getExpectedNotePayload(n2Record, b1, user),
getExpectedNotePayload(n1Record, b1, user),
},
Total: 2,
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetNote(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
privateNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "privateNote content",
Public: false,
}
testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
publicNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "publicNote content",
Public: true,
}
testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
deletedNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Deleted: true,
}
testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing publicNote")
t.Run("owner accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n1Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).First(&n1Record), "finding n1Record")
expected := getExpectedNotePayload(n1Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("owner accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("guest accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("guest accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("nonexistent", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", "someRandomString")
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("deleted", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", deletedNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
}

View file

@ -1,116 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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

@ -1,161 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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

@ -1,394 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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/presenters"
"github.com/dnote/dnote/pkg/server/session"
"github.com/dnote/dnote/pkg/server/token"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
type updateProfilePayload struct {
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 {
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
}
var params updateProfilePayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
http.Error(w, errors.Wrap(err, "invalid params").Error(), http.StatusBadRequest)
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
}
tx := a.App.DB.Begin()
if err := tx.Save(&user).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving user", err, http.StatusInternalServerError)
return
}
// check if email was changed
if params.Email != account.Email.String {
account.EmailVerified = false
}
account.Email.String = params.Email
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving account", err, http.StatusInternalServerError)
return
}
tx.Commit()
a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK)
}
type updateEmailPayload struct {
NewEmail string `json:"new_email"`
NewCipherKeyEnc string `json:"new_cipher_key_enc"`
OldAuthKey string `json:"old_auth_key"`
NewAuthKey string `json:"new_auth_key"`
}
func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) {
rows, err := db.Table("notes").Select("COUNT(id), date(to_timestamp(added_on/1000000000)) AS added_date").
Where("user_id = ?", userID).
Group("added_date").
Order("added_date DESC").Rows()
if err != nil {
handlers.DoError(w, "Failed to count lessons", err, http.StatusInternalServerError)
return
}
payload := map[string]int{}
for rows.Next() {
var count int
var d time.Time
if err := rows.Scan(&count, &d); err != nil {
handlers.DoError(w, "counting notes", err, http.StatusInternalServerError)
}
payload[d.Format("2006-1-2")] = count
}
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 {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
respondWithCalendar(a.App.DB, w, user.ID)
}
func (a *API) createVerificationToken(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
err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error
if err != nil {
handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
return
}
if account.EmailVerified {
http.Error(w, "Email already verified", http.StatusGone)
return
}
if account.Email.String == "" {
http.Error(w, "Email not set", http.StatusUnprocessableEntity)
return
}
tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification)
if err != nil {
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 {
handlers.RespondInvalidSMTPConfig(w)
} else {
handlers.DoError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
}
type verifyEmailPayload struct {
Token string `json:"token"`
}
func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
var params verifyEmailPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
var token database.Token
if err := a.App.DB.
Where("value = ? AND type = ?", params.Token, database.TokenTypeEmailVerification).
First(&token).Error; err != nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
if token.UsedAt != nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
// Expire after ttl
if time.Since(token.CreatedAt).Minutes() > 30 {
http.Error(w, "This link has been expired. Please request a new link.", http.StatusGone)
return
}
var account database.Account
if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
return
}
if account.EmailVerified {
http.Error(w, "Already verified", http.StatusConflict)
return
}
tx := a.App.DB.Begin()
account.EmailVerified = true
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
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()
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 {
handlers.DoError(w, "finding user", err, http.StatusInternalServerError)
return
}
s := session.New(user, account)
handlers.RespondJSON(w, http.StatusOK, s)
}
type emailPreferernceParams struct {
InactiveReminder *bool `json:"inactive_reminder"`
ProductUpdate *bool `json:"product_update"`
}
func (p emailPreferernceParams) getInactiveReminder() bool {
if p.InactiveReminder == nil {
return false
}
return *p.InactiveReminder
}
func (p emailPreferernceParams) getProductUpdate() bool {
if p.ProductUpdate == nil {
return false
}
return *p.ProductUpdate
}
func (a *API) updateEmailPreference(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 params emailPreferernceParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
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 {
handlers.DoError(w, "finding pref", err, http.StatusInternalServerError)
return
}
tx := a.App.DB.Begin()
if params.InactiveReminder != nil {
pref.InactiveReminder = params.getInactiveReminder()
}
if params.ProductUpdate != nil {
pref.ProductUpdate = params.getProductUpdate()
}
if err := tx.Save(&pref).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving pref", err, http.StatusInternalServerError)
return
}
token, ok := r.Context().Value(helpers.KeyToken).(database.Token)
if ok {
// 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()
handlers.DoError(w, "updating reset token", err, http.StatusInternalServerError)
return
}
}
tx.Commit()
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 {
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 {
handlers.DoError(w, "finding pref", err, http.StatusInternalServerError)
return
}
presented := presenters.PresentEmailPreference(pref)
handlers.RespondJSON(w, http.StatusOK, presented)
}
type updatePasswordPayload struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
func (a *API) updatePassword(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 params updatePasswordPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if params.OldPassword == "" || params.NewPassword == "" {
http.Error(w, "invalid params", http.StatusBadRequest)
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
}
password := []byte(params.OldPassword)
if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Warn("invalid password update attempt")
http.Error(w, "Wrong password", http.StatusUnauthorized)
return
}
if err := validatePassword(params.NewPassword); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(params.NewPassword), bcrypt.DefaultCost)
if err != nil {
http.Error(w, errors.Wrap(err, "hashing password").Error(), http.StatusInternalServerError)
return
}
if err := a.App.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil {
http.Error(w, errors.Wrap(err, "updating password").Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

View file

@ -1,691 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"net/http"
"testing"
"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/database"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
func TestUpdatePassword(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@example.com", "oldpassword")
// Execute
dat := `{"old_password": "oldpassword", "new_password": "newpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismsatch")
var account database.Account
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte("newpassword"))
assert.Equal(t, passwordErr, nil, "Password mismatch")
})
t.Run("old 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", "oldpassword")
// Execute
dat := `{"old_password": "randompassword", "new_password": "newpassword"}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
res := testutils.HTTPAuthDo(t, req, u)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "Status code mismsatch")
var account database.Account
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
assert.Equal(t, a.Password.String, account.Password.String, "password should not have been updated")
})
t.Run("password too short", 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", "oldpassword")
// Execute
dat := `{"old_password": "oldpassword", "new_password": "a"}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
res := testutils.HTTPAuthDo(t, req, u)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismsatch")
var account database.Account
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
assert.Equal(t, a.Password.String, account.Password.String, "password should not have been updated")
})
}
func TestCreateVerificationToken(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
emailBackend := testutils.MockEmailbackendImplementation{}
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
EmailBackend: &emailBackend,
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
// Execute
req := testutils.MakeReq(server.URL, "POST", "/verification-token", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "status code mismatch")
var account database.Account
var token database.Token
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, false, "email_verified should not have been updated")
assert.NotEqual(t, token.Value, "", "token Value mismatch")
assert.Equal(t, tokenCount, 1, "token count mismatch")
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token UsedAt mismatch")
assert.Equal(t, len(emailBackend.Emails), 1, "email queue count mismatch")
})
t.Run("already verified", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
a.EmailVerified = true
testutils.MustExec(t, testutils.DB.Save(&a), "preparing account")
// Execute
req := testutils.MakeReq(server.URL, "POST", "/verification-token", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusGone, "Status code mismatch")
var account database.Account
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, true, "email_verified should not have been updated")
assert.Equal(t, tokenCount, 0, "token count mismatch")
})
}
func TestVerifyEmail(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
tok := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailVerification,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"token": "someTokenValue"}`
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismatch")
var account database.Account
var token database.Token
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, true, "email_verified mismatch")
assert.NotEqual(t, token.Value, "", "token value should not have been updated")
assert.Equal(t, tokenCount, 1, "token count mismatch")
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
})
t.Run("used token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
usedAt := time.Now().Add(time.Hour * -11).UTC()
tok := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailVerification,
Value: "someTokenValue",
UsedAt: &usedAt,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"token": "someTokenValue"}`
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
var account database.Account
var token database.Token
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, false, "email_verified mismatch")
assert.NotEqual(t, token.UsedAt, nil, "token used_at mismatch")
assert.Equal(t, tokenCount, 1, "token count mismatch")
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
})
t.Run("expired token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
tok := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailVerification,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-31)), "Failed to prepare token created_at")
dat := `{"token": "someTokenValue"}`
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusGone, "")
var account database.Account
var token database.Token
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, false, "email_verified mismatch")
assert.Equal(t, tokenCount, 1, "token count mismatch")
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token should have not been used")
})
t.Run("already verified", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "oldpass1234")
a.EmailVerified = true
testutils.MustExec(t, testutils.DB.Save(&a), "preparing account")
tok := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailVerification,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"token": "someTokenValue"}`
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusConflict, "")
var account database.Account
var token database.Token
var tokenCount int
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
assert.Equal(t, account.EmailVerified, true, "email_verified mismatch")
assert.Equal(t, tokenCount, 1, "token count mismatch")
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token should have not been used")
})
}
func TestUpdateEmail(t *testing.T) {
t.Run("success", 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": "pass1234"}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/profile", dat)
res := testutils.HTTPAuthDo(t, req, u)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
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-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(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, false)
// Execute
dat := `{"inactive_reminder": true}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
res := testutils.HTTPAuthDo(t, req, u)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account")
assert.Equal(t, preference.InactiveReminder, true, "preference mismatch")
})
t.Run("with an unused token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, false)
tok := database.Token{
UserID: u.ID,
Type: database.TokenTypeEmailPreference,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"inactive_reminder": true}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var preference database.EmailPreference
var preferenceCount int
var token database.Token
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token")
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
})
t.Run("with nonexistent token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, true)
tok := database.Token{
UserID: u.ID,
Type: database.TokenTypeEmailPreference,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("with expired token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, true)
usedAt := time.Now().Add(-11 * time.Minute)
tok := database.Token{
UserID: u.ID,
Type: database.TokenTypeEmailPreference,
Value: "someTokenValue",
UsedAt: &usedAt,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("with a used but unexpired token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, true)
usedAt := time.Now().Add(-9 * time.Minute)
tok := database.Token{
UserID: u.ID,
Type: database.TokenTypeEmailPreference,
Value: "someTokenValue",
UsedAt: &usedAt,
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch")
})
t.Run("no user and no token", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupEmailPreferenceData(u, true)
// Execute
dat := `{"inactive_reminder": false}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("create a record if not exists", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
tok := database.Token{
UserID: u.ID,
Type: database.TokenTypeEmailPreference,
Value: "someTokenValue",
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var preferenceCount int
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.InactiveReminder, false, "email mismatch")
})
}
func TestGetEmailPreference(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
pref := testutils.SetupEmailPreferenceData(u, true)
// Execute
req := testutils.MakeReq(server.URL, "GET", "/account/email-preference", "")
res := testutils.HTTPAuthDo(t, req, u)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var got presenters.EmailPreference
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
expected := presenters.EmailPreference{
InactiveReminder: pref.InactiveReminder,
ProductUpdate: pref.ProductUpdate,
CreatedAt: presenters.FormatTS(pref.CreatedAt),
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
}
assert.DeepEqual(t, got, expected, "payload mismatch")
}

View file

@ -1,226 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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/log"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
// ErrLoginFailure is an error for failed login
var ErrLoginFailure = errors.New("Wrong email and password combination")
// SessionResponse is a response containing a session information
type SessionResponse struct {
Key string `json:"key"`
ExpiresAt int64 `json:"expires_at"`
}
func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) {
cookie := http.Cookie{
Name: "id",
Value: key,
Expires: expires,
Path: "/",
HttpOnly: true,
}
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 {
return errors.Wrap(err, "updating last_login_at")
}
return nil
}
type signinPayload struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (a *API) signin(w http.ResponseWriter, r *http.Request) {
var params signinPayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
if params.Email == "" || params.Password == "" {
http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized)
return
}
var account database.Account
conn := a.App.DB.Where("email = ?", params.Email).First(&account)
if conn.RecordNotFound() {
http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized)
return
} else if conn.Error != nil {
handlers.DoError(w, "getting user", err, http.StatusInternalServerError)
return
}
password := []byte(params.Password)
err = bcrypt.CompareHashAndPassword([]byte(account.Password.String), password)
if err != nil {
http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized)
return
}
var user database.User
err = a.App.DB.Where("id = ?", account.UserID).First(&user).Error
if err != nil {
handlers.DoError(w, "finding user", err, http.StatusInternalServerError)
return
}
err = a.App.TouchLastLoginAt(user, a.App.DB)
if err != nil {
http.Error(w, errors.Wrap(err, "touching login timestamp").Error(), http.StatusInternalServerError)
return
}
a.respondWithSession(a.App.DB, w, account.UserID, http.StatusOK)
}
func (a *API) signoutOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}
func (a *API) signout(w http.ResponseWriter, r *http.Request) {
key, err := handlers.GetCredential(r)
if err != nil {
handlers.DoError(w, "getting credential", nil, http.StatusInternalServerError)
return
}
if key == "" {
w.WriteHeader(http.StatusNoContent)
return
}
err = a.App.DeleteSession(key)
if err != nil {
handlers.DoError(w, "deleting session", nil, http.StatusInternalServerError)
return
}
handlers.UnsetSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
type registerPayload struct {
Email string `json:"email"`
Password string `json:"password"`
}
func validateRegisterPayload(p registerPayload) error {
if p.Email == "" {
return errors.New("email is required")
}
if len(p.Password) < 8 {
return errors.New("Password should be longer than 8 characters")
}
return nil
}
func parseRegisterPaylaod(r *http.Request) (registerPayload, error) {
var ret registerPayload
if err := json.NewDecoder(r.Body).Decode(&ret); err != nil {
return ret, errors.Wrap(err, "decoding json")
}
return ret, nil
}
func (a *API) register(w http.ResponseWriter, r *http.Request) {
if a.App.Config.DisableRegistration {
handlers.RespondForbidden(w)
return
}
params, err := parseRegisterPaylaod(r)
if err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if err := validateRegisterPayload(params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var count int
if err := a.App.DB.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil {
handlers.DoError(w, "checking duplicate user", err, http.StatusInternalServerError)
return
}
if count > 0 {
http.Error(w, "Duplicate email", http.StatusBadRequest)
return
}
user, err := a.App.CreateUser(params.Email, params.Password)
if err != nil {
handlers.DoError(w, "creating user", err, http.StatusInternalServerError)
return
}
a.respondWithSession(a.App.DB, w, user.ID, http.StatusCreated)
if err := a.App.SendWelcomeEmail(params.Email); err != nil {
log.ErrorWrap(err, "sending welcome email")
}
}
// respondWithSession makes a HTTP response with the session from the user with the given userID.
// It sets the HTTP-Only cookie for browser clients and also sends a JSON response for non-browser clients.
func (a *API) respondWithSession(db *gorm.DB, w http.ResponseWriter, userID int, statusCode int) {
session, err := a.App.CreateSession(userID)
if err != nil {
handlers.DoError(w, "creating session", nil, http.StatusBadRequest)
return
}
setSessionCookie(w, session.Key, session.ExpiresAt)
response := SessionResponse{
Key: session.Key,
ExpiresAt: session.ExpiresAt.Unix(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(response); err != nil {
handlers.DoError(w, "encoding response", err, http.StatusInternalServerError)
return
}
}

View file

@ -1,482 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"net/http"
"testing"
"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/testutils"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
func assertSessionResp(t *testing.T, res *http.Response) {
// after register, should sign in user
var got SessionResponse
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var sessionCount int
var session database.Session
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
testutils.MustExec(t, testutils.DB.First(&session), "getting session")
assert.Equal(t, sessionCount, 1, "sessionCount mismatch")
assert.Equal(t, got.Key, session.Key, "session Key mismatch")
assert.Equal(t, got.ExpiresAt, session.ExpiresAt.Unix(), "session ExpiresAt mismatch")
c := testutils.GetCookieByName(res.Cookies(), "id")
assert.Equal(t, c.Value, session.Key, "session key mismatch")
assert.Equal(t, c.Path, "/", "session path mismatch")
assert.Equal(t, c.HttpOnly, true, "session HTTPOnly mismatch")
assert.Equal(t, c.Expires.Unix(), session.ExpiresAt.Unix(), "session Expires mismatch")
}
func TestRegister(t *testing.T) {
testCases := []struct {
email string
password string
onPremise bool
expectedPro bool
}{
{
email: "alice@example.com",
password: "pass1234",
onPremise: false,
expectedPro: false,
},
{
email: "bob@example.com",
password: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY",
onPremise: false,
expectedPro: false,
},
{
email: "chuck@example.com",
password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC",
onPremise: false,
expectedPro: false,
},
// on premise
{
email: "dan@example.com",
password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC",
onPremise: true,
expectedPro: true,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("register %s %s", tc.email, tc.password), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
c := config.Load()
c.SetOnPremise(tc.onPremise)
// Setup
emailBackend := testutils.MockEmailbackendImplementation{}
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
EmailBackend: &emailBackend,
Config: c,
})
defer server.Close()
dat := fmt.Sprintf(`{"email": "%s", "password": "%s"}`, tc.email, tc.password)
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var account database.Account
testutils.MustExec(t, testutils.DB.Where("email = ?", tc.email).First(&account), "finding account")
assert.Equal(t, account.Email.String, tc.email, "Email mismatch")
assert.NotEqual(t, account.UserID, 0, "UserID mismatch")
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte(tc.password))
assert.Equal(t, passwordErr, nil, "Password mismatch")
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.MaxUSN, 0, "MaxUSN mismatch")
// welcome email
assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch")
assert.DeepEqual(t, emailBackend.Emails[0].To, []string{tc.email}, "email to mismatch")
// after register, should sign in user
assertSessionResp(t, res)
})
}
}
func TestRegisterMissingParams(t *testing.T) {
t.Run("missing email", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
dat := fmt.Sprintf(`{"password": %s}`, "SLMZFM5RmSjA5vfXnG5lPOnrpZSbtmV76cnAcrlr2yU")
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status mismatch")
var accountCount, userCount int
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
assert.Equal(t, accountCount, 0, "accountCount mismatch")
assert.Equal(t, userCount, 0, "userCount mismatch")
})
t.Run("missing password", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
dat := fmt.Sprintf(`{"email": "%s"}`, "alice@example.com")
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status mismatch")
var accountCount, userCount int
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
assert.Equal(t, accountCount, 0, "accountCount mismatch")
assert.Equal(t, userCount, 0, "userCount mismatch")
})
}
func TestRegisterDuplicateEmail(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
dat := `{"email": "alice@example.com", "password": "foobarbaz"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "status code mismatch")
var accountCount, userCount, verificationTokenCount int
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&verificationTokenCount), "counting verification token")
var user database.User
testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user")
assert.Equal(t, accountCount, 1, "account count mismatch")
assert.Equal(t, userCount, 1, "user count mismatch")
assert.Equal(t, verificationTokenCount, 0, "verification_token should not have been created")
assert.Equal(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
}
func TestRegisterDisabled(t *testing.T) {
defer testutils.ClearData(testutils.DB)
c := config.Load()
c.DisableRegistration = true
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: c,
})
defer server.Close()
dat := `{"email": "alice@example.com", "password": "foobarbaz"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusForbidden, "status code mismatch")
var accountCount, userCount int
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
assert.Equal(t, accountCount, 0, "account count mismatch")
assert.Equal(t, userCount, 0, "user count mismatch")
}
func TestSignIn(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
dat := `{"email": "alice@example.com", "password": "pass1234"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var user database.User
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
assert.NotEqual(t, user.LastLoginAt, nil, "LastLoginAt mismatch")
// after register, should sign in user
assertSessionResp(t, res)
})
t.Run("wrong password", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
dat := `{"email": "alice@example.com", "password": "wrongpassword1234"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var user database.User
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
assert.Equal(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
var sessionCount int
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
})
t.Run("wrong email", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
u := testutils.SetupUserData()
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
dat := `{"email": "bob@example.com", "password": "pass1234"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var user database.User
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
assert.DeepEqual(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
var sessionCount int
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
})
t.Run("nonexistent email", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
dat := `{"email": "nonexistent@example.com", "password": "pass1234"}`
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
// Execute
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
var sessionCount int
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
})
}
func TestSignout(t *testing.T) {
t.Run("authenticated", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
aliceUser := testutils.SetupUserData()
testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234")
anotherUser := testutils.SetupUserData()
session1 := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
UserID: aliceUser.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, testutils.DB.Save(&session1), "preparing session1")
session2 := database.Session{
Key: "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=",
UserID: anotherUser.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2")
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
// Execute
req := testutils.MakeReq(server.URL, "POST", "/v3/signout", "")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU="))
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusNoContent, "Status mismatch")
var sessionCount int
var s2 database.Session
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
testutils.MustExec(t, testutils.DB.Where("key = ?", "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=").First(&s2), "getting s2")
assert.Equal(t, sessionCount, 1, "sessionCount mismatch")
c := testutils.GetCookieByName(res.Cookies(), "id")
assert.Equal(t, c.Value, "", "session key mismatch")
assert.Equal(t, c.Path, "/", "session path mismatch")
assert.Equal(t, c.HttpOnly, true, "session HTTPOnly mismatch")
if c.Expires.After(time.Now()) {
t.Error("session cookie is not expired")
}
})
t.Run("unauthenticated", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
aliceUser := testutils.SetupUserData()
testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234")
anotherUser := testutils.SetupUserData()
session1 := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
UserID: aliceUser.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, testutils.DB.Save(&session1), "preparing session1")
session2 := database.Session{
Key: "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=",
UserID: anotherUser.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2")
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
// Execute
req := testutils.MakeReq(server.URL, "POST", "/v3/signout", "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusNoContent, "Status mismatch")
var sessionCount int
var postSession1, postSession2 database.Session
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
testutils.MustExec(t, testutils.DB.Where("key = ?", "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=").First(&postSession1), "getting postSession1")
testutils.MustExec(t, testutils.DB.Where("key = ?", "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=").First(&postSession2), "getting postSession2")
// two existing sessions should remain
assert.Equal(t, sessionCount, 2, "sessionCount mismatch")
c := testutils.GetCookieByName(res.Cookies(), "id")
assert.Equal(t, c, (*http.Cookie)(nil), "id cookie should have not been set")
})
}

View file

@ -1,267 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"net/http"
"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"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
type createBookPayload struct {
Name string `json:"name"`
}
// CreateBookResp is the response from create book api
type CreateBookResp struct {
Book presenters.Book `json:"book"`
}
func validateCreateBookPayload(p createBookPayload) error {
if p.Name == "" {
return errors.New("name is required")
}
return nil
}
// CreateBook creates a new book
func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
return
}
var params createBookPayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
err = validateCreateBookPayload(params)
if err != nil {
handlers.DoError(w, "validating payload", err, http.StatusBadRequest)
return
}
var bookCount int
err = a.App.DB.Model(database.Book{}).
Where("user_id = ? AND label = ?", user.ID, params.Name).
Count(&bookCount).Error
if err != nil {
handlers.DoError(w, "checking duplicate", err, http.StatusInternalServerError)
return
}
if bookCount > 0 {
http.Error(w, "duplicate book exists", http.StatusConflict)
return
}
book, err := a.App.CreateBook(user, params.Name)
if err != nil {
handlers.DoError(w, "inserting book", err, http.StatusInternalServerError)
}
resp := CreateBookResp{
Book: presenters.PresentBook(book),
}
handlers.RespondJSON(w, http.StatusCreated, resp)
}
// BooksOptions is a handler for OPTIONS endpoint for notes
func (a *API) BooksOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}
func respondWithBooks(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) {
var books []database.Book
conn := db.Where("user_id = ? AND NOT deleted", userID).Order("label ASC")
name := query.Get("name")
encryptedStr := query.Get("encrypted")
if name != "" {
part := fmt.Sprintf("%%%s%%", name)
conn = conn.Where("LOWER(label) LIKE ?", part)
}
if encryptedStr != "" {
var encrypted bool
if encryptedStr == "true" {
encrypted = true
} else {
encrypted = false
}
conn = conn.Where("encrypted = ?", encrypted)
}
if err := conn.Find(&books).Error; err != nil {
handlers.DoError(w, "finding books", err, http.StatusInternalServerError)
return
}
presentedBooks := presenters.PresentBooks(books)
handlers.RespondJSON(w, http.StatusOK, presentedBooks)
}
// GetBooks returns books for the user
func (a *API) GetBooks(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
return
}
query := r.URL.Query()
respondWithBooks(a.App.DB, user.ID, query, w)
}
// GetBook returns a book for the user
func (a *API) GetBook(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
return
}
vars := mux.Vars(r)
bookUUID := vars["bookUUID"]
var book database.Book
conn := a.App.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book)
if conn.RecordNotFound() {
w.WriteHeader(http.StatusNotFound)
return
}
if err := conn.Error; err != nil {
handlers.DoError(w, "finding book", err, http.StatusInternalServerError)
return
}
p := presenters.PresentBook(book)
handlers.RespondJSON(w, http.StatusOK, p)
}
type updateBookPayload struct {
Name *string `json:"name"`
}
// UpdateBookResp is the response from create book api
type UpdateBookResp struct {
Book presenters.Book `json:"book"`
}
// UpdateBook updates a book
func (a *API) UpdateBook(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
return
}
vars := mux.Vars(r)
uuid := vars["bookUUID"]
tx := a.App.DB.Begin()
var book database.Book
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
handlers.DoError(w, "finding book", err, http.StatusInternalServerError)
return
}
var params updateBookPayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
book, err = a.App.UpdateBook(tx, user, book, params.Name)
if err != nil {
tx.Rollback()
handlers.DoError(w, "updating a book", err, http.StatusInternalServerError)
}
tx.Commit()
resp := UpdateBookResp{
Book: presenters.PresentBook(book),
}
handlers.RespondJSON(w, http.StatusOK, resp)
}
// DeleteBookResp is the response from create book api
type DeleteBookResp struct {
Status int `json:"status"`
Book presenters.Book `json:"book"`
}
// DeleteBook removes a book
func (a *API) DeleteBook(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
return
}
vars := mux.Vars(r)
uuid := vars["bookUUID"]
tx := a.App.DB.Begin()
var book database.Book
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
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 {
handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
return
}
for _, note := range notes {
if _, err := a.App.DeleteNote(tx, user, note); err != nil {
handlers.DoError(w, "deleting a note", err, http.StatusInternalServerError)
return
}
}
b, err := a.App.DeleteBook(tx, user, book)
if err != nil {
handlers.DoError(w, "deleting book", err, http.StatusInternalServerError)
return
}
tx.Commit()
resp := DeleteBookResp{
Status: http.StatusOK,
Book: presenters.PresentBook(b),
}
handlers.RespondJSON(w, http.StatusOK, resp)
}

View file

@ -1,220 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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"
"fmt"
"net/http"
"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"
"github.com/pkg/errors"
)
type updateNotePayload struct {
BookUUID *string `json:"book_uuid"`
Content *string `json:"content"`
Public *bool `json:"public"`
}
type updateNoteResp struct {
Status int `json:"status"`
Result presenters.Note `json:"result"`
}
func validateUpdateNotePayload(p updateNotePayload) bool {
return p.BookUUID != nil || p.Content != nil || p.Public != nil
}
// UpdateNote updates note
func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var params updateNotePayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
handlers.DoError(w, "decoding params", err, http.StatusInternalServerError)
return
}
if ok := validateUpdateNotePayload(params); !ok {
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 {
handlers.DoError(w, "finding note", err, http.StatusInternalServerError)
return
}
tx := a.App.DB.Begin()
note, err = a.App.UpdateNote(tx, user, note, &app.UpdateNoteParams{
BookUUID: params.BookUUID,
Content: params.Content,
Public: params.Public,
})
if err != nil {
tx.Rollback()
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()
handlers.DoError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError)
return
}
tx.Commit()
// preload associations
note.User = user
note.Book = book
resp := updateNoteResp{
Status: http.StatusOK,
Result: presenters.PresentNote(note),
}
handlers.RespondJSON(w, http.StatusOK, resp)
}
type deleteNoteResp struct {
Status int `json:"status"`
Result presenters.Note `json:"result"`
}
// DeleteNote removes note
func (a *API) DeleteNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
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 {
handlers.DoError(w, "finding note", err, http.StatusInternalServerError)
return
}
tx := a.App.DB.Begin()
n, err := a.App.DeleteNote(tx, user, note)
if err != nil {
tx.Rollback()
handlers.DoError(w, "deleting note", err, http.StatusInternalServerError)
return
}
tx.Commit()
resp := deleteNoteResp{
Status: http.StatusNoContent,
Result: presenters.PresentNote(n),
}
handlers.RespondJSON(w, http.StatusOK, resp)
}
type createNotePayload struct {
BookUUID string `json:"book_uuid"`
Content string `json:"content"`
AddedOn *int64 `json:"added_on"`
EditedOn *int64 `json:"edited_on"`
}
func validateCreateNotePayload(p createNotePayload) error {
if p.BookUUID == "" {
return errors.New("bookUUID is required")
}
return nil
}
// CreateNoteResp is a response for creating a note
type CreateNoteResp struct {
Result presenters.Note `json:"result"`
}
// CreateNote creates a note
func (a *API) CreateNote(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 params createNotePayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
err = validateCreateNotePayload(params)
if err != nil {
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 {
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 {
handlers.DoError(w, "creating note", err, http.StatusInternalServerError)
return
}
// preload associations
note.User = user
note.Book = book
resp := CreateNoteResp{
Result: presenters.PresentNote(note),
}
handlers.RespondJSON(w, http.StatusCreated, resp)
}
// NotesOptions is a handler for OPTIONS endpoint for notes
func (a *API) NotesOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}

View file

@ -1,394 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 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/database"
"github.com/dnote/dnote/pkg/server/testutils"
)
func TestCreateNote(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 58,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
// Execute
dat := fmt.Sprintf(`{"book_uuid": "%s", "content": "note content"}`, b1.UUID)
req := testutils.MakeReq(server.URL, "POST", "/v3/notes", dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var noteRecord database.Note
var bookRecord database.Book
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, bookRecord.Label, b1.Label, "book name mismatch")
assert.Equal(t, bookRecord.UUID, b1.UUID, "book uuid mismatch")
assert.Equal(t, bookRecord.UserID, b1.UserID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, 58, "book usn mismatch")
assert.NotEqual(t, noteRecord.UUID, "", "note uuid should have been generated")
assert.Equal(t, noteRecord.BookUUID, b1.UUID, "note book_uuid mismatch")
assert.Equal(t, noteRecord.Body, "note content", "note content mismatch")
assert.Equal(t, noteRecord.USN, 102, "note usn mismatch")
}
func TestUpdateNote(t *testing.T) {
updatedBody := "some updated content"
b1UUID := "37868a8e-a844-4265-9a4f-0be598084733"
b2UUID := "8f3bd424-6aa5-4ed5-910d-e5b38ab09f8c"
testCases := []struct {
payload string
noteUUID string
noteBookUUID string
noteBody string
notePublic bool
noteDeleted bool
expectedNoteBody string
expectedNoteBookName string
expectedNoteBookUUID string
expectedNotePublic bool
}{
{
payload: fmt.Sprintf(`{
"content": "%s"
}`, updatedBody),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "some updated content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"book_uuid": "%s"
}`, b1UUID),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"book_uuid": "%s"
}`, b2UUID),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"book_uuid": "%s",
"content": "%s"
}`, b2UUID, updatedBody),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: "some updated content",
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"book_uuid": "%s",
"content": "%s"
}`, b1UUID, updatedBody),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "",
noteDeleted: true,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"public": %t
}`, true),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: true,
},
{
payload: fmt.Sprintf(`{
"public": %t
}`, false),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: true,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"content": "%s",
"public": %t
}`, updatedBody, false),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: true,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: fmt.Sprintf(`{
"book_uuid": "%s",
"content": "%s",
"public": %t
}`, b2UUID, updatedBody, true),
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "js",
expectedNotePublic: true,
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UUID: b1UUID,
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UUID: b2UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
note := database.Note{
UserID: user.ID,
UUID: tc.noteUUID,
BookUUID: tc.noteBookUUID,
Body: tc.noteBody,
Deleted: tc.noteDeleted,
Public: tc.notePublic,
}
testutils.MustExec(t, testutils.DB.Save(&note), "preparing note")
// Execute
endpoint := fmt.Sprintf("/v3/notes/%s", note.UUID)
req := testutils.MakeReq(server.URL, "PATCH", endpoint, tc.payload)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "status code mismatch for test case")
var bookRecord database.Book
var noteRecord database.Note
var userRecord database.User
var noteCount, bookCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 2, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, noteRecord.UUID, tc.noteUUID, "note uuid mismatch for test case")
assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, "note content mismatch for test case")
assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, "note book_uuid mismatch for test case")
assert.Equal(t, noteRecord.Public, tc.expectedNotePublic, "note public mismatch for test case")
assert.Equal(t, noteRecord.USN, 102, "note usn mismatch for test case")
assert.Equal(t, userRecord.MaxUSN, 102, "user max_usn mismatch for test case")
})
}
}
func TestDeleteNote(t *testing.T) {
b1UUID := "37868a8e-a844-4265-9a4f-0be598084733"
testCases := []struct {
content string
deleted bool
originalUSN int
expectedUSN int
expectedMaxUSN int
}{
{
content: "n1 content",
deleted: false,
originalUSN: 12,
expectedUSN: 982,
expectedMaxUSN: 982,
},
{
content: "",
deleted: true,
originalUSN: 12,
expectedUSN: 982,
expectedMaxUSN: 982,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 981), "preparing user max_usn")
b1 := database.Book{
UUID: b1UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
note := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: tc.content,
Deleted: tc.deleted,
USN: tc.originalUSN,
}
testutils.MustExec(t, testutils.DB.Save(&note), "preparing note")
// Execute
endpoint := fmt.Sprintf("/v3/notes/%s", note.UUID)
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var bookRecord database.Book
var noteRecord database.Note
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, noteRecord.UUID, note.UUID, "note uuid mismatch for test case")
assert.Equal(t, noteRecord.Body, "", "note content mismatch for test case")
assert.Equal(t, noteRecord.Deleted, true, "note deleted mismatch for test case")
assert.Equal(t, noteRecord.BookUUID, note.BookUUID, "note book_uuid mismatch for test case")
assert.Equal(t, noteRecord.UserID, note.UserID, "note user_id mismatch for test case")
assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note usn mismatch for test case")
assert.Equal(t, userRecord.MaxUSN, tc.expectedMaxUSN, "user max_usn mismatch for test case")
})
}
}

View file

@ -46,6 +46,7 @@ type App struct {
EmailTemplates mailer.Templates
EmailBackend mailer.Backend
Config config.Config
Files map[string][]byte
}
// Validate validates the app configuration

View file

@ -116,6 +116,10 @@ func (a *App) SendWelcomeEmail(email string) error {
// SendPasswordResetEmail sends password reset email
func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
if email == "" {
return ErrEmailRequired
}
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{
AccountEmail: email,
Token: tokenValue,
@ -131,6 +135,10 @@ func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
}
if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, mailer.EmailKindText, body); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
return ErrInvalidSMTPConfig
}
return errors.Wrapf(err, "queueing email for %s", email)
}

67
pkg/server/app/errors.go Normal file
View file

@ -0,0 +1,67 @@
package app
type appError string
func (e appError) Error() string {
return string(e)
}
func (e appError) Public() string {
return string(e)
}
var (
// ErrNotFound an error that indicates that the given resource is not found
ErrNotFound appError = "not found"
// ErrLoginInvalid is an error for invalid login
ErrLoginInvalid appError = "Wrong email and password combination"
// ErrDuplicateEmail is an error for duplicate email
ErrDuplicateEmail appError = "duplicate email"
// ErrEmailRequired is an error for missing email
ErrEmailRequired appError = "Please enter an email"
// ErrPasswordRequired is an error for missing email
ErrPasswordRequired appError = "Please enter a password"
// ErrPasswordTooShort is an error for short password
ErrPasswordTooShort appError = "password should be longer than 8 characters"
// ErrPasswordConfirmationMismatch is an error for password ans password confirmation not matching
ErrPasswordConfirmationMismatch appError = "password confirmation does not match password"
// ErrLoginRequired is an error for not authenticated
ErrLoginRequired appError = "login required"
// ErrBookUUIDRequired is an error for note missing book uuid
ErrBookUUIDRequired appError = "book uuid required"
// ErrBookNameRequired is an error for note missing book name
ErrBookNameRequired appError = "book name required"
// ErrDuplicateBook is an error for duplicate book
ErrDuplicateBook appError = "duplicate book exists"
// ErrEmptyUpdate is an error for empty update params
ErrEmptyUpdate appError = "update is empty"
// ErrInvalidUUID is an error for invalid uuid
ErrInvalidUUID appError = "invalid uuid"
// ErrInvalidSMTPConfig is an error for invalid SMTP configuration
ErrInvalidSMTPConfig appError = "SMTP is not configured"
// ErrInvalidToken is an error for invalid token
ErrInvalidToken appError = "invalid token"
// ErrMissingToken is an error for missing token
ErrMissingToken appError = "missing token"
// ErrExpiredToken is an error for missing token
ErrExpiredToken appError = "This token has expired."
// ErrPasswordResetTokenExpired is an error for expired password reset token
ErrPasswordResetTokenExpired appError = "this link has been expired. Please request a new password reset link."
// ErrInvalidPasswordChangeInput is an error for changing password
ErrInvalidPasswordChangeInput appError = "Both current and new passwords are required to change the password."
ErrInvalidPassword appError = "Invalid currnet password."
// ErrEmailTooLong is an error for email length exceeding the limit
ErrEmailTooLong appError = "Email is too long."
// ErrEmailAlreadyVerified is an error for trying to verify email that is already verified
ErrEmailAlreadyVerified appError = "Email is already verified."
)

View file

@ -19,6 +19,9 @@
package app
import (
"strings"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/jinzhu/gorm"
@ -174,3 +177,144 @@ func (a *App) GetUserNoteByUUID(userID int, uuid string) (*database.Note, error)
return &ret, nil
}
// GetNotesParams is params for finding notes
type GetNotesParams struct {
Year int
Month int
Page int
Books []string
Search string
Encrypted bool
PerPage int
}
type ftsParams struct {
HighlightAll bool
}
func getHeadlineOptions(params *ftsParams) string {
headlineOptions := []string{
"StartSel=<dnotehl>",
"StopSel=</dnotehl>",
"ShortWord=0",
}
if params != nil && params.HighlightAll {
headlineOptions = append(headlineOptions, "HighlightAll=true")
} else {
headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
}
return strings.Join(headlineOptions, ",")
}
func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
headlineOpts := getHeadlineOptions(params)
return conn.Select(`
notes.id,
notes.uuid,
notes.created_at,
notes.updated_at,
notes.book_uuid,
notes.user_id,
notes.added_on,
notes.edited_on,
notes.usn,
notes.deleted,
notes.encrypted,
ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
`, search, headlineOpts)
}
func getNotesBaseQuery(db *gorm.DB, userID int, q GetNotesParams) *gorm.DB {
conn := db.Where(
"notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
userID, false, q.Encrypted,
)
if q.Search != "" {
conn = selectFTSFields(conn, q.Search, nil)
conn = conn.Where("tsv @@ plainto_tsquery('english_nostop', ?)", q.Search)
}
if len(q.Books) > 0 {
conn = conn.Joins("INNER JOIN books ON books.uuid = notes.book_uuid").
Where("books.label in (?)", q.Books)
}
if q.Year != 0 || q.Month != 0 {
dateLowerbound, dateUpperbound := getDateBounds(q.Year, q.Month)
conn = conn.Where("notes.added_on >= ? AND notes.added_on < ?", dateLowerbound, dateUpperbound)
}
return conn
}
func getDateBounds(year, month int) (int64, int64) {
var yearUpperbound, monthUpperbound int
if month == 12 {
monthUpperbound = 1
yearUpperbound = year + 1
} else {
monthUpperbound = month + 1
yearUpperbound = year
}
lower := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixNano()
upper := time.Date(yearUpperbound, time.Month(monthUpperbound), 1, 0, 0, 0, 0, time.UTC).UnixNano()
return lower, upper
}
func orderGetNotes(conn *gorm.DB) *gorm.DB {
return conn.Order("notes.updated_at DESC, notes.id DESC")
}
func paginate(conn *gorm.DB, page, perPage int) *gorm.DB {
// Paginate
if page > 0 {
offset := perPage * (page - 1)
conn = conn.Offset(offset)
}
conn = conn.Limit(perPage)
return conn
}
// GetNotesResult is the result of getting notes
type GetNotesResult struct {
Notes []database.Note
Total int
}
// GetNotes returns a list of matching notes
func (a *App) GetNotes(userID int, params GetNotesParams) (GetNotesResult, error) {
conn := getNotesBaseQuery(a.DB, userID, params)
var total int
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
return GetNotesResult{}, errors.Wrap(err, "counting total")
}
notes := []database.Note{}
if total != 0 {
conn = orderGetNotes(conn)
conn = database.PreloadNote(conn)
conn = paginate(conn, params.Page, params.PerPage)
if err := conn.Find(&notes).Error; err != nil {
return GetNotesResult{}, errors.Wrap(err, "finding notes")
}
}
res := GetNotesResult{
Notes: notes,
Total: total,
}
return res, nil
}

View file

@ -19,6 +19,7 @@
package app
import (
"fmt"
"os"
"github.com/dnote/dnote/pkg/clock"
@ -60,6 +61,12 @@ func NewTest(appParams *App) App {
if appParams != nil && appParams.Config.DisableRegistration {
a.Config.DisableRegistration = appParams.Config.DisableRegistration
}
if appParams != nil && appParams.Config.PageTemplateDir != "" {
a.Config.PageTemplateDir = appParams.Config.PageTemplateDir
}
fmt.Printf("%+v\n", appParams)
fmt.Printf("%+v\n", a)
return a
}

View file

@ -20,6 +20,7 @@ package app
import (
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/token"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
@ -48,9 +49,29 @@ func createEmailPreference(user database.User, tx *gorm.DB) error {
}
// CreateUser creates a user
func (a *App) CreateUser(email, password string) (database.User, error) {
func (a *App) CreateUser(email, password string, passwordConfirmation string) (database.User, error) {
if email == "" {
return database.User{}, ErrEmailRequired
}
if len(password) < 8 {
return database.User{}, ErrPasswordTooShort
}
if password != passwordConfirmation {
return database.User{}, ErrPasswordConfirmationMismatch
}
tx := a.DB.Begin()
var count int
if err := tx.Model(database.Account{}).Where("email = ?", email).Count(&count).Error; err != nil {
return database.User{}, errors.Wrap(err, "counting user")
}
if count > 0 {
return database.User{}, ErrDuplicateEmail
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
tx.Rollback()
@ -99,3 +120,42 @@ func (a *App) CreateUser(email, password string) (database.User, error) {
return user, nil
}
// Authenticate authenticates a user
func (a *App) Authenticate(email, password string) (*database.User, error) {
var account database.Account
conn := a.DB.Where("email = ?", email).First(&account)
if conn.RecordNotFound() {
return nil, ErrNotFound
} else if conn.Error != nil {
return nil, conn.Error
}
err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte(password))
if err != nil {
return nil, ErrLoginInvalid
}
var user database.User
err = a.DB.Where("id = ?", account.UserID).First(&user).Error
if err != nil {
return nil, errors.Wrap(err, "finding user")
}
return &user, nil
}
// SignIn signs in a user
func (a *App) SignIn(user *database.User) (*database.Session, error) {
err := a.TouchLastLoginAt(*user, a.DB)
if err != nil {
log.ErrorWrap(err, "touching login timestamp")
}
session, err := a.CreateSession(user.ID)
if err != nil {
return nil, errors.Wrap(err, "creating session")
}
return &session, nil
}

View file

@ -27,9 +27,10 @@ import (
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
func TestCreateUser(t *testing.T) {
func TestCreateUser_ProValue(t *testing.T) {
testCases := []struct {
onPremise bool
expectedPro bool
@ -54,7 +55,7 @@ func TestCreateUser(t *testing.T) {
a := NewTest(&App{
Config: c,
})
if _, err := a.CreateUser("alice@example.com", "pass1234"); err != nil {
if _, err := a.CreateUser("alice@example.com", "pass1234", "pass1234"); err != nil {
t.Fatal(errors.Wrap(err, "executing"))
}
@ -68,3 +69,53 @@ func TestCreateUser(t *testing.T) {
})
}
}
func TestCreateUser(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
c := config.Load()
a := NewTest(&App{
Config: c,
})
if _, err := a.CreateUser("alice@example.com", "pass1234", "pass1234"); err != nil {
t.Fatal(errors.Wrap(err, "executing"))
}
var userCount int
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
assert.Equal(t, userCount, 1, "book count mismatch")
var accountCount int
var accountRecord database.Account
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
testutils.MustExec(t, testutils.DB.First(&accountRecord), "finding account")
assert.Equal(t, accountCount, 1, "account count mismatch")
assert.Equal(t, accountRecord.Email.String, "alice@example.com", "account email mismatch")
passwordErr := bcrypt.CompareHashAndPassword([]byte(accountRecord.Password.String), []byte("pass1234"))
assert.Equal(t, passwordErr, nil, "Password mismatch")
})
t.Run("duplicate email", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
aliceUser := database.User{}
aliceAccount := database.Account{UserID: aliceUser.ID, Email: database.ToNullString("alice@example.com")}
testutils.MustExec(t, testutils.DB.Save(&aliceUser), "preparing a user")
testutils.MustExec(t, testutils.DB.Save(&aliceAccount), "preparing an account")
a := NewTest(nil)
_, err := a.CreateUser("alice@example.com", "newpassword", "newpassword")
assert.Equal(t, err, ErrDuplicateEmail, "error mismatch")
var userCount, accountCount int
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
assert.Equal(t, userCount, 1, "user count mismatch")
assert.Equal(t, accountCount, 1, "account count mismatch")
})
}

24
pkg/server/assets/js/build.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# build.sh builds styles
set -ex
dir=$(dirname "${BASH_SOURCE[0]}")
basePath="$dir/../../.."
serverDir="$dir/../.."
outputDir="$serverDir/static"
inputDir="$dir/src"
task="cp $inputDir/main.js $outputDir"
if [[ "$1" == "true" ]]; then
(
cd "$basePath/watcher" && \
go run main.go \
--task="$task" \
--context="$inputDir" \
"$inputDir"
)
else
eval "$task"
fi

View file

@ -0,0 +1,11 @@
{
"name": "js",
"version": "0.1.0",
"description": "JavaScript assets",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "AGPL-3.0-or-later"
}

View file

@ -0,0 +1,59 @@
var getNextSibling = function (el, selector) {
var sibling = el.nextElementSibling;
if (!selector) {
return sibling;
}
while (sibling) {
if (sibling.matches(selector)) return sibling;
sibling = sibling.nextElementSibling;
}
};
var dropdownTriggerEls = document.getElementsByClassName('dropdown-trigger');
for (var i = 0; i < dropdownTriggerEls.length; i++) {
var dropdownTriggerEl = dropdownTriggerEls[i];
dropdownTriggerEl.addEventListener('click', function (e) {
var el = getNextSibling(e.target, '.dropdown-content');
el.classList.toggle('show');
});
}
// Dropdown closer
window.onclick = function (e) {
// Close dropdown on click outside the dropdown content or trigger
function shouldClose(target) {
var dropdownContentEls = document.getElementsByClassName(
'dropdown-content'
);
for (let i = 0; i < dropdownContentEls.length; ++i) {
var el = dropdownContentEls[i];
if (el.contains(target)) {
return false;
}
}
for (let i = 0; i < dropdownTriggerEls.length; ++i) {
var el = dropdownTriggerEls[i];
if (el.contains(target)) {
return false;
}
}
return true;
}
if (shouldClose(e.target)) {
var dropdowns = document.getElementsByClassName('dropdown-content');
for (var i = 0; i < dropdowns.length; i++) {
var openDropdown = dropdowns[i];
if (openDropdown.classList.contains('show')) {
openDropdown.classList.remove('show');
}
}
}
};

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
500
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -0,0 +1,52 @@
{
"name": "Dnote",
"short_name": "Dnote",
"icons": [
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
},
{
"src": "ASSET_BASE_PLACEHOLDER\/logo-512x512.png",
"sizes": "512x512",
"type": "image\/png",
"density": "4.0"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#072a40",
"theme_color": "#ffffff"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Page Not Found | Dnote</title>
<style type="text/css">
body {
margin: 0;
}
main {
text-align: center;
padding: 8em 0.5em;
}
.logo {
width: 207px;
}
.contact-list {
list-style: none;
padding-left: 0;
}
.contact-list li {
display: inline-block;
padding: 0 0.7em;
}
</style>
</head>
<body>
<main>
<h1>You are offline</h1>
<p>
Please check you connection and try again.
</p>
</main>
</body>
</html>

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# build.sh builds styles
set -ex
dir=$(dirname "${BASH_SOURCE[0]}")
serverDir="$dir/../.."
outputDir="$serverDir/static"
inputDir="$dir/src"
rm -rf "${outputDir:?}/*"
sass --version
task="sass \
--style compressed \
--source-map \
$inputDir:$outputDir"
# compile first then watch
eval "$task"
if [[ "$1" == "true" ]]; then
eval "$task --watch --poll"
fi

View file

@ -0,0 +1,11 @@
.books-page {
.books-content {
padding: rem(16px) rem(24px);
margin-top: rem(16px);
h1 {
border-bottom: 1px solid $lighter-gray;
margin-bottom: rem(12px);
}
}
}

View file

@ -0,0 +1,176 @@
/* 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/>.
*/
// From Bootstrap <4.3.1
// MIT Licensed - https://github.com/twbs/bootstrap/blob/master/LICENSE
.form-control {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.alert {
position: relative;
padding: 1.75rem 1.25rem;
border: 1px solid transparent;
}
.alert-heading {
color: inherit;
}
.alert-link {
font-weight: 700;
}
.alert-dismissible {
// padding-right: 4rem;
}
.alert-dismissible .close {
position: absolute;
top: 0;
right: 0;
padding: 0.75rem 1.25rem;
color: inherit;
}
.alert-primary {
color: #004085;
background-color: #cce5ff;
border-color: #b8daff;
}
.alert-primary hr {
border-top-color: #9fcdff;
}
.alert-primary .alert-link {
color: #002752;
}
.alert-secondary {
color: #383d41;
background-color: #e2e3e5;
border-color: #d6d8db;
}
.alert-secondary hr {
border-top-color: #c8cbcf;
}
.alert-secondary .alert-link {
color: #202326;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.alert-success hr {
border-top-color: #b1dfbb;
}
.alert-success .alert-link {
color: #0b2e13;
}
.alert-info {
color: #0c5460;
background-color: #d1ecf1;
border-color: #bee5eb;
}
.alert-info hr {
border-top-color: #abdde5;
}
.alert-info .alert-link {
color: #062c33;
}
.alert-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeeba;
}
.alert-warning hr {
border-top-color: #ffe8a1;
}
.alert-warning .alert-link {
color: #533f03;
}
.alert-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert-danger hr {
border-top-color: #f1b0b7;
}
.alert-danger .alert-link {
color: #491217;
}
.alert-light {
color: #818182;
background-color: #fefefe;
border-color: #fdfdfe;
}
.alert-light hr {
border-top-color: #ececf6;
}
.alert-light .alert-link {
color: #686868;
}
.alert-dark {
color: #1b1e21;
background-color: #d6d8d9;
border-color: #c6c8ca;
}
.alert-dark hr {
border-top-color: #b9bbbe;
}
.alert-dark .alert-link {
color: #040505;
}
// custom
.alert-slim {
padding: 0.75rem 1.25rem;
}

View file

@ -0,0 +1,182 @@
/* 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 './theme';
@import './rem';
@import './font';
@mixin button($text-color, $background-color) {
color: $text-color;
background-color: $background-color;
&:not(:disabled):hover {
color: $text-color;
background-color: darken($background-color, 5%);
box-shadow: 0px 0px 4px 2px #cacaca;
}
}
@mixin button-outline($color, $border-color) {
background: transparent;
color: $color;
&:not(.button-no-ui) {
border-color: $border-color;
border-width: 2px;
}
&:not(:disabled):hover {
color: $color;
box-shadow: 0px 0px 4px 2px #cacaca;
}
}
.button {
position: relative;
display: inline-block;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border-image: initial;
transition-property: color, box-shadow;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
text-decoration: none;
cursor: pointer;
&:not(.button-no-ui) {
border-width: 1px;
border-style: solid;
border-color: transparent;
}
&:not(:disabled):hover {
text-decoration: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
&:focus {
outline: 2px dotted #9c9c9c;
}
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.button-small {
@include font-size('small');
padding: rem(4px) rem(12px);
}
.button-normal {
// @include font-size('small');
padding: rem(8px) rem(16px);
}
.button-large {
@include font-size('medium');
padding: rem(8px) rem(24px);
@include breakpoint(md) {
padding: rem(12px) rem(36px);
}
@include breakpoint(lg) {
padding: rem(12px) rem(48px);
}
}
.button-xlarge {
@include font-size('x-large');
padding: rem(16px) rem(24px);
@include breakpoint(md) {
padding: rem(12px) rem(36px);
}
@include breakpoint(lg) {
padding: rem(16px) rem(48px);
}
}
.button-first {
@include button(#ffffff, #333745);
}
.button-first-outline {
@include button-outline(#333745, #333745);
}
.button-second {
@include button($black, $second);
}
.button-second-outline {
@include button-outline($black, $second);
}
.button-third {
@include button(#ffffff, $third);
}
.button-third-outline {
@include button-outline($third, $third);
}
.button-danger {
@include button-outline($danger-text, $danger-text);
font-weight: 600;
}
.button-stretch {
width: 100%;
}
.button ~ .button {
margin-left: rem(12px);
}
.button-no-ui {
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.button-no-padding {
padding: 0;
}
.button-link {
color: $link;
&:hover {
color: $link-hover;
text-decoration: underline;
}
}

View file

@ -0,0 +1,111 @@
/* 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 './responsive';
$lowDecay: 0.1;
$medDecay: 0.15;
$highDecay: 0.2;
// font-size is a mixin for pre-defined font-size values in rem.
// It also includes px as a fallback for older browsers.
@mixin font-size($size, $responsive: true) {
$smSizeValue: 16;
$mdSizeValue: 16;
$lgSizeValue: 16;
@if $size == 'x-small' {
$baseSize: 13;
$smSizeValue: $baseSize;
$mdSizeValue: $baseSize;
$lgSizeValue: $baseSize;
} @else if $size == 'small' {
$baseSize: 14;
$smSizeValue: $baseSize;
$mdSizeValue: $baseSize;
$lgSizeValue: $baseSize;
} @else if $size == 'regular' {
$baseSize: 16;
$smSizeValue: $baseSize * (1 - $lowDecay);
$mdSizeValue: $baseSize * (1 - $lowDecay);
$lgSizeValue: $baseSize;
} @else if $size == 'medium' {
$baseSize: 18;
$smSizeValue: $baseSize;
$mdSizeValue: $baseSize;
$lgSizeValue: $baseSize;
} @else if $size == 'large' {
$baseSize: 20;
$smSizeValue: $baseSize;
$mdSizeValue: $baseSize;
$lgSizeValue: $baseSize;
} @else if $size == 'x-large' {
$baseSize: 24;
$smSizeValue: $baseSize * (1 - $lowDecay * 2);
$mdSizeValue: $baseSize * (1 - $lowDecay);
$lgSizeValue: $baseSize;
} @else if $size == '2x-large' {
$baseSize: 32;
$smSizeValue: $baseSize * (1 - $lowDecay * 2);
$mdSizeValue: $baseSize * (1 - $lowDecay);
$lgSizeValue: $baseSize;
} @else if $size == '3x-large' {
$baseSize: 36;
$smSizeValue: $baseSize * (1 - $medDecay * 2);
$mdSizeValue: $baseSize * (1 - $medDecay);
$lgSizeValue: $baseSize;
} @else if $size == '4x-large' {
$baseSize: 48;
$smSizeValue: $baseSize * (1 - $medDecay * 2);
$mdSizeValue: $baseSize * (1 - $medDecay);
$lgSizeValue: $baseSize;
} @else if $size == '5x-large' {
$baseSize: 56;
$smSizeValue: $baseSize * (1 - $highDecay * 2);
$mdSizeValue: $baseSize * (1 - $highDecay);
$lgSizeValue: $baseSize;
}
@if $responsive == true {
font-size: $smSizeValue * 1px;
font-size: $smSizeValue * 0.1rem;
@include breakpoint(md) {
font-size: $mdSizeValue * 1px;
font-size: $mdSizeValue * 0.1rem;
}
@include breakpoint(lg) {
font-size: $lgSizeValue * 1px;
font-size: $lgSizeValue * 0.1rem;
}
} @else {
font-size: $lgSizeValue * 1px;
font-size: $lgSizeValue * 0.1rem;
}
}

View file

@ -0,0 +1,85 @@
.main {
position: relative;
display: flex;
flex-direction: column;
background: $lighter-gray;
min-height: calc(100vh - #{$header-height});
// margin-bottom: $footer-height;
&.nofooter {
margin-bottom: 0;
}
&.noheader:not(.nofooter) {
min-height: calc(100vh - #{$footer-height});
}
&.nofooter:not(.noheader) {
min-height: calc(100vh - #{$header-height});
}
&.nofooter.noheader {
min-height: 100vh;
}
@include breakpoint(lg) {
margin-bottom: 0;
min-height: calc(100vh - #{$header-height});
}
}
/* partials */
.partial--time {
color: $gray;
@include font-size('small');
.mobile-text {
@include breakpoint(md) {
display: none;
}
}
.text {
display: none;
@include breakpoint(md) {
display: inherit;
}
}
}
.partial--page-toolbar {
@include breakpoint(lg) {
height: rem(48px);
border-radius: rem(4px);
background: $light;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
&.bottom {
margin-top: rem(12px);
}
}
}
/* icons */
.icon--caret-right {
transform: rotate(-90deg);
}
.icon--caret-left {
transform: rotate(90deg);
}
// was originally used in note show
.frame {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
background: white;
&.collapsed {
.book-label {
// control the coloro of ellipsis when overflown
// color: $light-gray;
}
.book-label a {
// color: $light-gray;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,192 @@
@import './theme';
@import './variables';
.header-wrapper {
padding: 0;
z-index: 2;
position: relative;
display: flex;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
background: $first;
align-items: stretch;
justify-content: space-between;
flex: 1;
flex-direction: column;
position: sticky;
top: 0;
z-index: 4;
height: $header-height;
.container {
height: 100%;
}
@include breakpoint(md) {
flex-direction: row;
}
.header-content {
display: flex;
justify-content: space-between;
height: 100%;
}
.left {
display: flex;
}
.right {
display: flex;
}
.search-wrapper {
align-items: center;
display: flex;
margin-left: rem(32px);
}
.search-input {
width: rem(356px);
border: 0;
padding: 4px 12px;
border-radius: rem(4px);
@include font-size('small');
}
.brand {
display: flex;
align-items: center;
&:hover {
text-decoration: none;
}
}
.main-nav {
margin-left: rem(32px);
display: flex;
.list {
display: flex;
}
.item {
display: flex;
align-items: stretch;
}
.nav-link {
@include font-size('small');
display: flex;
font-weight: 600;
align-items: center;
padding: 0 rem(16px);
color: $white;
&:hover {
color: $white;
text-decoration: none;
background: lighten($first, 10%);
}
}
.nav-item {
@include font-size('small');
font-weight: 600;
}
}
.dropdown-trigger {
color: white;
padding: 16px;
font-size: 16px;
border: none;
cursor: pointer;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
width: rem(240px);
background: #fff;
border: 1px solid #d8d8d8;
border-radius: 4px;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.15);
top: calc(100% + 4px);
z-index: 1;
&.show {
display: block;
}
&.right-align {
right: 0;
}
}
.account-dropdown {
.dropdown-trigger {
height: 100%;
}
.account-dropdown-header {
@include font-size('small');
color: $light-gray;
padding: rem(8px) rem(12px);
display: block;
margin-bottom: 0;
white-space: nowrap;
svg {
fill: $light-gray;
}
.email {
font-weight: 600;
white-space: normal;
word-break: break-all;
}
}
.dropdown-link {
@include font-size('small');
white-space: pre;
padding: rem(8px) rem(14px);
width: 100%;
display: block;
color: black;
&:hover {
background: $lighter-gray;
text-decoration: none;
color: #0056b3;
}
&.disabled {
color: #d4d4d4;
cursor: not-allowed;
}
&:not(.disabled):focus {
background: $lighter-gray;
color: #0056b3;
outline: 1px dotted gray;
}
}
.session-notice-wrapper {
display: flex;
align-items: center;
}
.session-notice {
margin-left: rem(4px);
}
}
}

View file

@ -0,0 +1,147 @@
/* 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/>.
*/
/*
highlight.js
BSD 3-Clause License
Copyright (c) 2006, Ivan Sagalaev.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// github style
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
color: #333;
background: #f8f8f8;
}
.hljs-comment,
.hljs-quote {
color: #998;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: #333;
font-weight: bold;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr {
color: #008080;
}
.hljs-string,
.hljs-doctag {
color: #d14;
}
.hljs-title,
.hljs-section,
.hljs-selector-id {
color: #900;
font-weight: bold;
}
.hljs-subst {
font-weight: normal;
}
.hljs-type,
.hljs-class .hljs-title {
color: #458;
font-weight: bold;
}
.hljs-tag,
.hljs-name,
.hljs-attribute {
color: #000080;
font-weight: normal;
}
.hljs-regexp,
.hljs-link {
color: #009926;
}
.hljs-symbol,
.hljs-bullet {
color: #990073;
}
.hljs-built_in,
.hljs-builtin-name {
color: #0086b3;
}
.hljs-meta {
color: #999;
font-weight: bold;
}
.hljs-deletion {
background: #fdd;
}
.hljs-addition {
background: #dfd;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View file

@ -0,0 +1,185 @@
@import './theme';
@import './font';
.home-page {
.note-group-list {
flex-grow: 1;
@include breakpoint(lg) {
margin-top: rem(16px);
}
.note-group-list-empty {
padding: rem(40px) rem(16px);
text-align: center;
color: $gray;
}
}
.note-group {
position: relative;
border-radius: 4px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
&:not(:first-of-type) {
margin-top: rem(20px);
@include breakpoint(md) {
margin-top: rem(24px);
}
}
.note-group-header {
@include font-size('small');
display: flex;
justify-content: space-between;
color: white;
padding: rem(12px) rem(16px);
background: $light;
color: $black;
border-bottom: 1px solid $border-color;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.date {
font-weight: 600;
@include font-size('small');
}
.mask {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: white;
z-index: 1;
opacity: 0.8;
}
.header-date {
font-weight: 600;
@include font-size('regular');
}
.header-count {
font-weight: 300;
}
.list {
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
}
.note-list {
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
.note-item {
background: white;
position: relative;
border-bottom: 1px solid $border-color;
.link {
color: $black;
display: block;
padding: rem(12px) rem(16px);
border: 2px solid transparent;
&:hover {
text-decoration: none;
background: $light-blue;
color: inherit;
}
}
.meta {
line-height: rem(16px);
}
.body {
overflow: hidden;
text-overflow: ellipsis;
}
.note-header {
display: flex;
justify-content: space-between;
}
.note-content {
margin-top: rem(12px);
line-height: 1.6rem;
overflow: hidden;
text-overflow: ellipsis;
color: $gray;
}
.book-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
@include font-size('small');
width: 212px;
@include breakpoint('md') {
width: 320px;
}
}
.match {
display: inline-block;
background: #f7f77d;
padding: rem(4px) rem(4px);
}
}
.toolbar {
text-align: right;
}
.paginator {
display: inline-flex;
align-items: center;
.paginator-info {
@include font-size('small');
color: $gray;
}
.paginator-link {
padding: rem(12px) rem(12px);
&.disabled {
cursor: not-allowed;
}
}
.paginator-link-prev {
margin-left: rem(8px);
@include breakpoint(md) {
margin-left: rem(20px);
}
}
.caret-next {
transform: rotate(-90deg);
}
.caret-prev {
transform: rotate(90deg);
}
.paginator-label {
font-weight: 600;
}
}
}

View file

@ -0,0 +1,88 @@
@import './theme';
@import './font';
.auth-page {
background: $lighter-gray;
text-align: center;
min-height: 100vh;
padding: 50px 0;
.auth-button {
margin-top: 8px;
}
.heading {
color: $black;
@include font-size('2x-large');
font-weight: 300;
margin-top: 12px;
margin-bottom: 0;
}
.body {
max-width: 420px;
margin-left: auto;
margin-right: auto;
margin-top: 20px;
}
.referrer-flash {
margin: 24px 0;
}
.error-flash {
margin-bottom: 24px;
}
.footer {
margin-top: 20px;
line-height: 20px;
}
.callout {
color: #7c7c7c;
@include font-size('small');
}
.cta {
@include font-size('small');
}
.panel {
border: 1px solid $border-color;
background: $white;
border-radius: 2px;
padding: 20px;
text-align: left;
}
.auth-button {
margin-top: 16px;
}
.input-row {
& ~ .input-row {
margin-top: 12px;
}
}
.label {
@include font-size('small');
font-weight: 600;
width: 100%;
margin-bottom: 0;
}
.forgot {
@include font-size('small');
float: right;
font-weight: 400;
}
&.password-reset-page {
.email-input {
margin-top: rem(16px);
}
}
.alert {
margin-bottom: 1rem;
}
}

View file

@ -0,0 +1,966 @@
/* 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/>.
*/
/*
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.markdown-body .anchor {
float: left;
line-height: 1;
margin-left: -20px;
padding-right: 4px;
}
.markdown-body .anchor:focus {
outline: none;
}
.markdown-body h1:hover .anchor,
.markdown-body h2:hover .anchor,
.markdown-body h3:hover .anchor,
.markdown-body h4:hover .anchor,
.markdown-body h5:hover .anchor,
.markdown-body h6:hover .anchor {
text-decoration: none;
}
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
color: #24292e;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.markdown-body .pl-c {
color: #6a737d;
}
.markdown-body .pl-c1,
.markdown-body .pl-s .pl-v {
color: #005cc5;
}
.markdown-body .pl-e,
.markdown-body .pl-en {
color: #6f42c1;
}
.markdown-body .pl-s .pl-s1,
.markdown-body .pl-smi {
color: #24292e;
}
.markdown-body .pl-ent {
color: #22863a;
}
.markdown-body .pl-k {
color: #d73a49;
}
.markdown-body .pl-pds,
.markdown-body .pl-s,
.markdown-body .pl-s .pl-pse .pl-s1,
.markdown-body .pl-sr,
.markdown-body .pl-sr .pl-cce,
.markdown-body .pl-sr .pl-sra,
.markdown-body .pl-sr .pl-sre {
color: #032f62;
}
.markdown-body .pl-smw,
.markdown-body .pl-v {
color: #e36209;
}
.markdown-body .pl-bu {
color: #b31d28;
}
.markdown-body .pl-ii {
background-color: #b31d28;
color: #fafbfc;
}
.markdown-body .pl-c2 {
background-color: #d73a49;
color: #fafbfc;
}
.markdown-body .pl-c2:before {
content: '^M';
}
.markdown-body .pl-sr .pl-cce {
color: #22863a;
font-weight: 700;
}
.markdown-body .pl-ml {
color: #735c0f;
}
.markdown-body .pl-mh,
.markdown-body .pl-mh .pl-en,
.markdown-body .pl-ms {
color: #005cc5;
font-weight: 700;
}
.markdown-body .pl-mi {
color: #24292e;
font-style: italic;
}
.markdown-body .pl-mb {
color: #24292e;
font-weight: 700;
}
.markdown-body .pl-md {
background-color: #ffeef0;
color: #b31d28;
}
.markdown-body .pl-mi1 {
background-color: #f0fff4;
color: #22863a;
}
.markdown-body .pl-mc {
background-color: #ffebda;
color: #e36209;
}
.markdown-body .pl-mi2 {
background-color: #005cc5;
color: #f6f8fa;
}
.markdown-body .pl-mdr {
color: #6f42c1;
font-weight: 700;
}
.markdown-body .pl-ba {
color: #586069;
}
.markdown-body .pl-sg {
color: #959da5;
}
.markdown-body .pl-corl {
color: #032f62;
text-decoration: underline;
}
.markdown-body details {
display: block;
}
.markdown-body summary {
display: list-item;
}
.markdown-body a {
background-color: transparent;
}
.markdown-body a:active,
.markdown-body a:hover {
outline-width: 0;
}
.markdown-body strong {
font-weight: inherit;
font-weight: bolder;
}
.markdown-body h1 {
font-size: 2em;
margin: 0.67em 0;
}
.markdown-body img {
border-style: none;
}
.markdown-body code,
.markdown-body kbd,
.markdown-body pre {
font-family: monospace, monospace;
font-size: 1em;
}
.markdown-body hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
.markdown-body input {
font: inherit;
margin: 0;
}
.markdown-body input {
overflow: visible;
}
.markdown-body [type='checkbox'] {
box-sizing: border-box;
padding: 0;
}
.markdown-body * {
box-sizing: border-box;
}
.markdown-body input {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.markdown-body a {
color: #0366d6;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body hr {
background: transparent;
border: 0;
border-bottom: 1px solid #dfe2e5;
height: 0;
margin: 15px 0;
overflow: hidden;
}
.markdown-body hr:before {
content: '';
display: table;
}
.markdown-body hr:after {
clear: both;
content: '';
display: table;
}
.markdown-body table {
border-collapse: collapse;
border-spacing: 0;
}
.markdown-body td,
.markdown-body th {
padding: 0;
}
.markdown-body details summary {
cursor: pointer;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-bottom: 0;
margin-top: 0;
}
.markdown-body h1 {
font-size: 32px;
}
.markdown-body h1,
.markdown-body h2 {
font-weight: 600;
}
.markdown-body h2 {
font-size: 24px;
}
.markdown-body h3 {
font-size: 20px;
}
.markdown-body h3,
.markdown-body h4 {
font-weight: 600;
}
.markdown-body h4 {
font-size: 16px;
}
.markdown-body h5 {
font-size: 14px;
}
.markdown-body h5,
.markdown-body h6 {
font-weight: 600;
}
.markdown-body h6 {
font-size: 12px;
}
.markdown-body p {
margin-bottom: 10px;
margin-top: 0;
}
.markdown-body blockquote {
margin: 0;
}
.markdown-body ol,
.markdown-body ul {
margin-bottom: 0;
margin-top: 0;
padding-left: 0;
}
.markdown-body ol ol,
.markdown-body ul ol {
list-style-type: lower-roman;
}
.markdown-body ol ol ol,
.markdown-body ol ul ol,
.markdown-body ul ol ol,
.markdown-body ul ul ol {
list-style-type: lower-alpha;
}
.markdown-body dd {
margin-left: 0;
}
.markdown-body code,
.markdown-body pre {
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
monospace;
font-size: 12px;
}
.markdown-body pre {
margin-bottom: 0;
margin-top: 0;
}
.markdown-body input::-webkit-inner-spin-button,
.markdown-body input::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
.markdown-body .border {
border: 1px solid #e1e4e8 !important;
}
.markdown-body .border-0 {
border: 0 !important;
}
.markdown-body .border-bottom {
border-bottom: 1px solid #e1e4e8 !important;
}
.markdown-body .rounded-1 {
border-radius: 3px !important;
}
.markdown-body .bg-white {
background-color: #fff !important;
}
.markdown-body .bg-gray-light {
background-color: #fafbfc !important;
}
.markdown-body .text-gray-light {
color: #6a737d !important;
}
.markdown-body .mb-0 {
margin-bottom: 0 !important;
}
.markdown-body .my-2 {
margin-bottom: 8px !important;
margin-top: 8px !important;
}
.markdown-body .pl-0 {
padding-left: 0 !important;
}
.markdown-body .py-0 {
padding-bottom: 0 !important;
padding-top: 0 !important;
}
.markdown-body .pl-1 {
padding-left: 4px !important;
}
.markdown-body .pl-2 {
padding-left: 8px !important;
}
.markdown-body .py-2 {
padding-bottom: 8px !important;
padding-top: 8px !important;
}
.markdown-body .pl-3,
.markdown-body .px-3 {
padding-left: 16px !important;
}
.markdown-body .px-3 {
padding-right: 16px !important;
}
.markdown-body .pl-4 {
padding-left: 24px !important;
}
.markdown-body .pl-5 {
padding-left: 32px !important;
}
.markdown-body .pl-6 {
padding-left: 40px !important;
}
.markdown-body .f6 {
font-size: 12px !important;
}
.markdown-body .lh-condensed {
line-height: 1.25 !important;
}
.markdown-body .text-bold {
font-weight: 600 !important;
}
.markdown-body:before {
content: '';
display: table;
}
.markdown-body:after {
clear: both;
content: '';
display: table;
}
.markdown-body > :first-child {
margin-top: 0 !important;
}
.markdown-body > :last-child {
margin-bottom: 0 !important;
}
.markdown-body a:not([href]) {
color: inherit;
text-decoration: none;
}
.markdown-body blockquote,
.markdown-body dl,
.markdown-body ol,
.markdown-body p,
.markdown-body pre,
.markdown-body table,
.markdown-body ul {
margin-bottom: 16px;
margin-top: 0;
}
.markdown-body hr {
background-color: #e1e4e8;
border: 0;
height: 0.25em;
margin: 24px 0;
padding: 0;
}
.markdown-body blockquote {
border-left: 0.25em solid #dfe2e5;
color: #6a737d;
padding: 0 1em;
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body kbd {
background-color: #fafbfc;
border: 1px solid #c6cbd1;
border-bottom-color: #959da5;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #959da5;
color: #444d56;
display: inline-block;
font-size: 11px;
line-height: 10px;
padding: 3px 5px;
vertical-align: middle;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown-body h1 {
font-size: 2em;
}
.markdown-body h1,
.markdown-body h2 {
padding-bottom: 0.3em;
}
.markdown-body h2 {
border-bottom: 1px solid #eaecef;
}
.markdown-body h2 {
font-size: 1.5em;
}
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
color: #6a737d;
font-size: 0.85em;
}
.markdown-body ol,
.markdown-body ul {
padding-left: 2em;
}
.markdown-body ol ol,
.markdown-body ol ul,
.markdown-body ul ol,
.markdown-body ul ul {
margin-bottom: 0;
margin-top: 0;
}
.markdown-body li {
word-wrap: break-all;
}
.markdown-body li > p {
margin-top: 16px;
}
.markdown-body li + li {
margin-top: 0.25em;
}
.markdown-body dl {
padding: 0;
}
.markdown-body dl dt {
font-size: 1em;
font-style: italic;
font-weight: 600;
margin-top: 16px;
padding: 0;
}
.markdown-body dl dd {
margin-bottom: 16px;
padding: 0 16px;
}
.markdown-body table {
display: block;
overflow: auto;
width: 100%;
}
.markdown-body table th {
font-weight: 600;
}
.markdown-body table td,
.markdown-body table th {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.markdown-body img {
background-color: #fff;
box-sizing: content-box;
max-width: 100%;
}
.markdown-body img[align='right'] {
padding-left: 20px;
}
.markdown-body img[align='left'] {
padding-right: 20px;
}
.markdown-body code {
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-size: 85%;
margin: 0;
padding: 0.2em 0.4em;
}
.markdown-body pre {
word-wrap: normal;
}
.markdown-body pre > code {
background: transparent;
border: 0;
font-size: 100%;
margin: 0;
padding: 0;
white-space: pre;
word-break: normal;
white-space: pre-wrap;
}
.markdown-body .highlight {
margin-bottom: 16px;
}
.markdown-body .highlight pre {
margin-bottom: 0;
word-break: normal;
}
.markdown-body .highlight pre,
.markdown-body pre {
background-color: #f6f8fa;
border-radius: 3px;
font-size: 85%;
line-height: 1.45;
overflow: auto;
padding: 16px;
}
.markdown-body pre code {
background-color: transparent;
border: 0;
display: inline;
line-height: inherit;
margin: 0;
max-width: auto;
overflow: visible;
padding: 0;
word-wrap: normal;
}
.markdown-body .commit-tease-sha {
color: #444d56;
display: inline-block;
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
monospace;
font-size: 90%;
}
.markdown-body .blob-wrapper {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
overflow-x: auto;
overflow-y: hidden;
}
.markdown-body .blob-wrapper-embedded {
max-height: 240px;
overflow-y: auto;
}
.markdown-body .blob-num {
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
color: rgba(27, 31, 35, 0.3);
cursor: pointer;
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
monospace;
font-size: 12px;
line-height: 20px;
min-width: 50px;
padding-left: 10px;
padding-right: 10px;
text-align: right;
user-select: none;
vertical-align: top;
white-space: nowrap;
width: 1%;
}
.markdown-body .blob-num:hover {
color: rgba(27, 31, 35, 0.6);
}
.markdown-body .blob-num:before {
content: attr(data-line-number);
}
.markdown-body .blob-code {
line-height: 20px;
padding-left: 10px;
padding-right: 10px;
position: relative;
vertical-align: top;
}
.markdown-body .blob-code-inner {
color: #24292e;
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
monospace;
font-size: 12px;
overflow: visible;
white-space: pre;
word-wrap: normal;
}
.markdown-body .pl-token.active,
.markdown-body .pl-token:hover {
background: #ffea7f;
cursor: pointer;
}
.markdown-body kbd {
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-bottom-color: #c6cbd1;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #c6cbd1;
color: #444d56;
display: inline-block;
font: 11px SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
monospace;
line-height: 10px;
padding: 3px 5px;
vertical-align: middle;
}
.markdown-body :checked + .radio-label {
border-color: #0366d6;
position: relative;
z-index: 1;
}
.markdown-body .tab-size[data-tab-size='1'] {
-moz-tab-size: 1;
tab-size: 1;
}
.markdown-body .tab-size[data-tab-size='2'] {
-moz-tab-size: 2;
tab-size: 2;
}
.markdown-body .tab-size[data-tab-size='3'] {
-moz-tab-size: 3;
tab-size: 3;
}
.markdown-body .tab-size[data-tab-size='4'] {
-moz-tab-size: 4;
tab-size: 4;
}
.markdown-body .tab-size[data-tab-size='5'] {
-moz-tab-size: 5;
tab-size: 5;
}
.markdown-body .tab-size[data-tab-size='6'] {
-moz-tab-size: 6;
tab-size: 6;
}
.markdown-body .tab-size[data-tab-size='7'] {
-moz-tab-size: 7;
tab-size: 7;
}
.markdown-body .tab-size[data-tab-size='8'] {
-moz-tab-size: 8;
tab-size: 8;
}
.markdown-body .tab-size[data-tab-size='9'] {
-moz-tab-size: 9;
tab-size: 9;
}
.markdown-body .tab-size[data-tab-size='10'] {
-moz-tab-size: 10;
tab-size: 10;
}
.markdown-body .tab-size[data-tab-size='11'] {
-moz-tab-size: 11;
tab-size: 11;
}
.markdown-body .tab-size[data-tab-size='12'] {
-moz-tab-size: 12;
tab-size: 12;
}
.markdown-body .task-list-item {
list-style-type: none;
}
.markdown-body .task-list-item + .task-list-item {
margin-top: 3px;
}
.markdown-body .task-list-item input {
margin: 0 0.2em 0.25em -1.6em;
vertical-align: middle;
}
.markdown-body hr {
border-bottom-color: #eee;
}
.markdown-body .pl-0 {
padding-left: 0 !important;
}
.markdown-body .pl-1 {
padding-left: 4px !important;
}
.markdown-body .pl-2 {
padding-left: 8px !important;
}
.markdown-body .pl-3 {
padding-left: 16px !important;
}
.markdown-body .pl-4 {
padding-left: 24px !important;
}
.markdown-body .pl-5 {
padding-left: 32px !important;
}
.markdown-body .pl-6 {
padding-left: 40px !important;
}
.markdown-body .pl-7 {
padding-left: 48px !important;
}
.markdown-body .pl-8 {
padding-left: 64px !important;
}
.markdown-body .pl-9 {
padding-left: 80px !important;
}
.markdown-body .pl-10 {
padding-left: 96px !important;
}
.markdown-body .pl-11 {
padding-left: 112px !important;
}
.markdown-body .pl-12 {
padding-left: 128px !important;
}

View file

@ -0,0 +1,39 @@
/* 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/>.
*/
.marker {
display: inline-block;
padding: 0.25em 0.4em;
font-size: 75%;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
}
.marker-first {
color: #fff;
background-color: #007bff;
}
.marker-info {
color: #fff;
background-color: #17a2b8;
}

View file

@ -0,0 +1,102 @@
.note-page {
// min-height: calc(100vh - 57px);
background: $lighter-gray;
flex-grow: 1;
flex-basis: 0;
// .inner {
// display: flex;
// justify-content: center;
// padding-top: rem(40px);
// padding-bottom: rem(40px);
//
// @include breakpoint(md) {
// padding-top: rem(52px);
// padding-bottom: rem(52px);
// }
// }
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: rem(12px) rem(16px);
border-bottom: 1px solid $border-color;
}
.header-left,
.header-right {
display: flex;
align-items: center;
}
.book-icon {
vertical-align: middle;
}
.content-wrapper {
padding: rem(12px) rem(16px);
}
.collapsed-content {
color: $light-gray;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
@include font-size('small');
padding: rem(12px) rem(16px);
}
.ts {
color: $light-gray;
}
.ts-lead {
display: none;
@include breakpoint(md) {
display: inline;
}
}
.match {
display: inline-block;
background: #f7f77d;
}
.book-label {
@include font-size('medium');
font-weight: 600;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $black;
a {
color: inherit;
&:hover {
color: inherit;
}
}
}
// header
.header {
.book-label {
max-width: rem(200px);
margin-left: rem(12px);
@include breakpoint(sm) {
max-width: rem(200px);
}
@include breakpoint(md) {
max-width: rem(420px);
}
@include breakpoint(lg) {
max-width: rem(600px);
}
}
}
}

View file

@ -0,0 +1,367 @@
/* 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/>.
*/
/*!
* Bootstrap Reboot v4.3.1 (https://getbootstrap.com/)
* Copyright 2011-2019 The Bootstrap Authors
* Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
article,
aside,
figcaption,
figure,
footer,
header,
hgroup,
main,
nav,
section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex='-1']:focus {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]) {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):hover,
a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg {
overflow: hidden;
vertical-align: middle;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
select {
word-wrap: normal;
}
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
button:not(:disabled),
[type='button']:not(:disabled),
[type='reset']:not(:disabled),
[type='submit']:not(:disabled) {
cursor: pointer;
}
button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type='radio'],
input[type='checkbox'] {
box-sizing: border-box;
padding: 0;
}
input[type='date'],
input[type='time'],
input[type='datetime-local'],
input[type='month'] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
height: auto;
}
[type='search'] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

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/>.
*/
/*
MIT License
Copyright (c) 2017 Pierre Burel
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@use "sass:math";
// assume 1 rem = 10 px
// achieved by body { font-size: 62.5%; )
$rem-baseline: 10px !default;
$rem-fallback: false !default;
$rem-px-only: false !default;
@function rem-separator($list, $separator: false) {
@if $separator == 'comma' or $separator == 'space' {
@return append($list, null, $separator);
}
@if function-exists('list-separator') == true {
@return list-separator($list);
}
// list-separator polyfill by Hugo Giraudel (https://sass-compatibility.github.io/#list_separator_function)
$test-list: ();
@each $item in $list {
$test-list: append($test-list, $item, space);
}
@return if($test-list == $list, space, comma);
}
@mixin rem-baseline($zoom: 100%) {
font-size: $zoom / 16px * $rem-baseline;
}
@function rem-convert($to, $values...) {
$result: ();
$separator: rem-separator($values);
@each $value in $values {
@if type-of($value) == 'number' and unit($value) == 'rem' and $to == 'px' {
$result: append($result, $value / 1rem * $rem-baseline, $separator);
} @else if
type-of($value) ==
'number' and
unit($value) ==
'px' and
$to ==
'rem'
{
$result: append(
$result,
math.div($value, $rem-baseline) * 1rem,
$separator
);
} @else if type-of($value) == 'list' {
$value-separator: rem-separator($value);
$value: rem-convert($to, $value...);
$value: rem-separator($value, $value-separator);
$result: append($result, $value, $separator);
} @else {
$result: append($result, $value, $separator);
}
}
@return if(length($result) == 1, nth($result, 1), $result);
}
@function rem($values...) {
@if $rem-px-only {
@return rem-convert(px, $values...);
} @else {
@return rem-convert(rem, $values...);
}
}
@mixin rem($properties, $values...) {
@if type-of($properties) == 'map' {
@each $property in map-keys($properties) {
@include rem($property, map-get($properties, $property));
}
} @else {
@each $property in $properties {
@if $rem-fallback or $rem-px-only {
#{$property}: rem-convert(px, $values...);
}
@if not $rem-px-only {
#{$property}: rem-convert(rem, $values...);
}
}
}
}

View file

@ -0,0 +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/>.
*/
@import './variables';
@mixin breakpoint($point) {
@if $point == xl {
@media (min-width: $xl-breakpoint) {
@content;
}
} @else if $point == lg {
@media (min-width: $lg-breakpoint) {
@content;
}
} @else if $point == md {
@media (min-width: $md-breakpoint) {
@content;
}
} @else if $point == sm {
@media (min-width: $sm-breakpoint) {
@content;
}
} @else if $point == smonly {
@media (min-width: $sm-breakpoint) and (max-width: $md-breakpoint - 1px) {
@content;
}
} @else if $point == smdown {
@media (max-width: $md-breakpoint - 1px) {
@content;
}
} @else if $point == mdonly {
@media (min-width: $md-breakpoint) and (max-width: $lg-breakpoint - 1px) {
@content;
}
} @else if $point == mddown {
@media (max-width: $lg-breakpoint - 1px) {
@content;
}
}
}
// landscape is the mobile landscape mode
@mixin landscape() {
@media (max-height: 400px) {
@content;
}
}

View file

@ -0,0 +1,463 @@
/* 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/>.
*/
/**
* React Select
* ============
* Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/
* https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs
* MIT License: https://github.com/JedWatson/react-select
*/
.Select {
position: relative;
}
.Select input::-webkit-contacts-auto-fill-button,
.Select input::-webkit-credentials-auto-fill-button {
display: none !important;
}
.Select input::-ms-clear {
display: none !important;
}
.Select input::-ms-reveal {
display: none !important;
}
.Select,
.Select div,
.Select input,
.Select span {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.Select.is-disabled .Select-arrow-zone {
cursor: default;
pointer-events: none;
opacity: 0.35;
}
.Select.is-disabled > .Select-control {
background-color: #f9f9f9;
}
.Select.is-disabled > .Select-control:hover {
box-shadow: none;
}
.Select.is-open > .Select-control {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
background: #fff;
}
.Select.is-open > .Select-control .Select-arrow {
top: -2px;
border-color: transparent transparent #999;
border-width: 0 5px 5px;
}
.Select.is-searchable.is-open > .Select-control {
cursor: text;
}
.Select.is-searchable.is-focused:not(.is-open) > .Select-control {
cursor: text;
}
.Select.is-focused > .Select-control {
background: #fff;
}
.Select.is-focused > .Select-control {
border-top-color: #b6c9e9;
border-bottom-color: #b6c9e9;
}
.Select.is-focused:not(.is-open) > .Select-control {
background: #fff;
}
.Select.has-value.is-clearable.Select--single > .Select-control .Select-value {
padding-right: 42px;
}
.Select.has-value.Select--single > .Select-control .Select-value .Select-value-label,
.Select.has-value.is-pseudo-focused.Select--single
> .Select-control
.Select-value
.Select-value-label {
color: #333;
}
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label,
.Select.has-value.is-pseudo-focused.Select--single
> .Select-control
.Select-value
a.Select-value-label {
cursor: pointer;
text-decoration: none;
}
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:hover,
.Select.has-value.is-pseudo-focused.Select--single
> .Select-control
.Select-value
a.Select-value-label:hover,
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:focus,
.Select.has-value.is-pseudo-focused.Select--single
> .Select-control
.Select-value
a.Select-value-label:focus {
color: #007eff;
outline: none;
text-decoration: underline;
}
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:focus,
.Select.has-value.is-pseudo-focused.Select--single
> .Select-control
.Select-value
a.Select-value-label:focus {
background: #fff;
}
.Select.has-value.is-pseudo-focused .Select-input {
opacity: 0;
}
.Select.is-open .Select-arrow,
.Select .Select-arrow-zone:hover > .Select-arrow {
border-top-color: #666;
}
.Select.Select--rtl {
direction: rtl;
text-align: right;
}
.Select-control {
background-color: #fff;
color: #333;
cursor: default;
display: table;
border-top: 1px solid #e2e2e2;
border-bottom: 1px solid #e2e2e2;
height: 36px;
outline: none;
overflow: hidden;
position: relative;
width: 100%;
}
.Select-control:hover {
// box-shadow: inset 0px 0px 3px 2px rgba(0, 0, 0, 0.03);
}
.Select-control .Select-input:focus {
outline: none;
background: #fff;
}
.Select-placeholder,
.Select--single > .Select-control .Select-value {
bottom: 0;
font-weight: 300;
color: #c3c3c3;
left: 0;
line-height: 34px;
// padding-left: 10px;
padding-left: 16px;
padding-right: 10px;
position: absolute;
right: 0;
top: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Select-input {
height: 34px;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
}
.Select-input > input {
width: 100%;
background: none transparent;
border: 0 none;
box-shadow: none;
cursor: default;
display: inline-block;
font-family: inherit;
font-size: inherit;
margin: 0;
outline: none;
line-height: 17px;
/* For IE 8 compatibility */
padding: 8px 0 12px;
/* For IE 8 compatibility */
-webkit-appearance: none;
}
.is-focused .Select-input > input {
cursor: text;
}
.has-value.is-pseudo-focused .Select-input {
opacity: 0;
}
.Select-control:not(.is-searchable) > .Select-input {
outline: none;
}
.Select-loading-zone {
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 16px;
}
.Select-loading {
-webkit-animation: Select-animation-spin 400ms infinite linear;
-o-animation: Select-animation-spin 400ms infinite linear;
animation: Select-animation-spin 400ms infinite linear;
width: 16px;
height: 16px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid #ccc;
border-right-color: #333;
display: inline-block;
position: relative;
vertical-align: middle;
}
.Select-clear-zone {
-webkit-animation: Select-animation-fadeIn 200ms;
-o-animation: Select-animation-fadeIn 200ms;
animation: Select-animation-fadeIn 200ms;
color: #999;
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 17px;
}
.Select-clear-zone:hover {
color: #d0021b;
}
.Select-clear {
display: inline-block;
font-size: 18px;
line-height: 1;
}
.Select--multi .Select-clear-zone {
width: 17px;
}
.Select-arrow-zone {
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 25px;
padding-right: 5px;
}
.Select--rtl .Select-arrow-zone {
padding-right: 0;
padding-left: 5px;
}
.Select-arrow {
border-color: #999 transparent transparent;
border-style: solid;
border-width: 5px 5px 2.5px;
display: inline-block;
height: 0;
width: 0;
position: relative;
}
.Select-control > *:last-child {
padding-right: 5px;
}
.Select--multi .Select-multi-value-wrapper {
display: inline-block;
}
.Select .Select-aria-only {
position: absolute;
display: inline-block;
height: 1px;
width: 1px;
margin: -1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
float: left;
}
@-webkit-keyframes Select-animation-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes Select-animation-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.Select-menu-outer {
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
border: 1px solid #ccc;
border-top-color: #e6e6e6;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
box-sizing: border-box;
margin-top: -1px;
max-height: 200px;
position: absolute;
left: 0;
top: 100%;
width: 100%;
z-index: 1;
-webkit-overflow-scrolling: touch;
}
.Select.is-open > .Select-menu-outer {
border-top-color: #b6c9e9;
}
.Select-menu {
max-height: 150px;
overflow-y: auto;
}
.Select-option {
box-sizing: border-box;
background-color: #fff;
color: #666666;
cursor: pointer;
display: block;
padding: 8px 10px;
}
.Select-option:last-child {
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
}
.Select-option.is-selected {
background-color: #f5faff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.04);
color: #333;
}
.Select-option.is-focused {
background-color: #ebf5ff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.08);
color: #333;
}
.Select-option.is-disabled {
color: #cccccc;
cursor: default;
}
.Select-noresults {
box-sizing: border-box;
color: #999999;
cursor: default;
display: block;
padding: 8px 10px;
}
.Select--multi .Select-input {
vertical-align: middle;
margin-left: 10px;
padding: 0;
}
.Select--multi.Select--rtl .Select-input {
margin-left: 0;
margin-right: 10px;
}
.Select--multi.has-value .Select-input {
margin-left: 5px;
}
.Select--multi .Select-value {
background-color: #ebf5ff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.08);
border-radius: 2px;
border: 1px solid #c2e0ff;
/* Fallback color for IE 8 */
border: 1px solid rgba(0, 126, 255, 0.24);
color: #007eff;
display: inline-block;
font-size: 0.9em;
line-height: 1.4;
margin-left: 5px;
margin-top: 5px;
vertical-align: top;
}
.Select--multi .Select-value-icon,
.Select--multi .Select-value-label {
display: inline-block;
vertical-align: middle;
}
.Select--multi .Select-value-label {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
cursor: default;
padding: 2px 5px;
}
.Select--multi a.Select-value-label {
color: #007eff;
cursor: pointer;
text-decoration: none;
}
.Select--multi a.Select-value-label:hover {
text-decoration: underline;
}
.Select--multi .Select-value-icon {
cursor: pointer;
border-bottom-left-radius: 2px;
border-top-left-radius: 2px;
border-right: 1px solid #c2e0ff;
/* Fallback color for IE 8 */
border-right: 1px solid rgba(0, 126, 255, 0.24);
padding: 1px 5px 3px;
}
.Select--multi .Select-value-icon:hover,
.Select--multi .Select-value-icon:focus {
background-color: #d8eafd;
/* Fallback color for IE 8 */
background-color: rgba(0, 113, 230, 0.08);
color: #0071e6;
}
.Select--multi .Select-value-icon:active {
background-color: #c2e0ff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.24);
}
.Select--multi.Select--rtl .Select-value {
margin-left: 0;
margin-right: 5px;
}
.Select--multi.Select--rtl .Select-value-icon {
border-right: none;
border-left: 1px solid #c2e0ff;
/* Fallback color for IE 8 */
border-left: 1px solid rgba(0, 126, 255, 0.24);
}
.Select--multi.is-disabled .Select-value {
background-color: #fcfcfc;
border: 1px solid #e3e3e3;
color: #333;
}
.Select--multi.is-disabled .Select-value-icon {
cursor: not-allowed;
border-right: 1px solid #e3e3e3;
}
.Select--multi.is-disabled .Select-value-icon:hover,
.Select--multi.is-disabled .Select-value-icon:focus,
.Select--multi.is-disabled .Select-value-icon:active {
background-color: #fcfcfc;
}
@keyframes Select-animation-spin {
to {
transform: rotate(1turn);
}
}
@-webkit-keyframes Select-animation-spin {
to {
-webkit-transform: rotate(1turn);
}
}

View file

@ -0,0 +1,147 @@
@import './theme';
@import './font';
.settings-page {
.sidebar {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
background: white;
margin-bottom: rem(20px);
margin-top: rem(20px);
@include breakpoint(lg) {
margin-bottom: 0;
margin-top: 0;
}
}
.sidebar-item {
display: block;
padding: rem(12px) rem(16px);
border-left: 4px solid transparent;
@include font-size('regular');
&:hover {
text-decoration: none;
background: $light;
}
&.active {
font-weight: 600;
border-left-color: $first;
}
}
.setting-section-wrapper {
.header {
@include breakpoint(lg) {
display: none;
}
}
.setting-section {
margin-top: rem(24px);
background: white;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
&:first-child {
margin-top: 0;
}
}
.section-heading {
@include font-size('regular');
font-weight: 600;
padding-bottom: rem(4px);
background: $light;
padding: rem(16px) rem(20px);
}
.section-content {
margin-top: rem(20px);
}
.actions {
margin-top: rem(18px);
text-align: right;
}
}
.setting-row {
padding: rem(16px) rem(20px);
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
.setting-row-summary {
display: flex;
flex-direction: column;
// align-items: flex-start;
@include breakpoint(md) {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
.setting-row-main {
padding-top: rem(24px);
}
.setting-name {
font-weight: 400;
@include font-size('regular');
margin-bottom: 0;
}
.setting-desc {
margin-bottom: 0;
@include font-size('small');
color: $gray;
}
.setting-action {
display: flex;
flex-direction: column;
@include breakpoint(md) {
flex-direction: row;
}
}
.setting-right {
display: flex;
word-break: break-all;
justify-content: space-between;
align-items: center;
margin-top: rem(4px);
@include breakpoint(md) {
flex-direction: row;
align-items: center;
margin-top: 0;
}
}
.setting-edit {
color: $link;
padding: 0;
&:hover {
color: $link-hover;
}
@include breakpoint(md) {
margin-left: rem(16px);
}
}
.input-row {
& ~ .input-row,
.input-row {
margin-top: rem(12px);
}
}
}
.email-verification-form {
margin-left: rem(12px);
}
}

View file

@ -0,0 +1,241 @@
/* 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 './font';
@import './responsive';
@keyframes holderPulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
// placeholder frames
.holder {
animation: holderPulse 800ms infinite;
background: #f4f4f4;
&.holder-dark {
background: #e6e6e6;
}
}
input[type='text']:disabled,
input[type='email']:disabled,
input[type='number']:disabled,
input[type='password']:disabled,
textarea:disabled {
background-color: $lighter-gray;
cursor: not-allowed;
}
.list-unstyled {
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
.sr-only {
display: none;
}
.scrollbar-measure {
position: absolute;
top: -9999px;
width: 50px;
height: 50px;
overflow: scroll;
}
button {
img,
svg {
display: block;
}
}
.text-input {
border: 1px solid $border-color;
padding: rem(8px) rem(12px);
position: relative;
border-radius: rem(4px);
display: block;
&::placeholder {
color: $gray;
}
&:focus {
border-color: $light-blue;
box-shadow: inset 0 1px 2px rgba(24, 31, 35, 0.075),
0 0 0 0.2em rgba(4, 100, 210, 0.3);
outline: none;
}
}
.text-input-small {
padding: rem(4px) rem(12px);
}
.text-input-medium {
padding: rem(8px) rem(12px);
}
.text-input-stretch {
width: 100%;
}
.label-full {
width: 100%;
}
a {
color: $link;
&:hover {
color: $link-hover;
}
}
// normalize
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0;
}
// grid
.container.mobile-fw {
@include breakpoint(mddown) {
max-width: 100%;
}
}
.container.mobile-nopadding {
@include breakpoint(mddown) {
padding-left: 0;
padding-right: 0;
.row {
margin-left: 0;
margin-right: 0;
}
[class*='col-'] {
// Apply to all column(s) inside the row
padding-left: 0;
padding-right: 0;
}
}
}
html body {
overflow-y: scroll;
}
.page {
padding-top: rem(20px);
padding-bottom: rem(20px);
&.page-mobile-full {
padding-top: 0;
padding-bottom: 0;
@include breakpoint(lg) {
padding-top: rem(32px);
padding-bottom: rem(32px);
}
}
}
.page-header {
margin-top: rem(20px);
&.page-header-full {
margin-bottom: rem(20px);
}
@include breakpoint(lg) {
// padding: 0;
margin-bottom: rem(20px);
margin-top: 0;
}
}
.form-select {
appearance: none;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACeSURBVHgBzZPBCYQwFERn2Qa2BEuwhJSyHawdrB1oB1qCV6uwhHj0qBXoBPwQRONXEXzwLoEXcCTAzfxogpPEdJyNcZCIWu8CO5+p8WOxoR9NnK3EYrEX/wOxuDmqUcSikejlXfCFfqie5ngE/ie4cVS/ibS0XB4anBhxSaKIU+xQBmLV8m6HZiW2OECEi4/JoXrO78AFHR1oTSvcxQTq7lVcue6CCAAAAABJRU5ErkJggg==');
background-color: #fff;
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 8px 10px;
border: 1px solid $border-color;
min-height: 34px;
padding: 6px 8px;
padding-right: 24px;
outline: none;
vertical-align: middle;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08);
&:focus {
border-color: #2188ff;
outline: none;
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08),
0 0 0 2px rgba(3, 102, 214, 0.3);
}
&:disabled,
&.form-select-disabled {
background-image: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEKSURBVHgBzVTNDYIwFC4NB46OwAi4gY7gETgoE6gTGCcwTgAJ4efGCLCBjMAIXrmA3yOhQazQhJj4JQ0v7fte3/e1hbFfIk3TYxzHp6kc7dtCFEUW5/xBcdM0a9d1S1kel00mSWKCnIkkxDSnXADIMYYEU9O0zPf91WwB6L6NyB3atrUMw7hNFkCbFyROmXYYmypMDMNwo+t6ztSwtW27oEAXrXBuwu2rCht+WPgU7C8gPCBzYOBKhQS5FTwIKBYeQFeJoWyiKNYH5Co6OCuQr/0JdBuPVyElQCd7GRMb3B3HebsHHzexrmvyQvZwqjFZWsDzvCc62BFhSGYD3UMsfs6ToKOd+6EsxgtrtWLW4gUN3AAAAABJRU5ErkJggg==');
background-color: $lighter-gray;
}
}
.input-label {
// width: 100%;
width: auto;
font-weight: 600;
margin-bottom: rem(4px);
@include font-size('small');
}
.page-heading {
@include font-size('x-large');
}
.dropdown-caret {
display: inline-block;
vertical-align: middle;
border-top-width: 4px;
border-top-style: solid;
border-right: 4px solid transparent;
border-bottom: 0 solid transparent;
border-left: 4px solid transparent;
margin-left: rem(8px);
}
.divider {
height: 0;
overflow: hidden;
border-top: 1px solid #e9ecef;
}

View file

@ -0,0 +1,47 @@
/* 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/>.
*/
// basic colors
$black: #2a2a2a;
$white: #ffffff;
$light: #f7f9fa;
$gray: #686868;
$light-gray: #8c8c8c;
$lighter-gray: #f3f3f3;
$dark-gray: #637283;
// primary colors
$first: #072a40;
$second: #e7e7e7;
$third: #0a4b73;
// functional colors
$border-color: #d8d8d8;
$border-color-light: $lighter-gray;
$link: #6f53c0;
$link-hover: darken($link, 5%);
$danger-text: #cb2431;
$danger-background: #f8d7da;
$blue: #0668d7;
$light-blue: #ecf4ff;
$green: #28a755;
$active: #49abfd;

View file

@ -16,13 +16,16 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
$header-height: 60px;
$footer-height: 56px;
import (
"net/http"
)
// breakpoints
$xl-breakpoint: 1441px;
$lg-breakpoint: 992px;
$md-breakpoint: 576px;
$sm-breakpoint: 321px;
func (a *API) checkHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
:export {
mdBreakpoint: $md-breakpoint;
smBreakpoint: $sm-breakpoint;
}

View file

@ -0,0 +1,144 @@
/* 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 './reboot';
@import './grid';
@import './bootstrap';
@import './buttons';
@import './responsive';
@import './select';
@import './shared';
@import './marker';
@import './rem';
@import './markdown';
@import './hljs';
@import './login';
@import './home';
@import './note';
@import './books';
@import './settings';
@import './header';
@import './global';
html {
font-size: 62.5%; /* 1.0 rem = 10px */
}
html body {
margin: 0;
font-size: 1.6rem;
}
img {
max-width: 100%;
}
// input[type='email'],
// input[type='password'],
// input[type='text'] {
// &::placeholder {
// color: #aaa;
// }
// }
.main-content {
padding-top: 24px;
}
.no-scroll {
overflow: hidden;
// prevent ios safari from scrolling
// but it causes page to scroll to top on modal open
// position: fixed;
// left: 0;
// right: 0;
// top: 0;
// bottom: 0;
}
.container.mobile-nopadding {
@include breakpoint(mdonly) {
max-width: 100%;
}
@include breakpoint(mddown) {
padding-left: 0;
padding-right: 0;
.row {
margin-left: 0;
margin-right: 0;
}
[class*='col-'] {
// Apply to all column(s) inside the row
padding-left: 0;
padding-right: 0;
}
}
}
// START: override bootstrap
.form-control {
font-size: 1.6rem;
}
.dropdown {
position: inherit;
}
// END: override bootstrap
// START: bootstrap related
.input-group {
input ~ button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
// END: bootstrap related
// .twitter-follow-btn {
// position: fixed;
// bottom: 0px;
// right: 10px;
// }
.page {
//padding: 35px 0;
//min-height: calc(100vh - 57px);
//min-height: 100vh;
// padding-top: rem(48px);
}
.page-bgdark {
background: #ececec;
}
// Measure scrollbar width for padding body during modal show/hide
.modal-scrollbar-measure {
position: absolute;
top: -9999px;
width: 50px;
height: 50px;
overflow: scroll;
}
.input {
border-radius: rem(4px);
background-clip: padding-box;
border: 1px solid #ced4da;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}

View file

@ -0,0 +1,15 @@
package buildinfo
var (
// Version is the server version
Version = "master"
// CSSFiles is the css files
CSSFiles = ""
// JSFiles is the js files
JSFiles = ""
// RootURL is the root url
RootURL = "/"
// Standalone reprsents whether the build is for on-premises. It is a string
// rather than a boolean, so that it can be overridden during compile time.
Standalone = "false"
)

View file

@ -25,6 +25,11 @@ import (
"os"
)
const (
// AppEnvProduction represents an app environment for production.
AppEnvProduction string = "PRODUCTION"
)
var (
// ErrDBMissingHost is an error for an incomplete configuration missing the host
ErrDBMissingHost = errors.New("DB Host is empty")
@ -92,11 +97,25 @@ func loadDBConfig() PostgresConfig {
// Config is an application configuration
type Config struct {
AppEnv string
WebURL string
OnPremise bool
DisableRegistration bool
Port string
DB PostgresConfig
PageTemplateDir string
StaticDir string
AssetBaseURL string
}
func getAppEnv() string {
// DEPRECATED
goEnv := os.Getenv("GO_ENV")
if goEnv != "" {
return goEnv
}
return os.Getenv("APP_ENV")
}
// Load constructs and returns a new config based on the environment variables.
@ -107,11 +126,13 @@ func Load() Config {
}
c := Config{
AppEnv: getAppEnv(),
WebURL: os.Getenv("WebURL"),
Port: port,
OnPremise: readBoolEnv("OnPremise"),
DisableRegistration: readBoolEnv("DisableRegistration"),
DB: loadDBConfig(),
AssetBaseURL: "",
}
if err := validate(c); err != nil {
@ -126,6 +147,26 @@ func (c *Config) SetOnPremise(val bool) {
c.OnPremise = val
}
// SetPageTemplateDir sets page template dir for the config
func (c *Config) SetPageTemplateDir(d string) {
c.PageTemplateDir = d
}
// SetStaticDir sets static dir for the confi
func (c *Config) SetStaticDir(d string) {
c.StaticDir = d
}
// SetAssetBaseURL sets static dir for the confi
func (c *Config) SetAssetBaseURL(d string) {
c.AssetBaseURL = d
}
// IsProd checks if the app environment is configured to be production.
func (c Config) IsProd() bool {
return c.AppEnv == AppEnvProduction
}
func validate(c Config) error {
if _, err := url.ParseRequestURI(c.WebURL); err != nil {
return errors.Wrapf(ErrWebURLInvalid, "provided: '%s'", c.WebURL)

View file

@ -0,0 +1,10 @@
package consts
const (
// ContentTypeForm is the content type header for form encoded data
ContentTypeForm = "application/x-www-form-urlencoded"
// ContentTypeForm is the content type header for JSON encoded data
ContentTypeJSON = "application/json"
// ContentTypeHTML is the content type header for HTML
ContentTypeHTML = "text/html"
)

View file

@ -0,0 +1,64 @@
package context
import (
"context"
"github.com/dnote/dnote/pkg/server/database"
)
const (
userKey privateKey = "user"
accountKey privateKey = "account"
tokenKey privateKey = "token"
)
type privateKey string
// WithUser creates a new context with the given user
func WithUser(ctx context.Context, user *database.User) context.Context {
return context.WithValue(ctx, userKey, user)
}
// WithAccount creates a new context with the given account
func WithAccount(ctx context.Context, account *database.Account) context.Context {
return context.WithValue(ctx, accountKey, account)
}
// WithToken creates a new context with the given user
func WithToken(ctx context.Context, tok *database.Token) context.Context {
return context.WithValue(ctx, tokenKey, tok)
}
// User retrieves a user from the given context. It returns a pointer to
// a user. If the context does not contain a user, it returns nil.
func User(ctx context.Context) *database.User {
if temp := ctx.Value(userKey); temp != nil {
if user, ok := temp.(*database.User); ok {
return user
}
}
return nil
}
// Account retrieves an account from the given context.
func Account(ctx context.Context) *database.Account {
if temp := ctx.Value(accountKey); temp != nil {
if account, ok := temp.(*database.Account); ok {
return account
}
}
return nil
}
// Token retrieves a token from the given context.
func Token(ctx context.Context) *database.Token {
if temp := ctx.Value(tokenKey); temp != nil {
if tok, ok := temp.(*database.Token); ok {
return tok
}
}
return nil
}

View file

@ -0,0 +1,297 @@
package controllers
import (
"fmt"
"net/http"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/context"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
// NewBooks creates a new Books controller.
// It panics if the necessary templates are not parsed.
func NewBooks(app *app.App) *Books {
return &Books{
app: app,
}
}
// Books is a user controller.
type Books struct {
app *app.App
}
func (b *Books) getBooks(r *http.Request) ([]database.Book, error) {
user := context.User(r.Context())
if user == nil {
return []database.Book{}, app.ErrLoginRequired
}
conn := b.app.DB.Where("user_id = ? AND NOT deleted", user.ID).Order("label ASC")
query := r.URL.Query()
name := query.Get("name")
encryptedStr := query.Get("encrypted")
if name != "" {
part := fmt.Sprintf("%%%s%%", name)
conn = conn.Where("LOWER(label) LIKE ?", part)
}
if encryptedStr != "" {
var encrypted bool
if encryptedStr == "true" {
encrypted = true
} else {
encrypted = false
}
conn = conn.Where("encrypted = ?", encrypted)
}
var books []database.Book
if err := conn.Find(&books).Error; err != nil {
return []database.Book{}, nil
}
return books, nil
}
// V3Index gets books
func (b *Books) V3Index(w http.ResponseWriter, r *http.Request) {
result, err := b.getBooks(r)
if err != nil {
handleJSONError(w, err, "getting books")
return
}
respondJSON(w, http.StatusOK, presenters.PresentBooks(result))
}
// V3Show gets a book
func (b *Books) V3Show(w http.ResponseWriter, r *http.Request) {
user := context.User(r.Context())
if user == nil {
handleJSONError(w, app.ErrLoginRequired, "login required")
return
}
vars := mux.Vars(r)
bookUUID := vars["bookUUID"]
if !helpers.ValidateUUID(bookUUID) {
handleJSONError(w, app.ErrInvalidUUID, "login required")
return
}
var book database.Book
conn := b.app.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book)
if conn.RecordNotFound() {
w.WriteHeader(http.StatusNotFound)
return
}
if err := conn.Error; err != nil {
handleJSONError(w, err, "finding the book")
return
}
respondJSON(w, http.StatusOK, presenters.PresentBook(book))
}
type createBookPayload struct {
Name string `schema:"name" json:"name"`
}
func validateCreateBookPayload(p createBookPayload) error {
if p.Name == "" {
return app.ErrBookNameRequired
}
return nil
}
func (b *Books) create(r *http.Request) (database.Book, error) {
user := context.User(r.Context())
if user == nil {
return database.Book{}, app.ErrLoginRequired
}
var params createBookPayload
if err := parseRequestData(r, &params); err != nil {
return database.Book{}, errors.Wrap(err, "parsing request payload")
}
if err := validateCreateBookPayload(params); err != nil {
return database.Book{}, errors.Wrap(err, "validating payload")
}
var bookCount int
err := b.app.DB.Model(database.Book{}).
Where("user_id = ? AND label = ?", user.ID, params.Name).
Count(&bookCount).Error
if err != nil {
return database.Book{}, errors.Wrap(err, "checking duplicate")
}
if bookCount > 0 {
return database.Book{}, app.ErrDuplicateBook
}
book, err := b.app.CreateBook(*user, params.Name)
if err != nil {
return database.Book{}, errors.Wrap(err, "inserting a book")
}
return book, nil
}
// CreateBookResp is the response from create book api
type CreateBookResp struct {
Book presenters.Book `json:"book"`
}
// V3Create creates a book
func (b *Books) V3Create(w http.ResponseWriter, r *http.Request) {
result, err := b.create(r)
if err != nil {
handleJSONError(w, err, "creating a book")
return
}
resp := CreateBookResp{
Book: presenters.PresentBook(result),
}
respondJSON(w, http.StatusCreated, resp)
}
type updateBookPayload struct {
Name *string `schema:"name" json:"name"`
}
// UpdateBookResp is the response from create book api
type UpdateBookResp struct {
Book presenters.Book `json:"book"`
}
func (b *Books) update(r *http.Request) (database.Book, error) {
user := context.User(r.Context())
if user == nil {
return database.Book{}, app.ErrLoginRequired
}
vars := mux.Vars(r)
uuid := vars["bookUUID"]
if !helpers.ValidateUUID(uuid) {
return database.Book{}, app.ErrInvalidUUID
}
tx := b.app.DB.Begin()
var book database.Book
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
return database.Book{}, errors.Wrap(err, "finding book")
}
var params updateBookPayload
if err := parseRequestData(r, &params); err != nil {
return database.Book{}, errors.Wrap(err, "decoding payload")
}
book, err := b.app.UpdateBook(tx, *user, book, params.Name)
if err != nil {
tx.Rollback()
return database.Book{}, errors.Wrap(err, "updating a book")
}
tx.Commit()
return book, nil
}
// V3Update updates a book
func (b *Books) V3Update(w http.ResponseWriter, r *http.Request) {
book, err := b.update(r)
if err != nil {
handleJSONError(w, err, "updating a book")
return
}
resp := UpdateBookResp{
Book: presenters.PresentBook(book),
}
respondJSON(w, http.StatusOK, resp)
}
func (b *Books) del(r *http.Request) (database.Book, error) {
user := context.User(r.Context())
if user == nil {
return database.Book{}, app.ErrLoginRequired
}
vars := mux.Vars(r)
uuid := vars["bookUUID"]
if !helpers.ValidateUUID(uuid) {
return database.Book{}, app.ErrInvalidUUID
}
tx := b.app.DB.Begin()
var book database.Book
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
return database.Book{}, errors.Wrap(err, "finding a book")
}
var notes []database.Note
if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(&notes).Error; err != nil {
return database.Book{}, errors.Wrap(err, "finding notes for the book")
}
for _, note := range notes {
if _, err := b.app.DeleteNote(tx, *user, note); err != nil {
tx.Rollback()
return database.Book{}, errors.Wrap(err, "deleting a note in the book")
}
}
book, err := b.app.DeleteBook(tx, *user, book)
if err != nil {
return database.Book{}, errors.Wrap(err, "deleting the book")
}
tx.Commit()
return book, nil
}
// deleteBookResp is the response from create book api
type deleteBookResp struct {
Status int `json:"status"`
Book presenters.Book `json:"book"`
}
// Delete updates a book
func (b *Books) V3Delete(w http.ResponseWriter, r *http.Request) {
book, err := b.del(r)
if err != nil {
handleJSONError(w, err, "creating a books")
return
}
resp := deleteBookResp{
Status: http.StatusOK,
Book: presenters.PresentBook(book),
}
respondJSON(w, http.StatusOK, resp)
}
// IndexOptions is a handler for OPTIONS endpoint for notes
func (b *Books) IndexOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}

View file

@ -16,17 +16,19 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
package controllers
import (
"encoding/json"
"fmt"
"io/ioutil"
"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/database"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
@ -34,18 +36,21 @@ import (
)
func TestGetBooks(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
anotherUser := testutils.SetupUserData()
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
b1 := database.Book{
UserID: user.ID,
@ -77,7 +82,9 @@ func TestGetBooks(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&b4), "preparing b4")
// Execute
req := testutils.MakeReq(server.URL, "GET", "/v3/books", "")
endpoint := "/api/v3/books"
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
@ -114,19 +121,21 @@ func TestGetBooks(t *testing.T) {
}
func TestGetBooksByName(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
anotherUser := testutils.SetupUserData()
req := testutils.MakeReq(server.URL, "GET", "/v3/books?name=js", "")
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
b1 := database.Book{
UserID: user.ID,
@ -145,6 +154,9 @@ func TestGetBooksByName(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
// Execute
endpoint := "/api/v3/books?name=js"
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
@ -171,6 +183,315 @@ func TestGetBooksByName(t *testing.T) {
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetBook(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
anotherUser := testutils.SetupUserData()
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
// Execute
endpoint := fmt.Sprintf("/api/v3/books/%s", b1.UUID)
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Book
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var b1Record database.Book
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1")
expected := presenters.Book{
UUID: b1Record.UUID,
CreatedAt: b1Record.CreatedAt,
UpdatedAt: b1Record.UpdatedAt,
Label: b1Record.Label,
USN: b1Record.USN,
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetBookNonOwner(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
nonOwner := testutils.SetupUserData()
testutils.SetupAccountData(nonOwner, "bob@test.com", "pass1234")
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
// Execute
endpoint := fmt.Sprintf("/api/v3/books/%s", b1.UUID)
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
res := testutils.HTTPAuthDo(t, req, nonOwner)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "", "payload mismatch")
}
func TestCreateBook(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
req := testutils.MakeReq(server.URL, "POST", "/api/v3/books", `{"name": "js"}`)
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var bookRecord database.Book
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
maxUSN := 102
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.NotEqual(t, bookRecord.UUID, "", "book uuid should have been generated")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, maxUSN, "book user_id mismatch")
assert.Equal(t, userRecord.MaxUSN, maxUSN, "user max_usn mismatch")
var got CreateBookResp
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(errors.Wrap(err, "decoding"))
}
expected := CreateBookResp{
Book: presenters.Book{
UUID: bookRecord.UUID,
USN: bookRecord.USN,
CreatedAt: bookRecord.CreatedAt,
UpdatedAt: bookRecord.UpdatedAt,
Label: "js",
},
}
assert.DeepEqual(t, got, expected, "payload mismatch")
})
t.Run("duplicate", func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 58,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book data")
// Execute
req := testutils.MakeReq(server.URL, "POST", "/api/v3/books", `{"name": "js"}`)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusConflict, "")
var bookRecord database.Book
var bookCount, noteCount int
var userRecord database.User
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, b1.USN, "book usn mismatch")
assert.Equal(t, userRecord.MaxUSN, 101, "user max_usn mismatch")
})
}
func TestUpdateBook(t *testing.T) {
updatedLabel := "updated-label"
b1UUID := "ead8790f-aff9-4bdf-8eec-f734ccd29202"
b2UUID := "0ecaac96-8d72-4e04-8925-5a21b79a16da"
type payloadData struct {
Name *string `schema:"name" json:"name,omitempty"`
}
testCases := []struct {
payload testutils.PayloadWrapper
bookUUID string
bookDeleted bool
bookLabel string
expectedBookLabel string
}{
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Name: &updatedLabel,
},
},
bookUUID: b1UUID,
bookDeleted: false,
bookLabel: "original-label",
expectedBookLabel: updatedLabel,
},
// if a deleted book is updated, it should be un-deleted
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Name: &updatedLabel,
},
},
bookUUID: b1UUID,
bookDeleted: true,
bookLabel: "",
expectedBookLabel: updatedLabel,
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UUID: tc.bookUUID,
UserID: user.ID,
Label: tc.bookLabel,
Deleted: tc.bookDeleted,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UUID: b2UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
// Execute
endpoint := fmt.Sprintf("/api/v3/books/%s", tc.bookUUID)
req := testutils.MakeReq(server.URL, "PATCH", endpoint, tc.payload.ToJSON(t))
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, fmt.Sprintf("status code mismatch for test case %d", idx))
var bookRecord database.Book
var userRecord database.User
var noteCount, bookCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 2, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equalf(t, bookRecord.UUID, tc.bookUUID, "book uuid mismatch")
assert.Equalf(t, bookRecord.Label, tc.expectedBookLabel, "book label mismatch")
assert.Equalf(t, bookRecord.USN, 102, "book usn mismatch")
assert.Equalf(t, bookRecord.Deleted, false, "book Deleted mismatch")
assert.Equal(t, userRecord.MaxUSN, 102, fmt.Sprintf("user max_usn mismatch for test case %d", idx))
})
}
}
func TestDeleteBook(t *testing.T) {
testCases := []struct {
label string
@ -200,19 +521,22 @@ 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(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 58), "preparing user max_usn")
anotherUser := testutils.SetupUserData()
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&anotherUser).Update("max_usn", 109), "preparing another user max_usn")
b1 := database.Book{
@ -283,12 +607,10 @@ func TestDeleteBook(t *testing.T) {
}
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing a note data")
endpoint := fmt.Sprintf("/v3/books/%s", b2.UUID)
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
req.Header.Set("Version", "0.1.1")
req.Header.Set("Origin", "chrome-extension://iaolnfnipkoinabdbbakcmkkdignedce")
// Execute
endpoint := fmt.Sprintf("/api/v3/books/%s", b2.UUID)
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
@ -349,200 +671,3 @@ func TestDeleteBook(t *testing.T) {
})
}
}
func TestCreateBook(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
req := testutils.MakeReq(server.URL, "POST", "/v3/books", `{"name": "js"}`)
req.Header.Set("Version", "0.1.1")
req.Header.Set("Origin", "chrome-extension://iaolnfnipkoinabdbbakcmkkdignedce")
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var bookRecord database.Book
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
maxUSN := 102
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.NotEqual(t, bookRecord.UUID, "", "book uuid should have been generated")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, maxUSN, "book user_id mismatch")
assert.Equal(t, userRecord.MaxUSN, maxUSN, "user max_usn mismatch")
var got CreateBookResp
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(errors.Wrap(err, "decoding got"))
}
expected := CreateBookResp{
Book: presenters.Book{
UUID: bookRecord.UUID,
USN: bookRecord.USN,
CreatedAt: bookRecord.CreatedAt,
UpdatedAt: bookRecord.UpdatedAt,
Label: "js",
},
}
assert.DeepEqual(t, got, expected, "payload mismatch")
}
func TestCreateBookDuplicate(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 58,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book data")
// Execute
req := testutils.MakeReq(server.URL, "POST", "/v3/books", `{"name": "js"}`)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusConflict, "")
var bookRecord database.Book
var bookCount, noteCount int
var userRecord database.User
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, b1.USN, "book usn mismatch")
assert.Equal(t, userRecord.MaxUSN, 101, "user max_usn mismatch")
}
func TestUpdateBook(t *testing.T) {
updatedLabel := "updated-label"
b1UUID := "ead8790f-aff9-4bdf-8eec-f734ccd29202"
b2UUID := "0ecaac96-8d72-4e04-8925-5a21b79a16da"
testCases := []struct {
payload string
bookUUID string
bookDeleted bool
bookLabel string
expectedBookLabel string
}{
{
payload: fmt.Sprintf(`{
"name": "%s"
}`, updatedLabel),
bookUUID: b1UUID,
bookDeleted: false,
bookLabel: "original-label",
expectedBookLabel: updatedLabel,
},
// if a deleted book is updated, it should be un-deleted
{
payload: fmt.Sprintf(`{
"name": "%s"
}`, updatedLabel),
bookUUID: b1UUID,
bookDeleted: true,
bookLabel: "",
expectedBookLabel: updatedLabel,
},
}
for idx, tc := range testCases {
func() {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UUID: tc.bookUUID,
UserID: user.ID,
Label: tc.bookLabel,
Deleted: tc.bookDeleted,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UUID: b2UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
// Executdb,e
endpoint := fmt.Sprintf("/v3/books/%s", tc.bookUUID)
req := testutils.MakeReq(server.URL, "PATCH", endpoint, tc.payload)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, fmt.Sprintf("status code mismatch for test case %d", idx))
var bookRecord database.Book
var userRecord database.User
var noteCount, bookCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 2, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equalf(t, bookRecord.UUID, tc.bookUUID, "book uuid mismatch")
assert.Equalf(t, bookRecord.Label, tc.expectedBookLabel, "book label mismatch")
assert.Equalf(t, bookRecord.USN, 102, "book usn mismatch")
assert.Equalf(t, bookRecord.Deleted, false, "book Deleted mismatch")
assert.Equal(t, userRecord.MaxUSN, 102, fmt.Sprintf("user max_usn mismatch for test case %d", idx))
}()
}
}

View file

@ -0,0 +1,32 @@
package controllers
import (
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/log"
)
// Controllers is a group of controllers
type Controllers struct {
Users *Users
Notes *Notes
Books *Books
Sync *Sync
Static *Static
Health *Health
}
// New returns a new group of controllers
func New(app *app.App, baseDir string) *Controllers {
log.Info(app.Config.PageTemplateDir)
c := Controllers{}
c.Users = NewUsers(app, baseDir)
c.Notes = NewNotes(app)
c.Books = NewBooks(app)
c.Sync = NewSync(app)
c.Static = NewStatic(app, baseDir)
c.Health = NewHealth(app)
return &c
}

View file

@ -0,0 +1,23 @@
package controllers
import (
"net/http"
"github.com/dnote/dnote/pkg/server/app"
)
// NewHealth creates a new Health controller.
// It panics if the necessary templates are not parsed.
func NewHealth(app *app.App) *Health {
return &Health{}
}
// Health is a health controller.
type Health struct {
}
// Index handles GET /
func (n *Health) Index(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}

View file

@ -16,29 +16,30 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
package controllers
import (
"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/testutils"
"github.com/jinzhu/gorm"
)
func TestCheckHealth(t *testing.T) {
// Setup
func TestHealth(t *testing.T) {
defer testutils.ClearData(testutils.DB)
server := MustNewServer(t, &app.App{
DB: &gorm.DB{},
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
// Execute
req := testutils.MakeReq(server.URL, "GET", "/health", "")
req := testutils.MakeReq(server.URL, "GET", "/api/health", "")
res := testutils.HTTPDo(t, req)
// Test

View file

@ -0,0 +1,315 @@
package controllers
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/consts"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/views"
"github.com/gorilla/schema"
"github.com/pkg/errors"
)
func parseRequestData(r *http.Request, dst interface{}) error {
ct := r.Header.Get("Content-Type")
if ct == consts.ContentTypeForm {
if err := parseForm(r, dst); err != nil {
return errors.Wrap(err, "parsing form")
}
return nil
}
// default to JSON
if err := parseJSON(r, dst); err != nil {
return errors.Wrap(err, "parsing JSON")
}
return nil
}
func parseForm(r *http.Request, dst interface{}) error {
if err := r.ParseForm(); err != nil {
return err
}
return parseValues(r.PostForm, dst)
}
func parseURLParams(r *http.Request, dst interface{}) error {
if err := r.ParseForm(); err != nil {
return err
}
return parseValues(r.Form, dst)
}
func parseValues(values url.Values, dst interface{}) error {
dec := schema.NewDecoder()
// Ignore CSRF token field
dec.IgnoreUnknownKeys(true)
if err := dec.Decode(dst, values); err != nil {
return err
}
return nil
}
func parseJSON(r *http.Request, dst interface{}) error {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
return err
}
return 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
}
// 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
}
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
}
type authHeader struct {
scheme string
credential string
}
const (
sessionCookieName = "id"
sessionCookiePath = "/"
)
func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) {
cookie := http.Cookie{
Name: sessionCookieName,
Value: key,
Expires: expires,
Path: sessionCookiePath,
HttpOnly: true,
}
http.SetCookie(w, &cookie)
}
func unsetSessionCookie(w http.ResponseWriter) {
expires := time.Now().Add(time.Hour * -24 * 30)
cookie := http.Cookie{
Name: sessionCookieName,
Value: "",
Expires: expires,
Path: sessionCookiePath,
HttpOnly: true,
}
w.Header().Set("Cache-Control", "no-cache")
http.SetCookie(w, &cookie)
}
// SessionResponse is a response containing a session information
type SessionResponse struct {
Key string `json:"key"`
ExpiresAt int64 `json:"expires_at"`
}
func logError(err error, msg string) {
// log if internal error
// if _, ok := err.(views.PublicError); !ok {
// log.ErrorWrap(err, msg)
// }
log.ErrorWrap(err, msg)
}
func getStatusCode(err error) int {
rootErr := errors.Cause(err)
switch rootErr {
case app.ErrNotFound:
return http.StatusNotFound
case app.ErrLoginInvalid:
return http.StatusUnauthorized
case app.ErrDuplicateEmail, app.ErrEmailRequired, app.ErrPasswordTooShort:
return http.StatusBadRequest
case app.ErrLoginRequired:
return http.StatusUnauthorized
case app.ErrBookUUIDRequired:
return http.StatusBadRequest
case app.ErrEmptyUpdate:
return http.StatusBadRequest
case app.ErrInvalidUUID:
return http.StatusBadRequest
case app.ErrDuplicateBook:
return http.StatusConflict
case app.ErrInvalidToken:
return http.StatusBadRequest
case app.ErrPasswordResetTokenExpired:
return http.StatusGone
case app.ErrPasswordConfirmationMismatch:
return http.StatusBadRequest
case app.ErrInvalidPasswordChangeInput:
return http.StatusBadRequest
case app.ErrInvalidPassword:
return http.StatusUnauthorized
case app.ErrEmailTooLong:
return http.StatusBadRequest
case app.ErrEmailAlreadyVerified:
return http.StatusConflict
case app.ErrMissingToken:
return http.StatusBadRequest
case app.ErrExpiredToken:
return http.StatusGone
}
return http.StatusInternalServerError
}
// handleHTMLError writes the error to the log and sets the error message in the data.
func handleHTMLError(w http.ResponseWriter, r *http.Request, err error, msg string, v *views.View, d views.Data) {
statusCode := getStatusCode(err)
logError(err, msg)
d.SetAlert(err, v.AlertInBody)
v.Render(w, r, &d, statusCode)
}
// handleJSONError logs the error and responds with the given status code with a generic status text
func handleJSONError(w http.ResponseWriter, err error, msg string) {
statusCode := getStatusCode(err)
rootErr := errors.Cause(err)
var respText string
if pErr, ok := rootErr.(views.PublicError); ok {
respText = pErr.Public()
} else {
respText = http.StatusText(statusCode)
}
logError(err, msg)
http.Error(w, respText, statusCode)
}
// respondWithSession makes a HTTP response with the session from the user with the given userID.
// It sets the HTTP-Only cookie for browser clients and also sends a JSON response for non-browser clients.
func respondWithSession(w http.ResponseWriter, statusCode int, session *database.Session) {
setSessionCookie(w, session.Key, session.ExpiresAt)
response := SessionResponse{
Key: session.Key,
ExpiresAt: session.ExpiresAt.Unix(),
}
w.Header().Set("Content-Type", "application/json")
dat, err := json.Marshal(response)
if err != nil {
handleJSONError(w, err, "encoding response")
return
}
w.WriteHeader(statusCode)
w.Write(dat)
}
// 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")
dat, err := json.Marshal(payload)
if err != nil {
handleJSONError(w, err, "encoding response")
return
}
w.WriteHeader(statusCode)
w.Write(dat)
}
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"
}

View file

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

View file

@ -0,0 +1,446 @@
package controllers
import (
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/context"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/operations"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
// NewNotes creates a new Notes controller.
// It panics if the necessary templates are not parsed.
func NewNotes(app *app.App) *Notes {
return &Notes{
app: app,
}
}
var notesPerPage = 30
// Notes is a user controller.
type Notes struct {
app *app.App
}
// escapeSearchQuery escapes the query for full text search
func escapeSearchQuery(searchQuery string) string {
return strings.Join(strings.Fields(searchQuery), "&")
}
func parseSearchQuery(q url.Values) string {
searchStr := q.Get("q")
return escapeSearchQuery(searchStr)
}
func parsePageQuery(q url.Values) (int, error) {
pageStr := q.Get("page")
if len(pageStr) == 0 {
return 1, nil
}
p, err := strconv.Atoi(pageStr)
return p, err
}
func parseGetNotesQuery(q url.Values) (app.GetNotesParams, error) {
yearStr := q.Get("year")
monthStr := q.Get("month")
books := q["book"]
encryptedStr := q.Get("encrypted")
pageStr := q.Get("page")
page, err := parsePageQuery(q)
if err != nil {
return app.GetNotesParams{}, errors.Errorf("invalid page %s", pageStr)
}
if page < 1 {
return app.GetNotesParams{}, errors.Errorf("invalid page %s", pageStr)
}
var year int
if len(yearStr) > 0 {
y, err := strconv.Atoi(yearStr)
if err != nil {
return app.GetNotesParams{}, errors.Errorf("invalid year %s", yearStr)
}
year = y
}
var month int
if len(monthStr) > 0 {
m, err := strconv.Atoi(monthStr)
if err != nil {
return app.GetNotesParams{}, errors.Errorf("invalid month %s", monthStr)
}
if m < 1 || m > 12 {
return app.GetNotesParams{}, errors.Errorf("invalid month %s", monthStr)
}
month = m
}
var encrypted bool
if strings.ToLower(encryptedStr) == "true" {
encrypted = true
} else {
encrypted = false
}
ret := app.GetNotesParams{
Year: year,
Month: month,
Page: page,
Search: parseSearchQuery(q),
Books: books,
Encrypted: encrypted,
PerPage: notesPerPage,
}
return ret, nil
}
func (n *Notes) getNotes(r *http.Request) (app.GetNotesResult, app.GetNotesParams, error) {
user := context.User(r.Context())
if user == nil {
return app.GetNotesResult{}, app.GetNotesParams{}, app.ErrLoginRequired
}
query := r.URL.Query()
p, err := parseGetNotesQuery(query)
if err != nil {
return app.GetNotesResult{}, app.GetNotesParams{}, errors.Wrap(err, "parsing query")
}
res, err := n.app.GetNotes(user.ID, p)
if err != nil {
return app.GetNotesResult{}, app.GetNotesParams{}, errors.Wrap(err, "getting notes")
}
return res, p, nil
}
type noteGroup struct {
Year int
Month int
Data []database.Note
}
type bucketKey struct {
year int
month time.Month
}
func groupNotes(notes []database.Note) []noteGroup {
ret := []noteGroup{}
buckets := map[bucketKey][]database.Note{}
for _, note := range notes {
year := note.UpdatedAt.Year()
month := note.UpdatedAt.Month()
key := bucketKey{year, month}
if _, ok := buckets[key]; !ok {
buckets[key] = []database.Note{}
}
buckets[key] = append(buckets[key], note)
}
keys := []bucketKey{}
for key := range buckets {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
yearI := keys[i].year
yearJ := keys[j].year
monthI := keys[i].month
monthJ := keys[j].month
if yearI == yearJ {
return monthI < monthJ
}
return yearI < yearJ
})
for _, key := range keys {
group := noteGroup{
Year: key.year,
Month: int(key.month),
Data: buckets[key],
}
ret = append(ret, group)
}
return ret
}
func getMaxPage(page, total int) int {
tmp := float64(total) / float64(notesPerPage)
return int(math.Ceil(tmp))
}
// GetNotesResponse is a reponse by getNotesHandler
type GetNotesResponse struct {
Notes []presenters.Note `json:"notes"`
Total int `json:"total"`
}
// V3Index is a v3 handler for getting notes
func (n *Notes) V3Index(w http.ResponseWriter, r *http.Request) {
result, _, err := n.getNotes(r)
if err != nil {
handleJSONError(w, err, "getting notes")
return
}
respondJSON(w, http.StatusOK, GetNotesResponse{
Notes: presenters.PresentNotes(result.Notes),
Total: result.Total,
})
}
func (n *Notes) getNote(r *http.Request) (database.Note, error) {
user := context.User(r.Context())
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
note, ok, err := operations.GetNote(n.app.DB, noteUUID, user)
if !ok {
return database.Note{}, app.ErrNotFound
}
if err != nil {
return database.Note{}, errors.Wrap(err, "finding note")
}
return note, nil
}
// V3Show is api for show
func (n *Notes) V3Show(w http.ResponseWriter, r *http.Request) {
note, err := n.getNote(r)
if err != nil {
handleJSONError(w, err, "getting note")
return
}
respondJSON(w, http.StatusOK, presenters.PresentNote(note))
}
type createNotePayload struct {
BookUUID string `schema:"book_uuid" json:"book_uuid"`
Content string `schema:"content" json:"content"`
AddedOn *int64 `schema:"added_on" json:"added_on"`
EditedOn *int64 `schema:"edited_on" json:"edited_on"`
}
func validateCreateNotePayload(p createNotePayload) error {
if p.BookUUID == "" {
return app.ErrBookUUIDRequired
}
return nil
}
func (n *Notes) create(r *http.Request) (database.Note, error) {
user := context.User(r.Context())
if user == nil {
return database.Note{}, app.ErrLoginRequired
}
var params createNotePayload
if err := parseRequestData(r, &params); err != nil {
return database.Note{}, errors.Wrap(err, "parsing request payload")
}
if err := validateCreateNotePayload(params); err != nil {
return database.Note{}, err
}
var book database.Book
if err := n.app.DB.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil {
return database.Note{}, errors.Wrap(err, "finding book")
}
client := getClientType(r)
note, err := n.app.CreateNote(*user, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false, client)
if err != nil {
return database.Note{}, errors.Wrap(err, "creating note")
}
// preload associations
note.User = *user
note.Book = book
return note, nil
}
func (n *Notes) del(r *http.Request) (database.Note, error) {
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
user := context.User(r.Context())
if user == nil {
return database.Note{}, app.ErrLoginRequired
}
var note database.Note
if err := n.app.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(&note).Error; err != nil {
return database.Note{}, errors.Wrap(err, "finding note")
}
tx := n.app.DB.Begin()
note, err := n.app.DeleteNote(tx, *user, note)
if err != nil {
tx.Rollback()
return database.Note{}, errors.Wrap(err, "deleting note")
}
tx.Commit()
return note, nil
}
// CreateNoteResp is a response for creating a note
type CreateNoteResp struct {
Result presenters.Note `json:"result"`
}
// V3Create creates note
func (n *Notes) V3Create(w http.ResponseWriter, r *http.Request) {
note, err := n.create(r)
if err != nil {
handleJSONError(w, err, "creating note")
return
}
respondJSON(w, http.StatusCreated, CreateNoteResp{
Result: presenters.PresentNote(note),
})
}
type DeleteNoteResp struct {
Status int `json:"status"`
Result presenters.Note `json:"result"`
}
// V3Delete deletes note
func (n *Notes) V3Delete(w http.ResponseWriter, r *http.Request) {
note, err := n.del(r)
if err != nil {
handleJSONError(w, err, "deleting note")
return
}
respondJSON(w, http.StatusOK, DeleteNoteResp{
Status: http.StatusNoContent,
Result: presenters.PresentNote(note),
})
}
type updateNotePayload struct {
BookUUID *string `schema:"book_uuid" json:"book_uuid"`
Content *string `schema:"content" json:"content"`
Public *bool `schema:"public" json:"public"`
}
func validateUpdateNotePayload(p updateNotePayload) error {
if p.BookUUID == nil && p.Content == nil && p.Public == nil {
return app.ErrEmptyUpdate
}
return nil
}
func (n *Notes) update(r *http.Request) (database.Note, error) {
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
user := context.User(r.Context())
if user == nil {
return database.Note{}, app.ErrLoginRequired
}
var params updateNotePayload
err := parseRequestData(r, &params)
if err != nil {
return database.Note{}, errors.Wrap(err, "decoding params")
}
if err := validateUpdateNotePayload(params); err != nil {
return database.Note{}, err
}
var note database.Note
if err := n.app.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(&note).Error; err != nil {
return database.Note{}, errors.Wrap(err, "finding note")
}
tx := n.app.DB.Begin()
note, err = n.app.UpdateNote(tx, *user, note, &app.UpdateNoteParams{
BookUUID: params.BookUUID,
Content: params.Content,
Public: params.Public,
})
if err != nil {
tx.Rollback()
return database.Note{}, errors.Wrap(err, "updating note")
}
var book database.Book
if err := tx.Where("uuid = ? AND user_id = ?", note.BookUUID, user.ID).First(&book).Error; err != nil {
tx.Rollback()
return database.Note{}, errors.Wrapf(err, "finding book %s to preload", note.BookUUID)
}
tx.Commit()
// preload associations
note.User = *user
note.Book = book
return note, nil
}
type updateNoteResp struct {
Status int `json:"status"`
Result presenters.Note `json:"result"`
}
// V3Update updates a note
func (n *Notes) V3Update(w http.ResponseWriter, r *http.Request) {
note, err := n.update(r)
if err != nil {
handleJSONError(w, err, "updating note")
return
}
respondJSON(w, http.StatusOK, updateNoteResp{
Status: http.StatusOK,
Result: presenters.PresentNote(note),
})
}
// IndexOptions is a handler for OPTIONS endpoint for notes
func (n *Notes) IndexOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}

View file

@ -0,0 +1,770 @@
/* 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 controllers
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"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/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
return presenters.Note{
UUID: n.UUID,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
Body: n.Body,
AddedOn: n.AddedOn,
Public: n.Public,
USN: n.USN,
Book: presenters.NoteBook{
UUID: b.UUID,
Label: b.Label,
},
User: presenters.NoteUser{
UUID: u.UUID,
},
}
}
func TestGetNotes(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
anotherUser := testutils.SetupUserData()
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n1 content",
USN: 11,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
n2 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n2 content",
USN: 14,
Deleted: false,
AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
n3 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n3 content",
USN: 17,
Deleted: false,
AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
n4 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: "n4 content",
USN: 18,
Deleted: false,
AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
n5 := database.Note{
UserID: anotherUser.ID,
BookUUID: b3.UUID,
Body: "n5 content",
USN: 19,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
n6 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "",
USN: 11,
Deleted: true,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
// Execute
endpoint := "/api/v3/notes"
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("%s?year=2018&month=8", endpoint), "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload GetNotesResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record, n1Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
expected := GetNotesResponse{
Notes: []presenters.Note{
getExpectedNotePayload(n2Record, b1, user),
getExpectedNotePayload(n1Record, b1, user),
},
Total: 2,
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetNote(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
privateNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "privateNote content",
Public: false,
}
testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
publicNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "publicNote content",
Public: true,
}
testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
deletedNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Deleted: true,
}
testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing deletedNote")
getURL := func(noteUUID string) string {
return fmt.Sprintf("/api/v3/notes/%s", noteUUID)
}
t.Run("owner accessing private note", func(t *testing.T) {
// Execute
url := getURL(publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("owner accessing public note", func(t *testing.T) {
// Execute
url := getURL(publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing public note", func(t *testing.T) {
// Execute
url := getURL(publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing private note", func(t *testing.T) {
// Execute
url := getURL(privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("guest accessing public note", func(t *testing.T) {
// Execute
url := getURL(publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("guest accessing private note", func(t *testing.T) {
// Execute
url := getURL(privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("nonexistent", func(t *testing.T) {
// Execute
url := getURL("somerandomstring")
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("deleted", func(t *testing.T) {
// Execute
url := getURL(deletedNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
}
func TestCreateNote(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 58,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
// Execute
dat := fmt.Sprintf(`{"book_uuid": "%s", "content": "note content"}`, b1.UUID)
req := testutils.MakeReq(server.URL, "POST", "/api/v3/notes", dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var noteRecord database.Note
var bookRecord database.Book
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, bookRecord.Label, b1.Label, "book name mismatch")
assert.Equal(t, bookRecord.UUID, b1.UUID, "book uuid mismatch")
assert.Equal(t, bookRecord.UserID, b1.UserID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, 58, "book usn mismatch")
assert.NotEqual(t, noteRecord.UUID, "", "note uuid should have been generated")
assert.Equal(t, noteRecord.BookUUID, b1.UUID, "note book_uuid mismatch")
assert.Equal(t, noteRecord.Body, "note content", "note content mismatch")
assert.Equal(t, noteRecord.USN, 102, "note usn mismatch")
}
func TestDeleteNote(t *testing.T) {
b1UUID := "37868a8e-a844-4265-9a4f-0be598084733"
testCases := []struct {
content string
deleted bool
originalUSN int
expectedUSN int
expectedMaxUSN int
}{
{
content: "n1 content",
deleted: false,
originalUSN: 12,
expectedUSN: 982,
expectedMaxUSN: 982,
},
{
content: "",
deleted: true,
originalUSN: 12,
expectedUSN: 982,
expectedMaxUSN: 982,
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 981), "preparing user max_usn")
b1 := database.Book{
UUID: b1UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
note := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: tc.content,
Deleted: tc.deleted,
USN: tc.originalUSN,
}
testutils.MustExec(t, testutils.DB.Save(&note), "preparing note")
// Execute
endpoint := fmt.Sprintf("/api/v3/notes/%s", note.UUID)
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var bookRecord database.Book
var noteRecord database.Note
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, noteRecord.UUID, note.UUID, "note uuid mismatch for test case")
assert.Equal(t, noteRecord.Body, "", "note content mismatch for test case")
assert.Equal(t, noteRecord.Deleted, true, "note deleted mismatch for test case")
assert.Equal(t, noteRecord.BookUUID, note.BookUUID, "note book_uuid mismatch for test case")
assert.Equal(t, noteRecord.UserID, note.UserID, "note user_id mismatch for test case")
assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note usn mismatch for test case")
assert.Equal(t, userRecord.MaxUSN, tc.expectedMaxUSN, "user max_usn mismatch for test case")
})
}
}
func TestUpdateNote(t *testing.T) {
updatedBody := "some updated content"
b1UUID := "37868a8e-a844-4265-9a4f-0be598084733"
b2UUID := "8f3bd424-6aa5-4ed5-910d-e5b38ab09f8c"
type payloadData struct {
Content *string `schema:"content" json:"content,omitempty"`
BookUUID *string `schema:"book_uuid" json:"book_uuid,omitempty"`
Public *bool `schema:"public" json:"public,omitempty"`
}
testCases := []struct {
payload testutils.PayloadWrapper
noteUUID string
noteBookUUID string
noteBody string
notePublic bool
noteDeleted bool
expectedNoteBody string
expectedNoteBookName string
expectedNoteBookUUID string
expectedNotePublic bool
}{
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Content: &updatedBody,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "some updated content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
BookUUID: &b1UUID,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
BookUUID: &b2UUID,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
BookUUID: &b2UUID,
Content: &updatedBody,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: "some updated content",
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
BookUUID: &b1UUID,
Content: &updatedBody,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "",
noteDeleted: true,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "js",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Public: &testutils.TrueVal,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: true,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Public: &testutils.FalseVal,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: true,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: "original content",
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
Content: &updatedBody,
Public: &testutils.FalseVal,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: true,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b1UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "css",
expectedNotePublic: false,
},
{
payload: testutils.PayloadWrapper{
Data: payloadData{
BookUUID: &b2UUID,
Content: &updatedBody,
Public: &testutils.TrueVal,
},
},
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
noteBookUUID: b1UUID,
notePublic: false,
noteBody: "original content",
noteDeleted: false,
expectedNoteBookUUID: b2UUID,
expectedNoteBody: updatedBody,
expectedNoteBookName: "js",
expectedNotePublic: true,
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
defer server.Close()
user := testutils.SetupUserData()
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UUID: b1UUID,
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UUID: b2UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
note := database.Note{
UserID: user.ID,
UUID: tc.noteUUID,
BookUUID: tc.noteBookUUID,
Body: tc.noteBody,
Deleted: tc.noteDeleted,
Public: tc.notePublic,
}
testutils.MustExec(t, testutils.DB.Save(&note), "preparing note")
// Execute
var req *http.Request
endpoint := fmt.Sprintf("/api/v3/notes/%s", note.UUID)
req = testutils.MakeReq(server.URL, "PATCH", endpoint, tc.payload.ToJSON(t))
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "status code mismatch for test case")
var bookRecord database.Book
var noteRecord database.Note
var userRecord database.User
var noteCount, bookCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(&noteRecord), "finding note")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 2, "book count mismatch")
assert.Equalf(t, noteCount, 1, "note count mismatch")
assert.Equal(t, noteRecord.UUID, tc.noteUUID, "note uuid mismatch for test case")
assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, "note content mismatch for test case")
assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, "note book_uuid mismatch for test case")
assert.Equal(t, noteRecord.Public, tc.expectedNotePublic, "note public mismatch for test case")
assert.Equal(t, noteRecord.USN, 102, "note usn mismatch for test case")
assert.Equal(t, userRecord.MaxUSN, 102, "user max_usn mismatch for test case")
})
}
}

View file

@ -0,0 +1,124 @@
package controllers
import (
"net/http"
"github.com/dnote/dnote/pkg/server/app"
mw "github.com/dnote/dnote/pkg/server/middleware"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
// Route represents a single route
type Route struct {
Method string
Pattern string
Handler http.HandlerFunc
RateLimit bool
}
// RouteConfig is the configuration for routes
type RouteConfig struct {
Controllers *Controllers
WebRoutes []Route
APIRoutes []Route
}
// NewWebRoutes returns a new web routes
func NewWebRoutes(a *app.App, c *Controllers) []Route {
redirectGuest := &mw.AuthParams{RedirectGuestsToLogin: true}
ret := []Route{
{"GET", "/", mw.Auth(a, c.Users.Settings, redirectGuest), true},
{"GET", "/about", mw.Auth(a, c.Users.About, redirectGuest), true},
{"GET", "/login", mw.GuestOnly(a, c.Users.NewLogin), true},
{"POST", "/login", mw.GuestOnly(a, c.Users.Login), true},
{"POST", "/logout", c.Users.Logout, true},
{"GET", "/password-reset", c.Users.PasswordResetView.ServeHTTP, true},
{"PATCH", "/password-reset", c.Users.PasswordReset, true},
{"GET", "/password-reset/{token}", c.Users.PasswordResetConfirm, true},
{"POST", "/reset-token", c.Users.CreateResetToken, true},
{"POST", "/verification-token", mw.Auth(a, c.Users.CreateEmailVerificationToken, redirectGuest), true},
{"GET", "/verify-email/{token}", mw.Auth(a, c.Users.VerifyEmail, redirectGuest), true},
{"PATCH", "/account/profile", mw.Auth(a, c.Users.ProfileUpdate, nil), true},
{"PATCH", "/account/password", mw.Auth(a, c.Users.PasswordUpdate, nil), true},
}
if !a.Config.DisableRegistration {
ret = append(ret, Route{"GET", "/join", c.Users.New, true})
ret = append(ret, Route{"POST", "/join", c.Users.Create, true})
}
return ret
}
// NewAPIRoutes returns a new api routes
func NewAPIRoutes(a *app.App, c *Controllers) []Route {
proOnly := mw.AuthParams{ProOnly: true}
return []Route{
// internal
{"GET", "/health", c.Health.Index, true},
// v3
{"GET", "/v3/sync/fragment", mw.Cors(mw.Auth(a, c.Sync.GetSyncFragment, &proOnly)), false},
{"GET", "/v3/sync/state", mw.Cors(mw.Auth(a, c.Sync.GetSyncState, &proOnly)), false},
{"POST", "/v3/signin", mw.Cors(c.Users.V3Login), true},
{"POST", "/v3/signout", mw.Cors(c.Users.V3Logout), true},
{"OPTIONS", "/v3/signout", mw.Cors(c.Users.logoutOptions), true},
{"GET", "/v3/notes", mw.Cors(mw.Auth(a, c.Notes.V3Index, nil)), true},
{"GET", "/v3/notes/{noteUUID}", c.Notes.V3Show, true},
{"POST", "/v3/notes", mw.Cors(mw.Auth(a, c.Notes.V3Create, nil)), true},
{"DELETE", "/v3/notes/{noteUUID}", mw.Cors(mw.Auth(a, c.Notes.V3Delete, nil)), true},
{"PATCH", "/v3/notes/{noteUUID}", mw.Cors(mw.Auth(a, c.Notes.V3Update, nil)), true},
{"OPTIONS", "/v3/notes", mw.Cors(c.Notes.IndexOptions), true},
{"GET", "/v3/books", mw.Cors(mw.Auth(a, c.Books.V3Index, nil)), true},
{"GET", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Show, nil)), true},
{"POST", "/v3/books", mw.Cors(mw.Auth(a, c.Books.V3Create, nil)), true},
{"PATCH", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Update, nil)), true},
{"DELETE", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Delete, nil)), true},
{"OPTIONS", "/v3/books", mw.Cors(c.Books.IndexOptions), true},
}
}
func registerRoutes(router *mux.Router, wrapper mw.Middleware, app *app.App, routes []Route) {
for _, route := range routes {
wrappedHandler := wrapper(route.Handler, app, route.RateLimit)
router.
Handle(route.Pattern, wrappedHandler).
Methods(route.Method)
}
}
// NewRouter creates and returns a new router
func NewRouter(app *app.App, rc RouteConfig) (http.Handler, error) {
if err := app.Validate(); err != nil {
return nil, errors.Wrap(err, "validating the app parameters")
}
router := mux.NewRouter().StrictSlash(true)
webRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
registerRoutes(webRouter, mw.WebMw, app, rc.WebRoutes)
registerRoutes(apiRouter, mw.APIMw, app, rc.APIRoutes)
router.PathPrefix("/api/v1").Handler(mw.ApplyLimit(mw.NotSupported, true))
router.PathPrefix("/api/v2").Handler(mw.ApplyLimit(mw.NotSupported, true))
// static
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(app.Config.StaticDir)))
router.PathPrefix("/static/").Handler(staticHandler)
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("User-agent: *\nAllow: /"))
})
// catch-all
router.PathPrefix("/").HandlerFunc(rc.Controllers.Static.NotFound)
return mw.Global(router), nil
}

View file

@ -0,0 +1,77 @@
/* 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 controllers
import (
"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/testutils"
)
func TestNotSupportedVersions(t *testing.T) {
testCases := []struct {
path string
}{
// v1
{
path: "/api/v1",
},
{
path: "/api/v1/foo",
},
{
path: "/api/v1/bar/baz",
},
// v2
{
path: "/api/v2",
},
{
path: "/api/v2/foo",
},
{
path: "/api/v2/bar/baz",
},
}
// setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
Config: config.Config{
PageTemplateDir: "../views",
},
})
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")
})
}
}

View file

@ -0,0 +1,35 @@
package controllers
import (
"net/http"
"strings"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/views"
)
// NewStatic creates a new Static controller.
func NewStatic(app *app.App, baseDir string) *Static {
return &Static{
NotFoundView: views.NewView(baseDir, app, views.Config{Title: "Not Found", Layout: "base"}, "static/not_found"),
}
}
// Static is a static controller
type Static struct {
NotFoundView *views.View
}
// NotFound is a catch-all handler for requests with no matching handler
func (s *Static) NotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") {
s.NotFoundView.Render(w, r, nil, http.StatusOK)
} else {
statusText := http.StatusText(http.StatusNotFound)
w.Write([]byte(statusText))
}
}

View file

@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
package controllers
import (
"fmt"
@ -26,13 +26,27 @@ import (
"strconv"
"time"
"github.com/dnote/dnote/pkg/server/context"
"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/middleware"
"github.com/pkg/errors"
"github.com/dnote/dnote/pkg/server/app"
)
// NewSync creates a new Sync controller
func NewSync(app *app.App) *Sync {
return &Sync{
app: app,
}
}
// Sync is a sync controller.
type Sync struct {
app *app.App
}
// fullSyncBefore is the system-wide timestamp that represents the point in time
// before which clients must perform a full-sync rather than incremental sync.
const fullSyncBefore = 0
@ -121,13 +135,13 @@ func (e *queryParamError) Error() string {
return fmt.Sprintf("invalid query param %s=%s. %s", e.key, e.value, e.message)
}
func (a *API) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) {
func (s *Sync) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) {
var notes []database.Note
if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&notes).Error; err != nil {
if err := s.app.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&notes).Error; err != nil {
return SyncFragment{}, nil
}
var books []database.Book
if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil {
if err := s.app.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil {
return SyncFragment{}, nil
}
@ -192,7 +206,7 @@ func (a *API) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment
ret := SyncFragment{
FragMaxUSN: fragMaxUSN,
UserMaxUSN: userMaxUSN,
CurrentTime: a.App.Clock.Now().Unix(),
CurrentTime: s.app.Clock.Now().Unix(),
Notes: fragNotes,
Books: fragBooks,
ExpungedNotes: fragExpungedNotes,
@ -248,29 +262,29 @@ type GetSyncFragmentResp struct {
}
// GetSyncFragment responds with a sync fragment
func (a *API) GetSyncFragment(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)
func (s *Sync) GetSyncFragment(w http.ResponseWriter, r *http.Request) {
user := context.User(r.Context())
if user == nil {
middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
afterUSN, limit, err := parseGetSyncFragmentQuery(r.URL.Query())
if err != nil {
handlers.DoError(w, "parsing query params", err, http.StatusInternalServerError)
middleware.DoError(w, "parsing query params", err, http.StatusInternalServerError)
return
}
fragment, err := a.newFragment(user.ID, user.MaxUSN, afterUSN, limit)
fragment, err := s.newFragment(user.ID, user.MaxUSN, afterUSN, limit)
if err != nil {
handlers.DoError(w, "getting fragment", err, http.StatusInternalServerError)
middleware.DoError(w, "getting fragment", err, http.StatusInternalServerError)
return
}
response := GetSyncFragmentResp{
Fragment: fragment,
}
handlers.RespondJSON(w, http.StatusOK, response)
respondJSON(w, http.StatusOK, response)
}
// GetSyncStateResp represents a response from GetSyncFragment handler
@ -281,10 +295,10 @@ type GetSyncStateResp struct {
}
// GetSyncState responds with a sync fragment
func (a *API) GetSyncState(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)
func (s *Sync) GetSyncState(w http.ResponseWriter, r *http.Request) {
user := context.User(r.Context())
if user == nil {
middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
@ -292,7 +306,7 @@ func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) {
FullSyncBefore: fullSyncBefore,
MaxUSN: user.MaxUSN,
// TODO: exposing server time means we probably shouldn't seed random generator with time?
CurrentTime: a.App.Clock.Now().Unix(),
CurrentTime: s.app.Clock.Now().Unix(),
}
log.WithFields(log.Fields{
@ -300,5 +314,5 @@ func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) {
"resp": response,
}).Info("getting sync state")
handlers.RespondJSON(w, http.StatusOK, response)
respondJSON(w, http.StatusOK, response)
}

View file

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

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
package controllers
import (
"net/http/httptest"
@ -29,20 +29,29 @@ import (
// MustNewServer is a test utility function to initialize a new server
// with the given app paratmers
func MustNewServer(t *testing.T, appParams *app.App) *httptest.Server {
api := NewTestAPI(appParams)
r, err := NewRouter(&api)
server, err := NewServer(appParams)
if err != nil {
t.Fatal(errors.Wrap(err, "initializing server"))
t.Fatal(errors.Wrap(err, "initializing router"))
}
server := httptest.NewServer(r)
return server
}
// NewTestAPI returns a new API for test
func NewTestAPI(appParams *app.App) API {
func NewServer(appParams *app.App) (*httptest.Server, error) {
a := app.NewTest(appParams)
return API{App: &a}
ctl := New(&a, a.Config.PageTemplateDir)
rc := RouteConfig{
WebRoutes: NewWebRoutes(&a, ctl),
APIRoutes: NewAPIRoutes(&a, ctl),
Controllers: ctl,
}
r, err := NewRouter(&a, rc)
if err != nil {
return nil, errors.Wrap(err, "initializing router")
}
server := httptest.NewServer(r)
return server, nil
}

View file

@ -0,0 +1,718 @@
package controllers
import (
"net/http"
"net/url"
"time"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/buildinfo"
"github.com/dnote/dnote/pkg/server/context"
"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/dnote/dnote/pkg/server/views"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
var commonHelpers = map[string]interface{}{
"getPathWithReferrer": func(base string, referrer string) string {
if referrer == "" {
return base
}
query := url.Values{}
query.Set("referrer", referrer)
return helpers.GetPath(base, &query)
},
}
// NewUsers creates a new Users controller.
// It panics if the necessary templates are not parsed.
func NewUsers(app *app.App, baseDir string) *Users {
return &Users{
NewView: views.NewView(baseDir, app,
views.Config{Title: "Join", Layout: "base", HelperFuncs: commonHelpers, AlertInBody: true},
"users/new",
),
LoginView: views.NewView(baseDir, app,
views.Config{Title: "Sign In", Layout: "base", HelperFuncs: commonHelpers, AlertInBody: true},
"users/login",
),
PasswordResetView: views.NewView(baseDir, app,
views.Config{Title: "Reset Password", Layout: "base", HelperFuncs: commonHelpers, AlertInBody: true},
"users/password_reset",
),
PasswordResetConfirmView: views.NewView(baseDir, app,
views.Config{Title: "Reset Password", Layout: "base", HelperFuncs: commonHelpers, AlertInBody: true},
"users/password_reset_confirm",
),
SettingView: views.NewView(baseDir, app,
views.Config{Layout: "base", HelperFuncs: commonHelpers, HeaderTemplate: "navbar"},
"users/settings",
),
AboutView: views.NewView(baseDir, app,
views.Config{Title: "About", Layout: "base", HelperFuncs: commonHelpers, HeaderTemplate: "navbar"},
"users/settings_about",
),
EmailVerificationView: views.NewView(baseDir, app,
views.Config{Layout: "base", HelperFuncs: commonHelpers, HeaderTemplate: "navbar"},
"users/email_verification",
),
app: app,
}
}
// Users is a user controller.
type Users struct {
NewView *views.View
LoginView *views.View
SettingView *views.View
AboutView *views.View
PasswordResetView *views.View
PasswordResetConfirmView *views.View
EmailVerificationView *views.View
app *app.App
}
// New renders user registration page
func (u *Users) New(w http.ResponseWriter, r *http.Request) {
vd := getDataWithReferrer(r)
u.NewView.Render(w, r, &vd, http.StatusOK)
}
// RegistrationForm is the form data for registering
type RegistrationForm struct {
Email string `schema:"email"`
Password string `schema:"password"`
PasswordConfirmation string `schema:"password_confirmation"`
}
// Create handles register
func (u *Users) Create(w http.ResponseWriter, r *http.Request) {
vd := getDataWithReferrer(r)
var form RegistrationForm
if err := parseForm(r, &form); err != nil {
handleHTMLError(w, r, err, "parsing form", u.NewView, vd)
return
}
vd.Yield["Email"] = form.Email
user, err := u.app.CreateUser(form.Email, form.Password, form.PasswordConfirmation)
if err != nil {
handleHTMLError(w, r, err, "creating user", u.NewView, vd)
return
}
session, err := u.app.SignIn(&user)
if err != nil {
handleHTMLError(w, r, err, "signing in a user", u.LoginView, vd)
return
}
if err := u.app.SendWelcomeEmail(form.Email); err != nil {
log.ErrorWrap(err, "sending welcome email")
}
setSessionCookie(w, session.Key, session.ExpiresAt)
dest := getPathOrReferrer("/", r)
http.Redirect(w, r, dest, http.StatusFound)
}
// LoginForm is the form data for log in
type LoginForm struct {
Email string `schema:"email" json:"email"`
Password string `schema:"password" json:"password"`
}
func (u *Users) login(form LoginForm) (*database.Session, error) {
if form.Email == "" {
return nil, app.ErrEmailRequired
}
if form.Password == "" {
return nil, app.ErrPasswordRequired
}
user, err := u.app.Authenticate(form.Email, form.Password)
if err != nil {
// If the user is not found, treat it as invalid login
if err == app.ErrNotFound {
return nil, app.ErrLoginInvalid
}
return nil, err
}
s, err := u.app.SignIn(user)
if err != nil {
return nil, err
}
return s, nil
}
func getPathOrReferrer(path string, r *http.Request) string {
q := r.URL.Query()
referrer := q.Get("referrer")
if referrer == "" {
return path
}
return referrer
}
func getDataWithReferrer(r *http.Request) views.Data {
vd := views.Data{}
vd.Yield = map[string]interface{}{
"Referrer": r.URL.Query().Get("referrer"),
}
return vd
}
// NewLogin renders user login page
func (u *Users) NewLogin(w http.ResponseWriter, r *http.Request) {
vd := getDataWithReferrer(r)
u.LoginView.Render(w, r, &vd, http.StatusOK)
}
// Login handles login
func (u *Users) Login(w http.ResponseWriter, r *http.Request) {
vd := getDataWithReferrer(r)
var form LoginForm
if err := parseRequestData(r, &form); err != nil {
handleHTMLError(w, r, err, "parsing payload", u.LoginView, vd)
return
}
session, err := u.login(form)
if err != nil {
vd.Yield["Email"] = form.Email
handleHTMLError(w, r, err, "logging in user", u.LoginView, vd)
return
}
setSessionCookie(w, session.Key, session.ExpiresAt)
dest := getPathOrReferrer("/", r)
http.Redirect(w, r, dest, http.StatusFound)
}
// V3Login handles login
func (u *Users) V3Login(w http.ResponseWriter, r *http.Request) {
var form LoginForm
if err := parseRequestData(r, &form); err != nil {
handleJSONError(w, err, "parsing payload")
return
}
session, err := u.login(form)
if err != nil {
handleJSONError(w, err, "logging in user")
return
}
respondWithSession(w, http.StatusOK, session)
}
func (u *Users) logout(r *http.Request) (bool, error) {
key, err := GetCredential(r)
if err != nil {
return false, errors.Wrap(err, "getting credentials")
}
if key == "" {
return false, nil
}
if err = u.app.DeleteSession(key); err != nil {
return false, errors.Wrap(err, "deleting session")
}
return true, nil
}
// Logout handles logout
func (u *Users) Logout(w http.ResponseWriter, r *http.Request) {
var vd views.Data
ok, err := u.logout(r)
if err != nil {
handleHTMLError(w, r, err, "logging out", u.LoginView, vd)
return
}
if ok {
unsetSessionCookie(w)
}
http.Redirect(w, r, "/login", http.StatusFound)
}
// V3Logout handles logout via API
func (u *Users) V3Logout(w http.ResponseWriter, r *http.Request) {
ok, err := u.logout(r)
if err != nil {
handleJSONError(w, err, "logging out")
return
}
if ok {
unsetSessionCookie(w)
}
w.WriteHeader(http.StatusNoContent)
}
type createResetTokenPayload struct {
Email string `schema:"email" json:"email"`
}
func (u *Users) CreateResetToken(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
var form createResetTokenPayload
if err := parseForm(r, &form); err != nil {
handleHTMLError(w, r, err, "parsing form", u.PasswordResetView, vd)
return
}
if form.Email == "" {
handleHTMLError(w, r, app.ErrEmailRequired, "email is not provided", u.PasswordResetView, vd)
return
}
var account database.Account
conn := u.app.DB.Where("email = ?", form.Email).First(&account)
if conn.RecordNotFound() {
return
}
if err := conn.Error; err != nil {
handleHTMLError(w, r, err, "finding account", u.PasswordResetView, vd)
return
}
resetToken, err := token.Create(u.app.DB, account.UserID, database.TokenTypeResetPassword)
if err != nil {
handleHTMLError(w, r, err, "generating token", u.PasswordResetView, vd)
return
}
if err := u.app.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil {
handleHTMLError(w, r, err, "sending password reset email", u.PasswordResetView, vd)
return
}
alert := views.Alert{
Level: views.AlertLvlSuccess,
Message: "Check your email for a link to reset your password.",
}
views.RedirectAlert(w, r, "/password-reset", http.StatusFound, alert)
}
// PasswordResetConfirm renders password reset view
func (u *Users) PasswordResetConfirm(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
vars := mux.Vars(r)
token := vars["token"]
vd.Yield = map[string]interface{}{
"Token": token,
}
u.PasswordResetConfirmView.Render(w, r, &vd, http.StatusOK)
}
type resetPasswordPayload struct {
Password string `schema:"password" json:"password"`
PasswordConfirmation string `schema:"password_confirmation" json:"password_confirmation"`
Token string `schema:"token" json:"token"`
}
// PasswordReset renders password reset view
func (u *Users) PasswordReset(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
var params resetPasswordPayload
if err := parseForm(r, &params); err != nil {
handleHTMLError(w, r, err, "parsing params", u.NewView, vd)
return
}
vd.Yield = map[string]interface{}{
"Token": params.Token,
}
if params.Password != params.PasswordConfirmation {
handleHTMLError(w, r, app.ErrPasswordConfirmationMismatch, "password mismatch", u.PasswordResetConfirmView, vd)
return
}
var token database.Token
conn := u.app.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token)
if conn.RecordNotFound() {
handleHTMLError(w, r, app.ErrInvalidToken, "invalid token", u.PasswordResetConfirmView, vd)
return
}
if err := conn.Error; err != nil {
handleHTMLError(w, r, err, "finding token", u.PasswordResetConfirmView, vd)
return
}
if token.UsedAt != nil {
handleHTMLError(w, r, app.ErrInvalidToken, "invalid token", u.PasswordResetConfirmView, vd)
return
}
// Expire after 10 minutes
if time.Since(token.CreatedAt).Minutes() > 10 {
handleHTMLError(w, r, app.ErrPasswordResetTokenExpired, "expired token", u.PasswordResetConfirmView, vd)
return
}
tx := u.app.DB.Begin()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost)
if err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "hashing password", u.PasswordResetConfirmView, vd)
return
}
var account database.Account
if err := u.app.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "finding user", u.PasswordResetConfirmView, vd)
return
}
if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "updating password", u.PasswordResetConfirmView, vd)
return
}
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "updating password reset token", u.PasswordResetConfirmView, vd)
return
}
if err := u.app.DeleteUserSessions(tx, account.UserID); err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "deleting user sessions", u.PasswordResetConfirmView, vd)
return
}
tx.Commit()
var user database.User
if err := u.app.DB.Where("id = ?", account.UserID).First(&user).Error; err != nil {
handleHTMLError(w, r, err, "finding user", u.PasswordResetConfirmView, vd)
return
}
alert := views.Alert{
Level: views.AlertLvlSuccess,
Message: "Password reset successful",
}
views.RedirectAlert(w, r, "/login", http.StatusFound, alert)
if err := u.app.SendPasswordResetAlertEmail(account.Email.String); err != nil {
log.ErrorWrap(err, "sending password reset email")
}
}
func (u *Users) logoutOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version")
}
func (u *Users) Settings(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
u.SettingView.Render(w, r, &vd, http.StatusOK)
}
func (u *Users) About(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
vd.Yield = map[string]interface{}{
"Version": buildinfo.Version,
}
u.AboutView.Render(w, r, &vd, http.StatusOK)
}
type updatePasswordForm struct {
OldPassword string `schema:"old_password"`
NewPassword string `schema:"new_password"`
NewPasswordConfirmation string `schema:"new_password_confirmation"`
}
func (u *Users) PasswordUpdate(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
user := context.User(r.Context())
if user == nil {
handleHTMLError(w, r, app.ErrLoginRequired, "No authenticated user found", u.SettingView, vd)
return
}
var form updatePasswordForm
if err := parseRequestData(r, &form); err != nil {
handleHTMLError(w, r, err, "parsing payload", u.LoginView, vd)
return
}
if form.OldPassword == "" || form.NewPassword == "" {
handleHTMLError(w, r, app.ErrInvalidPasswordChangeInput, "invalid params", u.SettingView, vd)
return
}
if form.NewPassword != form.NewPasswordConfirmation {
handleHTMLError(w, r, app.ErrPasswordConfirmationMismatch, "passwords do not match", u.SettingView, vd)
return
}
var account database.Account
if err := u.app.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
handleHTMLError(w, r, err, "getting account", u.SettingView, vd)
return
}
password := []byte(form.OldPassword)
if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Warn("invalid password update attempt")
handleHTMLError(w, r, app.ErrInvalidPassword, "invalid password", u.SettingView, vd)
return
}
if err := validatePassword(form.NewPassword); err != nil {
handleHTMLError(w, r, err, "invalid password", u.SettingView, vd)
return
}
hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
if err != nil {
handleHTMLError(w, r, err, "hashing password", u.SettingView, vd)
return
}
if err := u.app.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil {
handleHTMLError(w, r, err, "updating password", u.SettingView, vd)
return
}
alert := views.Alert{
Level: views.AlertLvlSuccess,
Message: "Password change successful",
}
views.RedirectAlert(w, r, "/", http.StatusFound, alert)
}
func validatePassword(password string) error {
if len(password) < 8 {
return app.ErrPasswordTooShort
}
return nil
}
type updateProfileForm struct {
Email string `schema:"email"`
Password string `schema:"password"`
}
func (u *Users) ProfileUpdate(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
user := context.User(r.Context())
if user == nil {
handleHTMLError(w, r, app.ErrLoginRequired, "No authenticated user found", u.SettingView, vd)
return
}
var account database.Account
if err := u.app.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
handleHTMLError(w, r, err, "getting account", u.SettingView, vd)
return
}
var form updateProfileForm
if err := parseRequestData(r, &form); err != nil {
handleHTMLError(w, r, err, "parsing payload", u.SettingView, vd)
return
}
password := []byte(form.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")
handleHTMLError(w, r, app.ErrInvalidPassword, "Wrong password", u.SettingView, vd)
return
}
// Validate
if len(form.Email) > 60 {
handleHTMLError(w, r, app.ErrEmailTooLong, "Email is too long", u.SettingView, vd)
return
}
tx := u.app.DB.Begin()
if err := tx.Save(&user).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "saving user", u.SettingView, vd)
return
}
// check if email was changed
if form.Email != account.Email.String {
account.EmailVerified = false
}
account.Email.String = form.Email
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "saving account", u.SettingView, vd)
return
}
tx.Commit()
alert := views.Alert{
Level: views.AlertLvlSuccess,
Message: "Email change successful",
}
views.RedirectAlert(w, r, "/", http.StatusFound, alert)
}
func (u *Users) VerifyEmail(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
vars := mux.Vars(r)
tokenValue := vars["token"]
if tokenValue == "" {
handleHTMLError(w, r, app.ErrMissingToken, "Missing email verification token", u.EmailVerificationView, vd)
return
}
var token database.Token
if err := u.app.DB.
Where("value = ? AND type = ?", tokenValue, database.TokenTypeEmailVerification).
First(&token).Error; err != nil {
handleHTMLError(w, r, app.ErrInvalidToken, "Finding token", u.EmailVerificationView, vd)
return
}
if token.UsedAt != nil {
handleHTMLError(w, r, app.ErrInvalidToken, "Token has already been used.", u.EmailVerificationView, vd)
return
}
// Expire after ttl
if time.Since(token.CreatedAt).Minutes() > 30 {
handleHTMLError(w, r, app.ErrExpiredToken, "Token has expired.", u.EmailVerificationView, vd)
return
}
var account database.Account
if err := u.app.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
handleHTMLError(w, r, err, "finding account", u.EmailVerificationView, vd)
return
}
if account.EmailVerified {
handleHTMLError(w, r, app.ErrEmailAlreadyVerified, "Already verified", u.EmailVerificationView, vd)
return
}
tx := u.app.DB.Begin()
account.EmailVerified = true
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "updating email_verified", u.EmailVerificationView, vd)
return
}
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
tx.Rollback()
handleHTMLError(w, r, err, "updating reset token", u.EmailVerificationView, vd)
return
}
tx.Commit()
var user database.User
if err := u.app.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil {
handleHTMLError(w, r, err, "finding user", u.EmailVerificationView, vd)
return
}
session, err := u.app.SignIn(&user)
if err != nil {
handleHTMLError(w, r, err, "Creating session", u.EmailVerificationView, vd)
}
setSessionCookie(w, session.Key, session.ExpiresAt)
http.Redirect(w, r, "/", http.StatusFound)
}
func (u *Users) CreateEmailVerificationToken(w http.ResponseWriter, r *http.Request) {
vd := views.Data{}
user := context.User(r.Context())
if user == nil {
handleHTMLError(w, r, app.ErrLoginRequired, "No authenticated user found", u.SettingView, vd)
return
}
var account database.Account
err := u.app.DB.Where("user_id = ?", user.ID).First(&account).Error
if err != nil {
handleHTMLError(w, r, err, "finding account", u.SettingView, vd)
return
}
if account.EmailVerified {
handleHTMLError(w, r, app.ErrEmailAlreadyVerified, "email is already verified.", u.SettingView, vd)
return
}
if account.Email.String == "" {
handleHTMLError(w, r, app.ErrEmailRequired, "email is empty.", u.SettingView, vd)
return
}
tok, err := token.Create(u.app.DB, account.UserID, database.TokenTypeEmailVerification)
if err != nil {
handleHTMLError(w, r, err, "saving token", u.SettingView, vd)
return
}
if err := u.app.SendVerificationEmail(account.Email.String, tok.Value); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
handleHTMLError(w, r, app.ErrInvalidSMTPConfig, "SMTP config is not configured correctly.", u.SettingView, vd)
} else {
handleHTMLError(w, r, err, "sending verification email", u.SettingView, vd)
}
return
}
alert := views.Alert{
Level: views.AlertLvlSuccess,
Message: "Please check your email for the verification",
}
views.RedirectAlert(w, r, "/", http.StatusFound, alert)
}

Some files were not shown because too many files have changed in this diff Show more